Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Squirrel.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@
CODE_SIGN_ENTITLEMENTS = resources/Squirrel.entitlements;
COMBINE_HIDPI_IMAGES = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 1.1.2;
DEAD_CODE_STRIPPING = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
Expand Down Expand Up @@ -722,7 +722,7 @@
CLANG_ENABLE_OBJC_ARC = YES;
CODE_SIGN_ENTITLEMENTS = resources/Squirrel.entitlements;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 1.1.2;
DEAD_CODE_STRIPPING = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
Expand Down
31 changes: 31 additions & 0 deletions appcast.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
TriFecta 的 Sparkle 更新源(appcast)。
注意:
- 本项目已关闭 Sparkle 自动检查(SUEnableAutomaticChecks=false),
因此正常运行时不会自动拉取本文件;更新走「设置 → 关于 → 检查 TriFecta 更新」。
- 若要启用 Sparkle 自动更新,需为每个 Release 生成 EdDSA 签名(sparkle:edSignature),
并将本文件放在可公网访问的位置(如 GitHub Pages 或本仓库 raw 地址)。
- 本文件当前作为 TriFecta 更新源的占位/参考,指向 thesadbee/TriFecta 的 Release。
-->
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>TriFecta 更新</title>
<link>https://github.com/thesadbee/TriFecta/releases</link>
<description>TriFecta(基于 Rime/Squirrel 的 macOS 中文输入法)更新源</description>
<item>
<title>TriFecta v1.1.1</title>
<sparkle:version>1.1.1</sparkle:version>
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://github.com/thesadbee/TriFecta/releases/tag/v1.1.1</sparkle:releaseNotesLink>
<pubDate>Fri, 28 Aug 2026 03:13:18 +0000</pubDate>
<!-- sparkle:edSignature 需由发布流水线用 TriFecta 的 Sparkle EdDSA 私钥生成 -->
<enclosure url="https://github.com/thesadbee/TriFecta/releases/download/v1.1.1/TriFecta.pkg"
sparkle:version="1.1.1"
sparkle:shortVersionString="1.1.1"
length="17837706"
type="application/octet-stream" />
</item>
</channel>
</rss>
4 changes: 2 additions & 2 deletions resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<false/>
<key>SUFeedURL</key>
<string>https://rime.github.io/release/squirrel/appcast.xml</string>
<string>https://raw.githubusercontent.com/thesadbee/TriFecta/main/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>ukvWq2dKOWn3B9AsdsQIwOptiDdDKdUjAVNgFxSvB2o=</string>
<key>TICapsLockLanguageSwitchCapable</key>
Expand Down
137 changes: 127 additions & 10 deletions settings/Sources/TriFectaSettings/Pages/AboutPage.swift
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
//
// 关于:版本信息、GitHub 链接、检查更新。
// 关于:版本信息、GitHub 链接、更新检查。
// 更新分为两块,避免混淆:
// - Squirrel 更新:来自上游 rime/Squirrel 仓库(引擎本体),与 TriFecta 版本无关;
// - TriFecta 更新:来自 thesadbee/TriFecta 仓库(项目自身的发布版本)。
//
import SwiftUI
import AppKit

