diff --git a/Squirrel.xcodeproj/project.pbxproj b/Squirrel.xcodeproj/project.pbxproj
index c7d5b59..923971f 100644
--- a/Squirrel.xcodeproj/project.pbxproj
+++ b/Squirrel.xcodeproj/project.pbxproj
@@ -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)",
@@ -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)",
diff --git a/appcast.xml b/appcast.xml
new file mode 100644
index 0000000..e8a6e59
--- /dev/null
+++ b/appcast.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ TriFecta 更新
+ https://github.com/thesadbee/TriFecta/releases
+ TriFecta(基于 Rime/Squirrel 的 macOS 中文输入法)更新源
+ -
+ TriFecta v1.1.1
+ 1.1.1
+ 1.1.1
+ https://github.com/thesadbee/TriFecta/releases/tag/v1.1.1
+ Fri, 28 Aug 2026 03:13:18 +0000
+
+
+
+
+
diff --git a/resources/Info.plist b/resources/Info.plist
index df13876..e08090d 100644
--- a/resources/Info.plist
+++ b/resources/Info.plist
@@ -102,9 +102,9 @@
NSPrincipalClass
NSApplication
SUEnableAutomaticChecks
-
+
SUFeedURL
- https://rime.github.io/release/squirrel/appcast.xml
+ https://raw.githubusercontent.com/thesadbee/TriFecta/main/appcast.xml
SUPublicEDKey
ukvWq2dKOWn3B9AsdsQIwOptiDdDKdUjAVNgFxSvB2o=
TICapsLockLanguageSwitchCapable
diff --git a/settings/Sources/TriFectaSettings/Pages/AboutPage.swift b/settings/Sources/TriFectaSettings/Pages/AboutPage.swift
index 617306c..61be4c4 100644
--- a/settings/Sources/TriFectaSettings/Pages/AboutPage.swift
+++ b/settings/Sources/TriFectaSettings/Pages/AboutPage.swift
@@ -1,5 +1,8 @@
//
-// 关于:版本信息、GitHub 链接、检查更新。
+// 关于:版本信息、GitHub 链接、更新检查。
+// 更新分为两块,避免混淆:
+// - Squirrel 更新:来自上游 rime/Squirrel 仓库(引擎本体),与 TriFecta 版本无关;
+// - TriFecta 更新:来自 thesadbee/TriFecta 仓库(项目自身的发布版本)。
//
import SwiftUI
import AppKit
@@ -7,22 +10,28 @@ 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) {
@@ -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 {
@@ -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.. a }
+ }
+ return false
}
}
diff --git a/settings/Sources/TriFectaSettings/Pages/SyncPage.swift b/settings/Sources/TriFectaSettings/Pages/SyncPage.swift
index 86bd3d3..62d6633 100644
--- a/settings/Sources/TriFectaSettings/Pages/SyncPage.swift
+++ b/settings/Sources/TriFectaSettings/Pages/SyncPage.swift
@@ -1,19 +1,28 @@
//
-// 同步用户数据:Rime 无内置同步配置,仅投递 SquirrelSyncNotification。
+// 同步个人配置:迁移/备份 Rime 个人词库与配置。
+// - 导出:把用户个人词库/配置打包成 zip(Rime 可移植格式)。
+// - 导入:从其它 Rime 系输入法导入(.zip / custom_phrase.txt / *.dict.yaml)。
//
import SwiftUI
+import AppKit
+import UniformTypeIdentifiers
import TriFectaSettingsCore
struct SyncPage: View {
@EnvironmentObject private var state: AppState
@State private var syncing = false
+ @State private var busy = false
@State private var message: String?
+ @State private var messageIsError = false
+
+ private var userDir: URL { state.repo.paths.userDir }
var body: some View {
- PageScroll(title: "同步", footer: "需先在 Rime 配置中启用 Git 仓库,否则仅弹出提示。") {
+ PageScroll(title: "同步",
+ footer: "支持从其它 Rime 系输入法(如薄荷输入法)迁移个人词库与配置。") {
SettingCard {
SettingRow("同步用户数据",
- subtitle: "备份词频与使用习惯", divider: false) {
+ subtitle: "备份词频与使用习惯", divider: true) {
if syncing {
ProgressView().controlSize(.small)
} else {
@@ -22,21 +31,180 @@ struct SyncPage: View {
message = nil
DispatchQueue.main.async {
Deployer.syncUserData()
- message = "已投递同步请求(输入法进程后台执行)"
+ setMessage("已投递同步请求(输入法进程后台执行)")
syncing = false
}
}
}
}
+
+ // —— 导出个人配置 ——
+ SettingRow("导出个人配置(词库/配置)",
+ subtitle: "选择路径保存为 Rime 个人词库与配置包(.zip)",
+ divider: true) {
+ Button(busy ? "处理中…" : "导出个人配置…") { exportPersonal() }
+ }
+
+ // —— 导入个人配置 ——
+ SettingRow("导入个人配置(词库/配置)",
+ subtitle: "从其它 Rime 输入法导入(.zip / custom_phrase.txt / *.dict.yaml)",
+ divider: false) {
+ Button(busy ? "处理中…" : "导入个人配置…") { importPersonal() }
+ }
+
if let message = message {
Text(message)
.font(.system(size: 11))
- .foregroundColor(.secondary)
+ .foregroundColor(messageIsError ? .red : .secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
- .padding(.bottom, 10)
+ .padding(.vertical, 8)
+ }
+ }
+ }
+ }
+
+ private func setMessage(_ text: String, isError: Bool = false) {
+ message = text
+ messageIsError = isError
+ }
+
+ // MARK: - 统计可导出的个人配置文件
+
+ private func personalFiles() -> [(name: String, url: URL)] {
+ let fm = FileManager.default
+ var result: [(String, URL)] = []
+
+ guard let contents = try? fm.contentsOfDirectory(at: userDir, includingPropertiesForKeys: [.isRegularFileKey]) else {
+ return result
+ }
+ for url in contents {
+ let name = url.lastPathComponent
+ guard url.pathExtension != "bak" else { continue } // 跳过备份
+ let isCustomYaml = name.hasSuffix(".custom.yaml")
+ let isDictYaml = name.hasSuffix(".dict.yaml") // 用户自定义词典
+ let isCustomPhrase = name == "custom_phrase.txt" || name.hasSuffix("_phrases.txt")
+ let isUserSettings = name == "user.yaml" || name == "user.custom.yaml"
+ if isCustomYaml || isDictYaml || isCustomPhrase || isUserSettings {
+ if (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true {
+ result.append((name, url))
}
}
}
+ return result
+ }
+
+ // MARK: - 导出
+
+ private func exportPersonal() {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.zip]
+ panel.nameFieldStringValue = "TriFecta-Rime个人配置.zip"
+ panel.canCreateDirectories = true
+ guard panel.runModal() == .OK, let dest = panel.url else { return }
+
+ let files = personalFiles()
+ guard !files.isEmpty else {
+ setMessage("未找到可导出的个人词库/配置文件", isError: true)
+ return
+ }
+
+ busy = true
+ DispatchQueue.global(qos: .userInitiated).async {
+ let fm = FileManager.default
+ let tmp = fm.temporaryDirectory.appendingPathComponent("TriFecta-export-\(UUID().uuidString)")
+ do {
+ try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
+ for (name, url) in files {
+ let destURL = tmp.appendingPathComponent(name)
+ if fm.fileExists(atPath: destURL.path) { try? fm.removeItem(at: destURL) }
+ try fm.copyItem(at: url, to: destURL)
+ }
+ try? fm.removeItem(at: dest)
+ let ok = Self.run(["ditto", "-c", "-k", tmp.path, dest.path])
+ try? fm.removeItem(at: tmp)
+ DispatchQueue.main.async {
+ busy = false
+ if ok { setMessage("已导出:\(dest.path)") }
+ else { setMessage("导出失败", isError: true) }
+ }
+ } catch {
+ try? fm.removeItem(at: tmp)
+ DispatchQueue.main.async {
+ busy = false
+ setMessage("导出失败:\(error.localizedDescription)", isError: true)
+ }
+ }
+ }
+ }
+
+ // MARK: - 导入
+
+ private func importPersonal() {
+ let panel = NSOpenPanel()
+ var types: [UTType] = [.zip, .plainText]
+ if let yaml = UTType(filenameExtension: "yaml") { types.append(yaml) }
+ panel.allowedContentTypes = types
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = false
+ guard panel.runModal() == .OK, let src = panel.url else { return }
+
+ busy = true
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ let ext = src.pathExtension.lowercased()
+ let fm = FileManager.default
+ var ok = true
+ if ext == "zip" {
+ try? fm.createDirectory(at: userDir, withIntermediateDirectories: true)
+ ok = Self.run(["ditto", "-x", "-k", src.path, userDir.path])
+ } else if ext == "txt" {
+ // custom_phrase.txt —— luna_pinyin 方案已原生接入 custom_phrase 翻译器,
+ // 直接放入用户数据根目录并重新部署即可生效。
+ try fm.copyItem(at: src, to: userDir.appendingPathComponent("custom_phrase.txt"))
+ } else if ext == "yaml" {
+ try fm.copyItem(at: src, to: userDir.appendingPathComponent(src.lastPathComponent))
+ } else {
+ ok = false
+ DispatchQueue.main.async {
+ busy = false
+ setMessage("不支持的格式(请使用 .zip / .txt / .yaml)", isError: true)
+ }
+ return
+ }
+ DispatchQueue.main.async {
+ busy = false
+ if ok {
+ setMessage("已导入。已触发输入法重新部署(切换方案或稍后使用即生效)。")
+ Deployer.reload(paths: state.repo.paths)
+ } else {
+ setMessage("导入失败", isError: true)
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ busy = false
+ setMessage("导入失败:\(error.localizedDescription)", isError: true)
+ }
+ }
+ }
+ }
+
+ // MARK: - 工具
+
+ @discardableResult
+ private static func run(_ args: [String]) -> Bool {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ proc.arguments = Array(args.dropFirst())
+ proc.standardOutput = FileHandle.nullDevice
+ proc.standardError = FileHandle.nullDevice
+ do {
+ try proc.run()
+ proc.waitUntilExit()
+ return proc.terminationStatus == 0
+ } catch {
+ return false
+ }
}
}