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 + } } }