struct AboutPage: View {
@EnvironmentObject private var state: AppState
@Environment(\.appTheme) private var theme
@StateObject private var updater = AboutUpdateModel()

private enum Links {
static let repo = "https://github.com/thesadbee/TriFecta"
static let releases = "https://github.com/thesadbee/TriFecta/releases"
static let squirrelReleases = "https://github.com/rime/squirrel/releases"
static let squirrelFeed = "https://rime.github.io/release/squirrel/appcast.xml"
}

private var imeVersion: String {
let plist = NSDictionary(contentsOf: state.repo.paths.imeInfoPlist) as? [String: Any]
let short = plist?["CFBundleShortVersionString"] as? String ?? "—"
let short = plist?["CFBundleShortVersionString"] as? String
let build = plist?["CFBundleVersion"] as? String ?? "—"
return "\(short) (\(build))"
if let s = short, !s.isEmpty { return "\(s) (\(build))" }
return build
}

/// TriFecta 当前版本(对应发布 tag 的 v 前缀形式)
private var trifectaCurrent: String { "v" + imeVersion }

private var settingsVersion: String {
let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
return short
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
}

private func open(_ url: String) {
Expand All @@ -31,6 +40,21 @@ struct AboutPage: View {
}
}

// 上游 Squirrel 版本行:当前 → 最新
private var squirrelLine: String {
let latest = updater.squirrelLatest ?? "获取中…"
return "当前 \(imeVersion) → 上游最新 \(latest)"
}

// TriFecta 版本行:当前 → 最新(含“有新版本”提示)
private var trifectaLine: String {
if let lt = updater.trifectaLatest {
let hasNew = AboutUpdateModel.isNewer(current: trifectaCurrent, latest: lt)
return "当前 \(trifectaCurrent) → 最新 \(lt)\(hasNew ? "(有新版本)" : "")"
}
return "当前 \(trifectaCurrent) → 最新 获取中…"
}

var body: some View {
PageScroll(title: "关于", footer: "TriFecta:基于 Rime/Squirrel 的 macOS 中文输入法(GPL-3.0)") {
SettingCard {
Expand All @@ -47,17 +71,110 @@ struct AboutPage: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
Divider()
}

SettingCard {
// —— 上游 Squirrel 引擎更新(与 TriFecta 版本无关)——
SettingRow("Squirrel 更新(上游)",
subtitle: "上游 rime / 鼠鬚管 引擎版本,非 TriFecta 版本更新",
icon: "arrow.triangle.2.circlepath",
divider: false) {
VStack(alignment: .trailing, spacing: 4) {
Text(squirrelLine)
.font(.system(size: 11))
.foregroundColor(.secondary)
Button("打开上游 Releases") { open(Links.squirrelReleases) }
}
}
}

SettingCard {
// —— TriFecta 项目自身更新 ——
SettingRow("TriFecta 更新",
subtitle: "来自 thesadbee/TriFecta 仓库的项目版本",
icon: "arrow.triangle.2.circlepath",
divider: false) {
VStack(alignment: .trailing, spacing: 4) {
Text(trifectaLine)
.font(.system(size: 11))
.foregroundColor(.secondary)
Button(updater.loading ? "检查中…" : "检查 TriFecta 更新") { updater.refresh() }
}
}
}

SettingCard {
SettingRow("GitHub 仓库", icon: "arrow.up.right.square") {
Button("打开") { open(Links.repo) }
}
SettingRow("Rime Wiki(上游文档)", icon: "book") {
SettingRow("Rime Wiki(上游文档)", icon: "book", divider: false) {
Button("打开") { open("https://github.com/rime/home/wiki") }
}
SettingRow("检查更新", icon: "arrow.triangle.2.circlepath", divider: false) {
Button("查看 Releases") { open(Links.releases) }
}
}
}
.task { updater.refresh() }
}
}

/// 拉取两个仓库的最新 release 版本号(tag_name)。
@MainActor
final class AboutUpdateModel: ObservableObject {
@Published var squirrelLatest: String?
@Published var trifectaLatest: String?
@Published var loading = false
@Published var error: String?

func refresh() {
loading = true
let group = DispatchGroup()

var s: String?, t: String?, e: String?

group.enter()
fetchLatest("https://api.github.com/repos/rime/squirrel/releases/latest") { tag, err in
s = tag; if err != nil { e = e ?? err }; group.leave()
}

group.enter()
fetchLatest("https://api.github.com/repos/thesadbee/TriFecta/releases/latest") { tag, err in
t = tag; if err != nil { e = e ?? err }; group.leave()
}

group.notify(queue: .main) {
self.squirrelLatest = s
self.trifectaLatest = t
self.error = e
self.loading = false
}
}

private func fetchLatest(_ urlString: String, _ completion: @escaping (String?, String?) -> Void) {
guard let url = URL(string: urlString) else { completion(nil, "invalid url"); return }
var req = URLRequest(url: url)
req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: req) { data, resp, err in
if let err = err { completion(nil, err.localizedDescription); return }
guard let data = data,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tag = obj["tag_name"] as? String else {
completion(nil, "no tag_name"); return
}
completion(tag, nil)
}.resume()
}

/// 轻量版本比较("v1.1.1" 式):latest 是否比 current 更新
static func isNewer(current: String, latest: String) -> Bool {
let c = current.lowercased().replacingOccurrences(of: "v", with: "")
let l = latest.lowercased().replacingOccurrences(of: "v", with: "")
let cp = c.split(separator: ".").compactMap { Int($0) }
let lp = l.split(separator: ".").compactMap { Int($0) }
let n = max(cp.count, lp.count)
for i in 0..<n {
let a = i < cp.count ? cp[i] : 0
let b = i < lp.count ? lp[i] : 0
if b != a { return b > a }
}
return false
}
}
Loading
Loading