From 2c24ecbfeaa67f34c1155a6a90c339bd8609ac9f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:08:46 +0800 Subject: [PATCH 1/4] Sync and backup: export and import, backups, upstream sharing, multi-device sync Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/overview.json | 21 +- scripts/shots/core/zh/overview.json | 21 +- scripts/shots/mock/core.ts | 50 +- src-tauri/Cargo.lock | 45 +- src-tauri/Cargo.toml | 14 +- src-tauri/src/call.rs | 44 +- src-tauri/src/client_prefs.rs | 354 ++++++++++++ src-tauri/src/clients/mod.rs | 23 + src-tauri/src/export.rs | 191 ++++++- src-tauri/src/gateway.rs | 4 + src-tauri/src/import_link.rs | 240 +++++++- src-tauri/src/import_link/tests.rs | 255 +++++++++ src-tauri/src/lib.rs | 17 + src-tauri/src/notices/rules.rs | 3 + src-tauri/src/prefs.rs | 52 ++ src-tauri/src/scan.rs | 4 + src-tauri/src/settings.rs | 4 + src-tauri/src/sync.rs | 671 ++++++++++++++++++++++ src-tauri/src/updater.rs | 1 + src-tauri/src/window.rs | 2 + src-tauri/src/wire.rs | 54 +- src-tauri/tests/ts_bindings.rs | 3 + src/App.i18n.tsx | 5 +- src/ConfigDialogs.i18n.ts | 24 +- src/ConfigDialogs.tsx | 119 +--- src/aliases/aliases.i18n.ts | 4 +- src/backup/BackupList.tsx | 120 ++++ src/backup/BackupPart.tsx | 45 ++ src/backup/BackupsDialog.tsx | 37 ++ src/backup/DropImport.tsx | 52 ++ src/backup/ExportDialog.tsx | 264 +++++++++ src/backup/ImportDialog.tsx | 481 ++++++++++++++++ src/backup/Passphrase.tsx | 76 +++ src/backup/RestoreDialog.tsx | 338 ++++++++++++ src/backup/SaveVersionDialog.tsx | 74 +++ src/backup/api.ts | 45 ++ src/backup/backup.i18n.ts | 440 +++++++++++++++ src/backup/importFlow.test.ts | 157 ++++++ src/backup/importFlow.ts | 205 +++++++ src/backup/items.test.ts | 78 +++ src/backup/items.ts | 135 +++++ src/backup/parts.tsx | 14 + src/backup/restorePlan.test.ts | 53 ++ src/backup/restorePlan.ts | 73 +++ src/backup/useImport.tsx | 21 + src/connection/Unlinked.tsx | 2 +- src/connection/connection.i18n.tsx | 8 +- src/control.ts | 25 +- src/generated/lite-api.ts | 22 + src/generated/tw-api.ts | 705 +++++++++++++++++++++++- src/i18n/core.zh.json | 118 +++- src/i18n/terminology.md | 5 +- src/keys/DeleteDialog.i18n.ts | 4 +- src/labels.i18n.ts | 17 - src/labels.ts | 19 - src/launch/trouble.i18n.ts | 4 +- src/nav.tsx | 9 +- src/palette/palette.i18n.ts | 4 +- src/palette/sections.ts | 3 + src/repair.i18n.ts | 4 +- src/repair.tsx | 4 +- src/routing/RouteConfirmDialogs.i18n.ts | 8 +- src/routing/RoutingPage.i18n.ts | 4 +- src/settings/GeneralSection.tsx | 4 +- src/settings/SettingsPage.tsx | 11 + src/settings/SyncBackupSection.i18n.ts | 10 + src/settings/SyncBackupSection.tsx | 35 ++ src/shell/Workspace.tsx | 35 +- src/shell/describe.ts | 2 +- src/sync/ConflictDialog.i18n.ts | 56 ++ src/sync/ConflictDialog.tsx | 269 +++++++++ src/sync/Dialogs.tsx | 340 ++++++++++++ src/sync/Fields.tsx | 249 +++++++++ src/sync/JoinWizard.i18n.ts | 84 +++ src/sync/JoinWizard.tsx | 456 +++++++++++++++ src/sync/MergeRows.i18n.ts | 51 ++ src/sync/MergeRows.tsx | 232 ++++++++ src/sync/Notices.tsx | 215 ++++++++ src/sync/SyncPart.i18n.ts | 155 ++++++ src/sync/SyncPart.tsx | 451 +++++++++++++++ src/sync/UpstreamBadges.i18n.ts | 27 + src/sync/UpstreamBadges.tsx | 71 +++ src/sync/api.ts | 53 ++ src/sync/decide.ts | 177 ++++++ src/sync/dialogs.i18n.ts | 71 +++ src/sync/fields.i18n.ts | 86 +++ src/sync/labels.i18n.ts | 91 +++ src/sync/labels.ts | 58 ++ src/sync/sync.test.ts | 235 ++++++++ src/sync/target.ts | 130 +++++ src/sync/useSync.ts | 30 + src/types.ts | 4 + src/ui/README.md | 2 +- src/upstreams/PriceSheetDialog.i18n.ts | 4 +- src/upstreams/ShareDialog.i18n.ts | 132 +++++ src/upstreams/ShareDialog.tsx | 188 +++++++ src/upstreams/UpstreamTable.i18n.ts | 7 + src/upstreams/UpstreamTable.tsx | 43 ++ src/upstreams/UpstreamsPage.i18n.tsx | 20 +- src/upstreams/UpstreamsPage.tsx | 65 ++- src/upstreams/share.test.ts | 199 +++++++ src/upstreams/share.ts | 320 +++++++++++ src/upstreams/upstreamForm.i18n.ts | 4 + src/upstreams/upstreamForm.test.ts | 15 + src/upstreams/upstreamForm.ts | 3 + 105 files changed, 10263 insertions(+), 325 deletions(-) create mode 100644 src-tauri/src/client_prefs.rs create mode 100644 src-tauri/src/sync.rs create mode 100644 src/backup/BackupList.tsx create mode 100644 src/backup/BackupPart.tsx create mode 100644 src/backup/BackupsDialog.tsx create mode 100644 src/backup/DropImport.tsx create mode 100644 src/backup/ExportDialog.tsx create mode 100644 src/backup/ImportDialog.tsx create mode 100644 src/backup/Passphrase.tsx create mode 100644 src/backup/RestoreDialog.tsx create mode 100644 src/backup/SaveVersionDialog.tsx create mode 100644 src/backup/api.ts create mode 100644 src/backup/backup.i18n.ts create mode 100644 src/backup/importFlow.test.ts create mode 100644 src/backup/importFlow.ts create mode 100644 src/backup/items.test.ts create mode 100644 src/backup/items.ts create mode 100644 src/backup/parts.tsx create mode 100644 src/backup/restorePlan.test.ts create mode 100644 src/backup/restorePlan.ts create mode 100644 src/backup/useImport.tsx create mode 100644 src/settings/SyncBackupSection.i18n.ts create mode 100644 src/settings/SyncBackupSection.tsx create mode 100644 src/sync/ConflictDialog.i18n.ts create mode 100644 src/sync/ConflictDialog.tsx create mode 100644 src/sync/Dialogs.tsx create mode 100644 src/sync/Fields.tsx create mode 100644 src/sync/JoinWizard.i18n.ts create mode 100644 src/sync/JoinWizard.tsx create mode 100644 src/sync/MergeRows.i18n.ts create mode 100644 src/sync/MergeRows.tsx create mode 100644 src/sync/Notices.tsx create mode 100644 src/sync/SyncPart.i18n.ts create mode 100644 src/sync/SyncPart.tsx create mode 100644 src/sync/UpstreamBadges.i18n.ts create mode 100644 src/sync/UpstreamBadges.tsx create mode 100644 src/sync/api.ts create mode 100644 src/sync/decide.ts create mode 100644 src/sync/dialogs.i18n.ts create mode 100644 src/sync/fields.i18n.ts create mode 100644 src/sync/labels.i18n.ts create mode 100644 src/sync/labels.ts create mode 100644 src/sync/sync.test.ts create mode 100644 src/sync/target.ts create mode 100644 src/sync/useSync.ts create mode 100644 src/upstreams/ShareDialog.i18n.ts create mode 100644 src/upstreams/ShareDialog.tsx create mode 100644 src/upstreams/share.test.ts create mode 100644 src/upstreams/share.ts diff --git a/scripts/shots/core/en/overview.json b/scripts/shots/core/en/overview.json index fb163d93..6affe4b0 100644 --- a/scripts/shots/core/en/overview.json +++ b/scripts/shots/core/en/overview.json @@ -178,7 +178,8 @@ "kind": "group" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-api-key", @@ -211,7 +212,8 @@ "kind": "group" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -250,7 +252,8 @@ "rule": "catch-all" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -284,7 +287,8 @@ "rule": "qwen" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-api-key", @@ -314,7 +318,8 @@ "rule": "deepseek" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-goog-api-key", @@ -349,7 +354,8 @@ "rule": "gemini" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -380,7 +386,8 @@ "rule": "local-models" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false } ], "proxies": [ diff --git a/scripts/shots/core/zh/overview.json b/scripts/shots/core/zh/overview.json index e3ebeed1..c3189166 100644 --- a/scripts/shots/core/zh/overview.json +++ b/scripts/shots/core/zh/overview.json @@ -178,7 +178,8 @@ "kind": "group" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-api-key", @@ -211,7 +212,8 @@ "kind": "group" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -250,7 +252,8 @@ "rule": "兜底" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -284,7 +287,8 @@ "rule": "Qwen 模型" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-api-key", @@ -314,7 +318,8 @@ "rule": "DeepSeek 模型" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "x-goog-api-key", @@ -349,7 +354,8 @@ "rule": "Gemini 模型" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false }, { "auth_header": "authorization", @@ -380,7 +386,8 @@ "rule": "本地模型" } ], - "signed_in": null + "signed_in": null, + "sync_excluded": false } ], "proxies": [ diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index b82be50b..b288d91f 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -83,12 +83,31 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { GetConfig: () => ({ path: "~/.thinkwatch/config.yaml", text: CONFIG_TEXT, version: configVersion() }), PatchConfig: refuse, PutConfig: refuse, - ConfigHistory: () => [{ version: configVersion(), at_ms: NOW - 2 * DAY, origin: "ui", bytes: 2_714, current: true }], ConfigAt: () => ({ section: null, name: null }), - ConfigRollback: refuse, ConfigRepairPlan: () => ({ base_version: configVersion(), fixes: [] }), RepairConfig: refuse, SaveListen: refuse, + // 导入导出与备份:只读的列一份当前版本,写的拒绝 + ConfigItems: () => overview(lastSeen()).providers.map((p) => ({ item: { kind: "provider", name: p.name }, refs: [] })), + ExportConfig: refuse, + ImportPreview: refuse, + ImportConfig: refuse, + Backups: () => [ + { + id: "0000000001-00000000", + at_ms: NOW - 2 * DAY, + origin: "ui", + device: null, + local: true, + sync: false, + counts: { providers: overview(lastSeen()).providers.length, plugins: 0 }, + core_version: "0.77.0", + current: true, + }, + ], + BackupFile: refuse, + RestorePreview: refuse, + Restore: refuse, History: (req) => { const from = req.from_ms ?? -Infinity; @@ -228,6 +247,33 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { ReorderPlugins: refuse, TrialPlugin: refuse, PluginLogs: () => [], + // 同步(协议 50):截图里没开同步,改的一律拒绝 + SyncStatus: () => ({ + state: "off", + target: null, + encrypted: true, + device: null, + last_synced_ms: null, + failing_since_ms: null, + error: null, + devices: [], + conflicts: 0, + skipped: 0, + notices: [], + excluded: [], + excluded_cascade: [], + }), + SyncTest: refuse, + SyncJoinPreview: refuse, + SyncEnable: refuse, + SyncSave: refuse, + SyncNow: refuse, + SyncConflicts: () => [], + SyncResolve: refuse, + SyncEncryption: refuse, + SyncUnlock: refuse, + SyncDisable: refuse, + SyncRepublish: refuse, }; /** ChatGPT Plus 的两个额度窗口:5 小时用了一半多,每周的三成 */ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13bc0567..c4f76dce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -985,7 +985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2661,7 +2661,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3626,7 +3626,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3682,7 +3682,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4810,7 +4810,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4836,6 +4836,7 @@ name = "thinkwatch-lite" version = "2026.10.24" dependencies = [ "anyhow", + "base64 0.22.1", "block2", "chrono", "dispatch2", @@ -5337,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "serde", "serde_json", @@ -5349,8 +5350,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "memchr", "serde", @@ -5359,8 +5360,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5378,8 +5379,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "serde", "serde_json", @@ -5405,8 +5406,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "serde", "ts-rs", @@ -5414,8 +5415,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "libc", "notify", @@ -5425,8 +5426,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.77.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.77.0#00c9a892675c75c97e29609d762e63accb37fa34" +version = "0.78.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5890,7 +5891,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5fc57877..5a19242e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.77.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } [lib] name = "thinkwatch_lite_lib" @@ -70,6 +70,8 @@ tw-guard = { workspace = true } tw-link = { workspace = true } anyhow = "1" +# 配置包(`.twconfig`)在网页和这一侧之间按 Base64 交接(`export`)。锁里本来就有的那一版 +base64 = "0.22" # 新连接的 id、诊断包的临时目录名 rand = "0.10" libc = "0.2" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index b7b43463..eadfd3c4 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -63,13 +63,11 @@ webview_endpoints![ InFlight, // 在跑的请求此刻生成得多快:流量页在跑的那几行每秒问一次 Live, - // 配置原文与历史 + // 配置原文。光标在哪一段(`ConfigAt`);修改记录由备份取代(下面) GetConfig, PatchConfig, PutConfig, - ConfigHistory, ConfigAt, - ConfigRollback, // 一键修复:修的是磁盘上那一份,界面只交版本号 ConfigRepairPlan, RepairConfig, @@ -173,6 +171,30 @@ webview_endpoints![ ReorderPlugins, TrialPlugin, PluginLogs, + // 导入导出与备份:配置里有哪些项目、导出、导入的预览与写入;备份版本的列表、另存、 + // 恢复的预览与写入。备份的几个在安全模式下照样能用 + ConfigItems, + ExportConfig, + ImportPreview, + ImportConfig, + Backups, + BackupFile, + RestorePreview, + Restore, + // 同步。固定密钥、通用偏好只经过 Rust(`sync`、`client_prefs`) + SyncStatus, + SyncTest, + SyncJoinPreview, + SyncEnable, + SyncSave, + SyncNow, + SyncConflicts, + SyncResolve, + SyncEncryption, + SyncUnlock, + SyncDisable, + // 同步位置空了、keyring 不见了之后,由人选的「重新上传本机配置」 + SyncRepublish, ]; /// 请求按这个端点的类型读一遍再发:**界面发来的形状不对,在这里就停下**, @@ -214,6 +236,11 @@ mod tests { "CreatePluginConfirmed", "SavePluginConfirmed", "ApprovePluginFileConfirmed", + // 哪几把密钥被接管着的客户端用着,由 Rust 这边算(它看得见这台机器上的客户端); + // 通用偏好由 Rust 这边读写(`client_prefs`) + "PinKeys", + "GetClientPrefs", + "PutClientPrefs", ] { assert!(!ALLOWED.contains(&name), "{name}"); } @@ -252,6 +279,17 @@ mod tests { } } + /// 修改记录由备份取代(同步与备份约定 2.1):旧的两个端点不再给界面 + #[test] + fn the_config_history_is_replaced_by_backups() { + for name in ["ConfigHistory", "ConfigRollback"] { + assert!(!ALLOWED.contains(&name), "{name}"); + } + for name in ["Backups", "BackupFile", "RestorePreview", "Restore"] { + assert!(ALLOWED.contains(&name), "{name}"); + } + } + /// 前端那份清单和这里一样。多一个,界面调了会被拒;少一个,界面上的类型 /// 就会允许一个这里不接的调用。 #[test] diff --git a/src-tauri/src/client_prefs.rs b/src-tauri/src/client_prefs.rs new file mode 100644 index 00000000..5bc28074 --- /dev/null +++ b/src-tauri/src/client_prefs.rs @@ -0,0 +1,354 @@ +//! Lite 的通用偏好(语言、外观、菜单栏、提醒、检查更新)交给 core 保存(方案 4.4)。 +//! +//! **为什么交给 core。**这几项要随导出、备份和同步走:在一台电脑上换了语言,另一台跟着 +//! 换。core 不解析它们,整个存成数据目录里的 `client-prefs.json`,变了发 +//! `client_prefs_changed`;这里收到就读回来、当场生效。 +//! +//! **`app.json` 照旧写着一份**:core 起来之前的启动画面、窗口语言要用。两边谁说了算: +//! +//! - **core 说了算**:接上 core、core 说偏好变了时读回来,和 `app.json` 不一样的几项照 core 的 +//! 改(多半是别的设备同步过来的)。core 那边还没有(头一次)就把这边的交上去; +//! - **这台电脑上刚改的说了算**:在设置里改了一项,改完 `app.json` 交给 core。交不上(core +//! 没起来、连着远程)就在数据目录里留一个记号([`PENDING_FILE`]),下次接上本机 core 时 +//! 先交这边的,不读回来盖掉。 +//! +//! **只在连着本机时**。连着远程 core 时偏好只存在这台电脑上(方案 4.4):远程 core 是另一台 +//! 设备,它的偏好不是这台电脑的。 +//! +//! 交上去的是 [`General`] 那几项,加上 core 那份里这一版不认识的键(新版本的 Lite 写下的) +//! 原样留着。 + +use std::sync::atomic::{AtomicU32, Ordering}; + +use tauri::{Emitter, Manager}; + +use crate::error::Out; +use crate::prefs::{self, Prefs}; +use crate::{AppState, data_dir, i18n, menubar, notices, theme}; + +/// 交上去的那几项。键和 `app.json` 里的写法一样 +const FIELDS: [&str; 5] = ["language", "theme", "notices", "menubar", "check_updates"]; + +/// 改过、还没交给本机 core 的记号 +const PENDING_FILE: &str = "client-prefs.pending"; + +/// 通用偏好那几项,写成交给 core 的样子 +pub fn general(p: &Prefs) -> serde_json::Map { + let mut m = serde_json::Map::new(); + m.insert("language".into(), serde_json::json!(p.language)); + m.insert("theme".into(), serde_json::json!(p.theme)); + m.insert("notices".into(), serde_json::json!(p.notices)); + m.insert("menubar".into(), serde_json::json!(p.menubar)); + m.insert("check_updates".into(), serde_json::json!(p.check_updates)); + m +} + +/// 把 core 那一份盖到 `p` 上:**写着的、读得懂的才盖**,没写的、这一版读不懂的保持原样 +/// (和 `prefs::load` 一项一项读是同一个道理)。返回盖完的 +pub fn overlay(p: &Prefs, from: &serde_json::Value) -> Prefs { + let mut out = p.clone(); + let Some(m) = from.as_object() else { + return out; + }; + fn read( + m: &serde_json::Map, + k: &str, + ) -> Option { + serde_json::from_value(m.get(k)?.clone()).ok() + } + if let Some(v) = read(m, "language") { + out.language = v; + } + if let Some(v) = read(m, "theme") { + out.theme = v; + } + if let Some(v) = read(m, "notices") { + out.notices = v; + } + if let Some(v) = read(m, "menubar") { + out.menubar = v; + } + if let Some(v) = read(m, "check_updates") { + out.check_updates = v; + } + out +} + +/// 交给 core 的那一份:这边的几项,盖在 core 那份上(保留这一版不认识的键) +pub fn merged(p: &Prefs, theirs: Option<&serde_json::Value>) -> serde_json::Value { + let mut m = theirs + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + for (k, v) in general(p) { + m.insert(k, v); + } + serde_json::Value::Object(m) +} + +/// 只比那几项 +fn same(a: &Prefs, b: &Prefs) -> bool { + let (a, b) = (general(a), general(b)); + FIELDS.iter().all(|k| a.get(*k) == b.get(*k)) +} + +/// 读、交、盖一次只走一条:读回来的那一份不该落在一次交之前 +static ONE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// 排着还没交上去的几次。有的话读回来的是交之前的样子,不拿它盖 +static QUEUED: AtomicU32 = AtomicU32::new(0); + +fn pending_path() -> std::path::PathBuf { + data_dir().join(PENDING_FILE) +} + +fn mark_pending(on: bool) { + let p = pending_path(); + let r = if on { + std::fs::write(&p, b"1") + } else { + match std::fs::remove_file(&p) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + r => r, + } + }; + if let Err(e) = r { + tracing::debug!("通用偏好的待交记号写不了:{e}"); + } +} + +fn local(app: &tauri::AppHandle) -> bool { + app.try_state::() + .is_some_and(|s| !s.link.is_remote()) +} + +/// 设置里刚改了一项(已经写进 `app.json`):交给本机 core。连着远程时只留记号 +pub fn changed(app: &tauri::AppHandle) { + if !local(app) { + mark_pending(true); + return; + } + QUEUED.fetch_add(1, Ordering::SeqCst); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _one = ONE.lock().await; + let r = push(&app).await; + QUEUED.fetch_sub(1, Ordering::SeqCst); + if let Err(e) = r { + tracing::debug!("通用偏好没交给 core,下次接上再交:{e:#}"); + mark_pending(true); + } + }); +} + +/// 过一会儿读回来(接上 core、core 说变了) +pub fn pull_soon(app: &tauri::AppHandle) { + if !local(app) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _one = ONE.lock().await; + if QUEUED.load(Ordering::SeqCst) > 0 { + // 后面排着一次交:交上去之后 core 会再说一声,那时再读 + return; + } + if let Err(e) = pull(&app).await { + tracing::debug!("没读回通用偏好:{e:#}"); + } + }); +} + +async fn push(app: &tauri::AppHandle) -> anyhow::Result<()> { + let st = app + .try_state::() + .ok_or_else(|| anyhow::anyhow!("not ready"))?; + let theirs = st + .control + .call::(&[], &()) + .await?; + let p = prefs::load(&data_dir()); + let body = tw_api::ClientPrefs { + prefs: Some(merged(&p, theirs.prefs.as_ref().filter(|v| !v.is_null()))), + }; + st.control + .call::(&[], &body) + .await?; + mark_pending(false); + Ok(()) +} + +async fn pull(app: &tauri::AppHandle) -> anyhow::Result<()> { + // 这边有改过没交上去的:先交,不读回来盖掉 + if pending_path().exists() { + return push(app).await; + } + let st = app + .try_state::() + .ok_or_else(|| anyhow::anyhow!("not ready"))?; + let theirs = st + .control + .call::(&[], &()) + .await?; + match theirs.prefs.as_ref().filter(|v| !v.is_null()) { + // core 那边还没有:头一次,把这边的交上去 + None => push(app).await, + Some(v) => { + let v = v.clone(); + apply(app, move |p| *p = overlay(p, &v)).await?; + Ok(()) + } + } +} + +/// 改 `app.json`,**和原来不一样的几项当场生效**:语言换掉窗口和菜单栏的文案、外观换掉 +/// 窗口外观、提醒换一档、菜单栏重画。设置页听 `client-prefs-applied` 重读 +async fn apply( + app: &tauri::AppHandle, + f: impl FnOnce(&mut Prefs) + Send + 'static, +) -> anyhow::Result { + let before = prefs::load(&data_dir()); + let after = crate::clients::blocking(move || prefs::update(&data_dir(), f)) + .await + .map_err(|e| anyhow::anyhow!(e.into_msg().text))??; + if same(&before, &after) { + return Ok(false); + } + if before.language != after.language { + i18n::set(i18n::effective(after.language)); + if let Some(st) = app.try_state::() { + st.menubar_now.notify_one(); + } + let _ = app.emit("language-changed", i18n::current()); + } + if before.theme != after.theme { + theme::apply(app, after.theme); + } + if before.notices != after.notices + && let Some(n) = app.try_state::>() + { + let (n, mode) = (n.inner().clone(), after.notices); + crate::clients::blocking(move || n.set_mode(mode)) + .await + .map_err(|e| anyhow::anyhow!(e.into_msg().text))?; + let _ = app.emit("notice-mode-changed", mode); + } + if before.menubar != after.menubar { + menubar::set_style(after.menubar); + if let Some(st) = app.try_state::() { + st.menubar_now.notify_one(); + } + let _ = app.emit("menubar-style-changed", after.menubar); + } + let _ = app.emit("client-prefs-applied", ()); + Ok(true) +} + +/// 这台电脑上的通用偏好(远程模式下导出时附进配置包,方案 4.4) +#[tauri::command] +pub fn client_prefs() -> serde_json::Value { + serde_json::Value::Object(general(&prefs::load(&data_dir()))) +} + +/// 用一份通用偏好(远程模式下导入配置包时)。和 core 推来的一样:读得懂的几项盖上、当场 +/// 生效;连着本机时随后交给 core +#[tauri::command] +pub async fn apply_client_prefs(app: tauri::AppHandle, prefs: serde_json::Value) -> Out { + let changed = apply(&app, move |p| *p = overlay(p, &prefs)) + .await + .map_err(crate::error::text)?; + if changed { + self::changed(&app); + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::Lang; + use crate::notices::Mode; + use crate::theme::Theme; + + /// 交上去的就是那五项,`null` 是跟随系统 + #[test] + fn the_general_prefs_are_the_five_shared_settings() { + let p = Prefs { + language: Some(Lang::En), + theme: None, + notices: Mode::App, + menubar: menubar::Style::Numbers, + check_updates: false, + ..Prefs::default() + }; + assert_eq!( + serde_json::Value::Object(general(&p)), + serde_json::json!({ + "language": "en", + "theme": null, + "notices": "app", + "menubar": "numbers", + "check_updates": false, + }) + ); + } + + /// 写着的盖上(`null` 也是写着:改回跟随系统);没写的、读不懂的保持原样; + /// 客户端配置位置不在里面,不被碰 + #[test] + fn only_what_is_written_and_understood_is_taken() { + let mine = Prefs { + language: Some(Lang::Zh), + theme: Some(Theme::Dark), + notices: Mode::System, + check_updates: true, + client_locations: std::collections::BTreeMap::from([( + "codex".to_string(), + tw_adopt::locations::Places::default(), + )]), + ..Prefs::default() + }; + let got = overlay( + &mine, + &serde_json::json!({ + "language": "en", + "theme": null, + "notices": "digest", + "added_later": 1, + }), + ); + assert_eq!(got.language, Some(Lang::En)); + assert_eq!(got.theme, None); + assert_eq!(got.notices, Mode::System, "读不懂的一档不盖"); + assert!(got.check_updates, "没写的不盖"); + assert_eq!(got.client_locations, mine.client_locations); + // 不是一份偏好:原样 + assert_eq!(overlay(&mine, &serde_json::json!([1])), mine); + } + + /// 交上去时,core 那份里这一版不认识的键留着 + #[test] + fn keys_this_version_does_not_know_are_kept_on_the_way_up() { + let p = Prefs { + language: Some(Lang::En), + ..Prefs::default() + }; + let up = merged( + &p, + Some(&serde_json::json!({ "language": "zh", "added_later": [1] })), + ); + assert_eq!(up["language"], "en"); + assert_eq!(up["added_later"], serde_json::json!([1])); + assert_eq!(merged(&p, None)["check_updates"], true); + } + + #[test] + fn sameness_only_looks_at_the_shared_settings() { + let a = Prefs::default(); + let mut b = a.clone(); + b.client_locations + .insert("codex".into(), tw_adopt::locations::Places::default()); + assert!(same(&a, &b)); + b.menubar = menubar::Style::Icon; + assert!(!same(&a, &b)); + } +} diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index 4ebdb3fd..0a399178 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -1160,6 +1160,29 @@ pub(crate) fn adopted_owner(keys: &[tw_api::ClientView], name: &str) -> Result Vec { + let (home, backups) = (home_dir(), backups()); + let distros = wsl::distros(); + keys.iter() + .filter(|k| { + ops::adopted_owner(&home, &backups, keys, &k.name).is_some() + || k.client.as_deref().is_some_and(|id| { + tw_adopt::wsl::split_key_id(id).is_some_and(|(client, _)| { + distros + .iter() + .any(|d| tw_adopt::wsl::same_distro(client, &d.name, id)) + }) + }) + }) + .map(|k| k.name.clone()) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 8b783376..136d5fc1 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -10,12 +10,25 @@ //! //! 写盘走 `atomic_file::write`:先写同目录的临时文件再换上去,覆盖已有的文件时写到一半 //! 失败,原来的那份原样留着。 +//! +//! 配置包(`.twconfig`,导出、备份的另存)是二进制:core 交来的是 Base64,网页原样交过来, +//! 这里解开再写(`base64: true`)。**读也在这一侧**([`open_import`]、拖进窗口的文件 +//! [`dropped`]):网页拿到的只是读出来的内容,不能指定读哪个路径。 -use std::path::Path; +use std::path::{Path, PathBuf}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; use tauri_plugin_dialog::DialogExt; -use crate::error::Out; +use crate::error::{CmdError, Out}; +use crate::wire::{DroppedFile, PickedFile}; + +/// 配置包的扩展名 +pub const PACK_EXT: &str = "twconfig"; + +/// 读进来的配置包最大多少(和 core 的上限一样) +pub const MAX_PACK: u64 = 16 * 1024 * 1024; /// 按默认文件名的扩展名给对话框一个类型过滤(`安全日志-….csv` → `CSV`, `csv`)。 /// 没有扩展名就不过滤 @@ -32,13 +45,22 @@ fn filter_of(name: &str) -> Option<(String, String)> { /// /// 对话框挂在发起导出的那个窗口上(macOS 上是从窗口顶上滑下来的那种)。等用户选的这一会儿 /// 不占着运行时的线程:插件在主线程上弹框,选好了从回调里交回来。 +/// +/// `base64`:`contents` 是 Base64,解开之后写(配置包)。不给是一段文字,原样写 #[tauri::command] pub async fn save_export( app: tauri::AppHandle, window: tauri::WebviewWindow, name: String, contents: String, + base64: Option, ) -> Out> { + let bytes = if base64.unwrap_or(false) { + B64.decode(contents.trim()) + .map_err(|e| CmdError::plain(format!("The file contents are not valid Base64: {e}")))? + } else { + contents.into_bytes() + }; let (tx, rx) = tokio::sync::oneshot::channel(); let mut dialog = app.dialog().file().set_file_name(&name).set_parent(&window); if let Some((label, ext)) = filter_of(&name) { @@ -62,7 +84,7 @@ pub async fn save_export( format!("The chosen location cannot be used: {e}") ) })?; - crate::atomic_file::write(&path, contents.as_bytes()).map_err(|e| { + crate::atomic_file::write(&path, &bytes).map_err(|e| { tr!( format!("无法写入文件 {}:{e}", path.display()), format!("The file {} could not be written: {e}", path.display()) @@ -71,10 +93,173 @@ pub async fn save_export( Ok(Some(path.display().to_string())) } +/// 读一个配置包:扩展名是 `.twconfig`、是普通文件、不超过 [`MAX_PACK`]。**内容不在这里解析**: +/// 是不是配置包、要不要口令由 core 的导入预览说 +pub fn read_pack(path: &Path) -> Result { + let shown = path.display(); + let meta = std::fs::metadata(path).map_err(|e| { + tr!( + format!("无法读取 {shown}:{e}"), + format!("{shown} could not be read: {e}") + ) + })?; + if !meta.is_file() { + return Err(tr!( + format!("{shown} 不是文件。"), + format!("{shown} is not a file.") + )); + } + if meta.len() > MAX_PACK { + return Err(tr!( + format!("{shown} 超过 16 MiB,不是配置包。"), + format!("{shown} is larger than 16 MiB and is not a config pack.") + )); + } + let bytes = std::fs::read(path).map_err(|e| { + tr!( + format!("无法读取 {shown}:{e}"), + format!("{shown} could not be read: {e}") + ) + })?; + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + Ok(PickedFile { + name, + data_b64: B64.encode(bytes), + }) +} + +/// 是不是配置包的扩展名(不分大小写) +pub fn is_pack(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case(PACK_EXT)) +} + +/// 弹「打开」对话框,只列配置包,读出所选的那一个。用户取消了是 `None` +#[tauri::command] +pub async fn open_import( + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Out> { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_parent(&window) + .add_filter( + tr!("ThinkWatch 配置包", "ThinkWatch config pack"), + &[PACK_EXT], + ) + .pick_file(move |picked| { + let _ = tx.send(picked); + }); + let picked = rx.await.map_err(|_| { + tr!( + "无法打开「打开」对话框。", + "The open dialog could not be opened." + ) + })?; + let Some(picked) = picked else { + return Ok(None); + }; + let path = picked.into_path().map_err(|e| { + tr!( + format!("无法使用所选的文件:{e}"), + format!("The chosen file cannot be used: {e}") + ) + })?; + tokio::task::spawn_blocking(move || read_pack(&path)) + .await + .map_err(|e| CmdError::plain(e.to_string()))? + .map(Some) + .map_err(CmdError::plain) +} + +/// Tauri 事件:拖着配置包进出主窗口(`true` 进来、`false` 离开或放下了),界面据此画放下的提示 +pub const DRAG_EVENT: &str = "import-drag"; +/// Tauri 事件:拖进主窗口放下的配置包([`DroppedFile`]) +pub const DROP_EVENT: &str = "import-file"; + +/// 拖进来的东西里第一个配置包。别的文件不理 +fn first_pack(paths: &[PathBuf]) -> Option<&PathBuf> { + paths.iter().find(|p| is_pack(p)) +} + +/// 主窗口上的拖放(`lib.rs` 的窗口事件)。**只认配置包**:拖进来的不是它就当没看见;放下时由这 +/// 一侧读文件,网页拿到的是内容,不是路径 +pub fn dropped(window: &tauri::Window, event: &tauri::DragDropEvent) { + use tauri::Emitter; + match event { + tauri::DragDropEvent::Enter { paths, .. } => { + if first_pack(paths).is_some() { + let _ = window.emit(DRAG_EVENT, true); + } + } + tauri::DragDropEvent::Leave => { + let _ = window.emit(DRAG_EVENT, false); + } + tauri::DragDropEvent::Drop { paths, .. } => { + let _ = window.emit(DRAG_EVENT, false); + let Some(path) = first_pack(paths).cloned() else { + return; + }; + let window = window.clone(); + tauri::async_runtime::spawn(async move { + let read = tokio::task::spawn_blocking(move || read_pack(&path)).await; + let out = match read { + Ok(Ok(file)) => DroppedFile::File { file }, + Ok(Err(text)) => DroppedFile::Failed { + error: CmdError::plain(text).into_msg(), + }, + Err(e) => DroppedFile::Failed { + error: CmdError::plain(e.to_string()).into_msg(), + }, + }; + let _ = window.emit(DROP_EVENT, out); + }); + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; + /// 只认 `.twconfig`,不分大小写;别的扩展名、没有扩展名的不当配置包 + #[test] + fn only_twconfig_files_count_as_packs() { + assert!(is_pack(Path::new("/x/ThinkWatch-2026-10-18.twconfig"))); + assert!(is_pack(Path::new("备份.TWConfig"))); + assert!(!is_pack(Path::new("config.yaml"))); + assert!(!is_pack(Path::new("twconfig"))); + let paths = vec![ + PathBuf::from("/a/readme.md"), + PathBuf::from("/a/b.twconfig"), + ]; + assert_eq!(first_pack(&paths), Some(&PathBuf::from("/a/b.twconfig"))); + } + + /// 读出来的是原样的字节(Base64),文件名不带目录;目录、太大的文件读不了 + #[test] + fn a_pack_is_read_as_base64_and_limits_are_kept() { + let dir = std::env::temp_dir().join(format!("tw-export-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.twconfig"); + std::fs::write(&file, [0u8, 1, 2, 255]).unwrap(); + let got = read_pack(&file).unwrap(); + assert_eq!(got.name, "a.twconfig"); + assert_eq!(B64.decode(got.data_b64).unwrap(), vec![0u8, 1, 2, 255]); + assert!(read_pack(&dir).is_err()); + let big = dir.join("big.twconfig"); + let f = std::fs::File::create(&big).unwrap(); + f.set_len(MAX_PACK + 1).unwrap(); + assert!(read_pack(&big).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// 对话框的类型过滤跟着默认文件名的扩展名走;中文文件名照样认,没有扩展名就不过滤 #[test] fn the_save_dialog_filters_by_the_default_names_extension() { diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 55f2614c..d65990c3 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -402,6 +402,8 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { // **每次接上都按现状对一次账**:接上之前 core 报过的(启动时的凭据 // 失效、断线那段里被拒的配置),事件流不会再说一遍 reconcile_notices(&b); + // 同步那几样也是:固定密钥报一次、通用偏好读回来、同步的状态对一次 + crate::sync::on_link(&b); if resumed { let lost = tw_api::Event::EventsDropped { id: 0, @@ -466,6 +468,8 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { } }); } + // 同步的三种:要人处理的状态发通知、改写接管着的客户端的密钥、读回通用偏好 + crate::sync::on_event(&a, &ev); let _ = a.emit("core-event", &ev); }, ); diff --git a/src-tauri/src/import_link.rs b/src-tauri/src/import_link.rs index 6810131d..9b80beb5 100644 --- a/src-tauri/src/import_link.rs +++ b/src-tauri/src/import_link.rs @@ -37,7 +37,7 @@ use tw_api::{BalanceSetting, Billing, Protocol, ZaiFamily}; use crate::wire::{ ImportAuth, ImportAwsKeys, ImportHeader, ImportLink, ImportOAuth, ImportPrice, ImportProposal, - ImportService, ImportSheet, ImportSpec, + ImportService, ImportSheet, ImportSpec, ShareLink, }; /// 链接的前缀。主机名那一段不分大小写 @@ -198,6 +198,16 @@ pub enum Rejected { Missing(Param), } +impl Rejected { + /// 这一条落在哪一项上(整条的毛病是 None) + pub fn param(self) -> Option { + match self { + Self::Invalid(p) | Self::NotOffered(p) | Self::Missing(p) => Some(p), + _ => None, + } + } +} + // ---------------------------------------------------------- 服务类型 /// 链接里的四种协议。ChatGPT 账号要登录、Bedrock 要它自己的凭据,都由服务类型定 @@ -1308,6 +1318,234 @@ fn check_prices(s: &str) -> Result, Rejected> { Ok(out) } +// ---------------------------------------------------------- 生成(分享一个上游) +// +// 上游行的「分享…」:界面按这个上游的配置拼一份提议,这里写成链接。**生成和解析在同一个 +// 模块里**:写出去的每一项都是 [`parse`] 收的写法,和对话框自己会填的一样的不写(服务自己的 +// 地址、固定的协议、默认的认证方式、站点、计费方式)。写完再解析一遍,交回解析出来的那一份 +// ([`share`])—— 接收方点开之后看到的就是它。 + +/// 网页形式的链接:官网读完 `#` 之后的片段,打开同样参数的 `thinkwatch://import?…`。片段不发给 +/// 网站的服务器 +pub const WEB_PREFIX: &str = "https://thinkwat.ch/import#"; + +/// 链接里一个值的编码:字母、数字和 `-_.~,` 原样,别的一律 `%XX`(`+` 也编码:解析时它是空格) +fn encode(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for b in v.bytes() { + if b.is_ascii_alphanumeric() || b"-_.~,".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// 价格、倍率写成的十进制数:至多六位小数,末尾的 0 不写(`3`、`0.375`) +fn decimal_text(x: f64) -> String { + let s = format!("{x:.6}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-0" { + "0".into() + } else { + s.into() + } +} + +fn auth_slug(a: ImportAuth) -> &'static str { + match a { + ImportAuth::Key => "key", + ImportAuth::Oauth => "oauth", + ImportAuth::Account => "account", + ImportAuth::AwsKeys => "aws-keys", + } +} + +/// 一个模型的规格:`模型|上下文窗口|输出上限|推理|图片输入`,没写的一格空着 +fn spec_text(s: &ImportSpec) -> String { + let n = |v: Option| v.map(|n| n.to_string()).unwrap_or_default(); + let f = |v: Option| match v { + Some(true) => "y", + Some(false) => "n", + None => "", + }; + format!( + "{}|{}|{}|{}|{}", + s.model, + n(s.context_window), + n(s.max_output_tokens), + f(s.reasoning), + f(s.image_input) + ) +} + +/// 一个模型的价格:`模型|输入|输出|缓存读|5 分钟缓存写|1 小时缓存写`。缓存价和输入价一样的那一格 +/// 空着(解析时按输入价补),末尾空着的几格整个省掉 +fn price_text(p: &ImportPrice) -> String { + let mut f = vec![ + p.model.clone(), + decimal_text(p.input), + decimal_text(p.output), + ]; + let input = decimal_text(p.input); + for c in [p.cache_read, p.cache_write_5m, p.cache_write_1h] { + let c = decimal_text(c); + f.push(if c == input { String::new() } else { c }); + } + while f.len() > 3 && f.last().is_some_and(String::is_empty) { + f.pop(); + } + f.join("|") +} + +/// 一份提议写成链接的参数(`k=v&…`,值已编码),按 [`Param::ALL`] 的顺序 +pub fn query(p: &ImportProposal) -> String { + let mut out: Vec<(Param, String)> = vec![(Param::Service, p.service.slug().into())]; + let mut put = |k: Param, v: String| out.push((k, v)); + if p.auth != ImportAuth::Key { + put(Param::Auth, auth_slug(p.auth).into()); + } + // 站点:地址是哪一边的标准地址就是哪一边,没有地址是 BigModel。和它一样就不写 + if p.service == ImportService::Zai + && let Some(site) = p.site + { + let implied = p + .base_url + .as_deref() + .and_then(zai_site_of) + .unwrap_or(ZaiFamily::Bigmodel); + if site != implied { + put(Param::Site, site.slug().into()); + } + } + if let Some(n) = &p.name { + put(Param::Name, n.clone()); + } + if let Some(u) = &p.base_url { + put(Param::Url, u.clone()); + } + if let Some(x) = p.protocol { + put(Param::Protocol, x.slug().into()); + } + if let Some(k) = &p.key { + put(Param::Key, k.clone()); + } + if let Some(o) = &p.oauth { + put(Param::OauthEndpoint, o.endpoint.clone()); + put(Param::OauthRefresh, o.refresh.clone()); + if let Some(v) = &o.client_id { + put(Param::OauthClientId, v.clone()); + } + if let Some(v) = &o.client_secret { + put(Param::OauthClientSecret, v.clone()); + } + } + if let Some(r) = &p.region { + put(Param::Region, r.clone()); + } + if let Some(a) = &p.aws { + put(Param::AwsAccessKeyId, a.access_key_id.clone()); + put(Param::AwsSecretAccessKey, a.secret_access_key.clone()); + if let Some(t) = &a.session_token { + put(Param::AwsSessionToken, t.clone()); + } + } + if !p.headers.is_empty() { + let lines: Vec = p + .headers + .iter() + .map(|h| format!("{}: {}", h.name, h.value)) + .collect(); + put(Param::Headers, lines.join("\n")); + } + if p.client_identity { + put(Param::ClientIdentity, "on".into()); + } + if let Some(c) = p.concurrency { + put(Param::Concurrency, c.to_string()); + } + if let Some(b) = p.balance { + put(Param::Balance, b.slug().into()); + } + if !p.models.is_empty() { + put(Param::Models, p.models.join(",")); + } + // 空的启用范围写不进链接(空值整条作废):不写,按全部模型 + if let Some(m) = p.models_only.as_ref().filter(|m| !m.is_empty()) { + put(Param::ModelsOnly, m.join(",")); + } + if !p.specs.is_empty() { + let s: Vec = p.specs.iter().map(spec_text).collect(); + put(Param::Specs, s.join(";")); + } + if let Some(b) = p.billing { + put(Param::Billing, b.slug().into()); + } + if let Some(sheet) = &p.price_sheet { + // 只有倍率、或者倍率不是 1 才写倍率:两项都不写就没有这张价目表 + if sheet.multiplier != 1.0 || sheet.prices.is_empty() { + put(Param::PriceMultiplier, decimal_text(sheet.multiplier)); + } + if !sheet.prices.is_empty() { + let s: Vec = sheet.prices.iter().map(price_text).collect(); + put(Param::Prices, s.join(";")); + } + } + let pairs: Vec = out + .iter() + .map(|(k, v)| format!("{}={}", k.name(), encode(v))) + .collect(); + pairs.join("&") +} + +/// `thinkwatch://import?…` +pub fn generate(p: &ImportProposal) -> String { + format!("{PREFIX}?{}", query(p)) +} + +/// `https://thinkwat.ch/import#…`:参数和 [`generate`] 的一样 +pub fn web_link(p: &ImportProposal) -> String { + format!("{WEB_PREFIX}{}", query(p)) +} + +/// 写成链接,再按 [`parse`] 读一遍:读不了是 [`ShareLink::Invalid`],写出来超过 [`MAX_LINK`] 是 +/// [`ShareLink::TooLong`]。**交回读出来的那一份、按它重写的链接**:地址规范化过(主机名小写、 +/// IDN 转 ASCII、去掉末尾的 `/`),同一份提议总是同一条链接 +pub fn share(p: &ImportProposal) -> ShareLink { + let read = |link: &str| match parse(link) { + Ok(ImportLink::Proposal { proposal }) => Ok(*proposal), + Ok(ImportLink::Unsupported { .. }) => Err(None), + Err(why) => Err(why.param().map(|p| p.name().to_string())), + }; + let too_long = |link: &str| ShareLink::TooLong { + length: u32::try_from(link.len()).unwrap_or(u32::MAX), + }; + let first = generate(p); + if first.len() > MAX_LINK { + return too_long(&first); + } + let canonical = match read(&first) { + Ok(q) => q, + Err(param) => return ShareLink::Invalid { param }, + }; + let app = generate(&canonical); + if app.len() > MAX_LINK { + return too_long(&app); + } + ShareLink::Ready { + web: web_link(&canonical), + app, + proposal: Box::new(canonical), + } +} + +/// 分享一个上游:界面按它的配置拼好的提议写成链接([`share`])。不写配置、不发请求 +#[tauri::command] +pub fn share_import_link(proposal: ImportProposal) -> ShareLink { + share(&proposal) +} + // ---------------------------------------------------------- 一次只处理一条 #[derive(Debug)] diff --git a/src-tauri/src/import_link/tests.rs b/src-tauri/src/import_link/tests.rs index 3686f162..b71dfe44 100644 --- a/src-tauri/src/import_link/tests.rs +++ b/src-tauri/src/import_link/tests.rs @@ -1828,3 +1828,258 @@ fn sub2api_import_button_links_are_accepted() { ); assert_eq!(antigravity.protocol, Some(Protocol::Anthropic)); } + +// ---------------------------------------------------------- 生成(分享)与往返 + +/// 生成的链接按同一版解析出来的就是原来那一份;再写一遍还是同一条链接 +#[track_caller] +fn round_trip(q: &ImportProposal) { + let link = generate(q); + assert!(link.len() <= MAX_LINK, "{link}"); + let back = p(&link).unwrap_or_else(|why| panic!("{why:?}: {link}")); + assert_eq!(&back, q, "{link}"); + assert_eq!(generate(&back), link); + // 网页形式的 `#` 之后和 `?` 之后是同一串 + assert_eq!( + web_link(q).strip_prefix(WEB_PREFIX), + link.strip_prefix(&format!("{PREFIX}?")) + ); +} + +/// 一批各种写法的链接(每种服务、每种认证方式、高级设置、模型、规格、价目表):解析出来的 +/// 每一份都能原样写回去 +#[test] +fn every_proposal_the_parser_accepts_round_trips() { + let links: Vec> = vec![ + vec![ + ("url", "https://api.relay.example/v1"), + ("key", "sk-abc123"), + ], + vec![("service", "anthropic"), ("key", "sk-ant-1")], + vec![ + ("service", "openai"), + ("protocol", "openai-responses"), + ("key", "sk-1"), + ], + vec![ + ("service", "openai"), + ("auth", "account"), + ("name", "chatgpt"), + ], + vec![("service", "gemini"), ("key", "AIza-1")], + vec![("service", "deepseek"), ("key", "sk-ds")], + vec![("service", "zai"), ("key", "z-1")], + vec![("service", "zai"), ("site", "zai"), ("key", "z-1")], + vec![("service", "zai"), ("url", ZAI_ZAI), ("key", "z-1")], + vec![("service", "zai"), ("auth", "account"), ("site", "zai")], + vec![("service", "zai"), ("auth", "account")], + vec![ + ("service", "bedrock"), + ("region", "ap-northeast-1"), + ("key", "ABSK-1"), + ], + vec![ + ("service", "bedrock"), + ("auth", "aws-keys"), + ( + "url", + "https://vpce-1.bedrock-runtime.us-west-2.vpce.amazonaws.com", + ), + ("region", "us-west-2"), + ("aws_access_key_id", "AKIAIOSFODNN7EXAMPLE"), + ( + "aws_secret_access_key", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ), + ("aws_session_token", "FQoGZXIvYXdzE+token="), + ], + vec![ + ("service", "openrouter"), + ("key", "sk-or-1"), + ("models", "anthropic/claude-sonnet-5"), + ], + vec![ + ("service", "thinkwatch"), + ("url", "http://gateway.corp"), + ("key", "tw-1"), + ], + vec![ + ("service", "sub2api"), + ("name", "公司中转"), + ("url", "https://relay.example/antigravity"), + ("protocol", "anthropic"), + ("key", "sk-a+b/c=="), + ("client_identity", "on"), + ("concurrency", "8"), + ("models", "claude-opus-5-5,claude-sonnet-5-5"), + ("models_only", "claude-*,gpt-5?"), + ], + vec![ + ("service", "newapi"), + ("url", "https://new.example"), + ("key", "sk-n"), + ], + vec![("service", "ollama")], + vec![ + ("service", "ollama"), + ("url", "http://192.168.1.20:11434"), + ("billing", "per-token"), + ], + vec![ + ("service", "custom"), + ("auth", "oauth"), + ("url", "https://relay.example"), + ("oauth_endpoint", "https://auth.relay.example/oauth/token"), + ("oauth_refresh", "rt-1"), + ("oauth_client_id", "cid"), + ("oauth_client_secret", "csec"), + ("headers", "X-Team: core\nX-Trace: on"), + ("balance", "sub2api"), + ], + vec![ + ("service", "custom"), + ("url", "https://relay.example"), + ("specs", "m-1|200000|32000|y|n;m-2||8192||y"), + ("billing", "free"), + ], + vec![ + ("service", "custom"), + ("url", "https://relay.example"), + ("price_multiplier", "1.055"), + ], + vec![ + ("service", "custom"), + ("url", "https://relay.example"), + ("prices", "m-1|3|15|0.3|3.75|6;m-2|0.5|1.5;m-3|1|2||1.25"), + ], + vec![ + ("service", "custom"), + ("url", "https://relay.example"), + ("price_multiplier", "0.8"), + ("prices", "m-1|3.000001|15"), + ], + ]; + for l in &links { + let q = pp(l).unwrap_or_else(|why| panic!("{why:?}: {l:?}")); + round_trip(&q); + } +} + +/// 和对话框自己会填的一样的不写:服务自己的地址、固定的协议、默认的认证方式、默认的站点、 +/// 没开的转发客户端身份 +#[test] +fn defaults_are_left_out_of_the_link() { + let q = pp(&[("service", "anthropic"), ("key", "sk-ant-1")]).unwrap(); + assert_eq!( + generate(&q), + "thinkwatch://import?service=anthropic&key=sk-ant-1" + ); + let z = pp(&[("service", "zai"), ("url", ZAI_BIGMODEL), ("key", "z")]).unwrap(); + assert_eq!( + generate(&z), + "thinkwatch://import?service=zai&url=https%3A%2F%2Fopen.bigmodel.cn%2Fapi%2Fanthropic&key=z" + ); + let z = pp(&[ + ("service", "zai"), + ("auth", "account"), + ("site", "bigmodel"), + ]) + .unwrap(); + assert_eq!(generate(&z), "thinkwatch://import?service=zai&auth=account"); + // 价目表:倍率是 1 不写;缓存价和输入价一样的那几格空着、末尾的省掉 + let s = pp(&[ + ("service", "custom"), + ("url", "https://r.example"), + ("prices", "m|3|15|3|3.75|3"), + ]) + .unwrap(); + assert!( + generate(&s).ends_with("&prices=m%7C3%7C15%7C%7C3.75"), + "{}", + generate(&s) + ); +} + +/// 分享:交回解析出来的那一份。地址规范化过(主机名小写、末尾没有 `/`) +#[test] +fn sharing_hands_back_what_the_receiver_will_see() { + let mut q = blank("https://Relay.Example/v1/"); + q.service = ImportService::Sub2api; + q.key = Some("sk-1".into()); + q.name = Some("公司中转".into()); + let ShareLink::Ready { app, web, proposal } = share(&q) else { + panic!("{:?}", share(&q)); + }; + assert_eq!( + proposal.base_url.as_deref(), + Some("https://relay.example/v1") + ); + assert_eq!( + app, + "thinkwatch://import?service=sub2api&name=%E5%85%AC%E5%8F%B8%E4%B8%AD%E8%BD%AC&url=https%3A%2F%2Frelay.example%2Fv1&key=sk-1" + ); + assert_eq!( + web, + "https://thinkwat.ch/import#service=sub2api&name=%E5%85%AC%E5%8F%B8%E4%B8%AD%E8%BD%AC&url=https%3A%2F%2Frelay.example%2Fv1&key=sk-1" + ); + round_trip(&proposal); +} + +/// 写不进链接的值(`${变量名}`、链接不收的字符)说是哪一项;整条超过上限说多长 +#[test] +fn values_a_link_cannot_carry_are_named_and_long_links_refused() { + let mut q = blank("https://relay.example"); + q.key = Some("${OPENAI_API_KEY}".into()); + assert_eq!( + share(&q), + ShareLink::Invalid { + param: Some("key".into()) + } + ); + let mut q = blank("https://relay.example"); + q.name = Some("a/b".into()); + assert_eq!( + share(&q), + ShareLink::Invalid { + param: Some("name".into()) + } + ); + let mut q = blank("https://relay.example"); + q.price_sheet = Some(ImportSheet { + multiplier: 1.0, + prices: (0..64) + .map(|i| ImportPrice { + model: format!("vendor/{}-{i:03}", "m".repeat(100)), + input: 123.456789, + output: 234.567891, + cache_read: 12.345678, + cache_write_5m: 156.789012, + cache_write_1h: 246.801357, + }) + .collect(), + }); + match share(&q) { + ShareLink::TooLong { length } => assert!(length as usize > MAX_LINK), + other => panic!("{other:?}"), + } +} + +/// 价格、倍率的写法:至多六位小数,末尾的 0 不写 +#[test] +fn decimals_are_written_without_trailing_zeros() { + assert_eq!(decimal_text(3.0), "3"); + assert_eq!(decimal_text(10.0), "10"); + assert_eq!(decimal_text(0.375), "0.375"); + assert_eq!(decimal_text(1.055), "1.055"); + assert_eq!(decimal_text(0.0), "0"); + assert_eq!(decimal_text(0.000_000_4), "0"); + assert_eq!(decimal_text(3.000_001), "3.000001"); +} + +/// 编码之后只剩链接里安全的字符:`&`、`=`、`+`、`#`、`%`、空格、中文都编码 +#[test] +fn every_value_is_encoded_so_it_stays_one_parameter() { + assert_eq!(encode("a&b=c+d #%"), "a%26b%3Dc%2Bd%20%23%25"); + assert_eq!(encode("中"), "%E4%B8%AD"); + assert_eq!(encode("m-1,m_2.x~"), "m-1,m_2.x~"); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50fdba91..faf68d1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ pub mod autostart; pub mod call; pub mod chatgpt; pub mod cleanup; +/// Lite 的通用偏好交给 core 保存、随同步走(方案 4.4),见模块头上 +pub mod client_prefs; pub mod clients; /// 系统睡醒、改时钟、换时区时叫醒定在钟点上的几处,见模块头上 pub mod clock; @@ -62,6 +64,8 @@ pub mod scan; pub mod settings; pub mod single; pub mod supervisor; +/// 同步在这一侧的几件事:通知、改写接管着的客户端的密钥、固定密钥,见模块头上 +pub mod sync; pub mod theme; pub mod uninstall; pub mod update; @@ -219,6 +223,7 @@ pub fn run() { live::live_subscribe, live::live_unsubscribe, export::save_export, + export::open_import, upstreams::upstream_stats, aliases::alias_hints, notices::commands::notices_list, @@ -232,6 +237,7 @@ pub fn run() { notices::commands::take_pending_view, import_link::take_import_link, import_link::import_link_closed, + import_link::share_import_link, keys::copy_key, keys::copy_gateway_base, keys::gateway_base, @@ -289,6 +295,11 @@ pub fn run() { plugins::plugin_approve_confirmed, scan::scan_clients, diagnostics::save_diagnostics, + sync::pick_sync_folder, + sync::sync_device_name, + sync::sync_pin_keys, + client_prefs::client_prefs, + client_prefs::apply_client_prefs, ]) .setup(|app| { let handle = app.handle().clone(); @@ -591,6 +602,12 @@ pub fn run() { // 常驻 128 MB 不划算。销毁的代价是重开时要重新加载一次页面 // (几百毫秒),`show_main_window` 本来就会在窗口不存在时 // 重建它。 + // 配置包拖进主窗口:放下时打开导入对话框(读文件在 `export` 这一侧) + if let tauri::WindowEvent::DragDrop(drop) = event + && window.label() == "main" + { + export::dropped(window, drop); + } if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let app = window.app_handle().clone(); diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index d74b9e2f..680f9300 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -62,6 +62,9 @@ pub fn default_view(key: &str) -> &'static str { "scan" => MCP, "plugin" => PLUGINS, "keylimit" => KEYS, + // 同步要处理的事在设置的「同步与备份」;密钥改写不成的在客户端页 + "sync" => crate::sync::SYNC_VIEW, + "keysync" => "clients", // 网关、配置文件、监听,以及认不出来的:设置页至少能看到网关在不在跑 _ => SETTINGS, } diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs index 71e4077d..4f28dc9f 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -41,6 +41,11 @@ pub struct Prefs { /// 只写和默认位置不一样的几项。**没写就是都在默认位置**,见 `clients::locations` #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub client_locations: BTreeMap, + /// 这一份 Lite 的标识:16 个十六进制字符,第一次要用时生成([`instance_id`])。core 按它分开 + /// 记各个 Lite 报的固定密钥(几个 Lite 连着同一个远程 core 时各报各的)。**不是通用偏好**: + /// 不导出、不同步 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, } impl Default for Prefs { @@ -52,6 +57,7 @@ impl Default for Prefs { notices: Mode::System, menubar: crate::menubar::Style::Full, client_locations: BTreeMap::new(), + instance: None, } } } @@ -108,6 +114,32 @@ fn from_fields(fields: &Fields) -> Prefs { .collect() }) .unwrap_or(d.client_locations), + instance: field::(fields, "instance").filter(|s| valid_instance(s)), + } +} + +/// 写着的标识像不像样(手改坏了的当没有,重新生成一个) +fn valid_instance(s: &str) -> bool { + s.len() == 16 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// 这一份 Lite 的标识([`Prefs::instance`])。没有就生成一个、存进 `app.json`;存不进去也照样 +/// 交回这一次生成的(下次再生成一个新的,core 那边旧来源 30 天后自己作废) +pub fn instance_id(dir: &Path) -> String { + if let Some(id) = load(dir).instance { + return id; + } + let fresh = crate::connection::store::new_id(); + match update(dir, |p| { + if p.instance.is_none() { + p.instance = Some(fresh.clone()); + } + }) { + Ok(p) => p.instance.unwrap_or(fresh), + Err(e) => { + tracing::debug!("实例标识没存上:{e:#}"); + fresh + } } } @@ -175,6 +207,25 @@ mod tests { (d, p) } + /// 这一份 Lite 的标识:第一次要用时生成、存下,之后一直是它;手改坏了的重新生成。改别的设置 + /// 不会把它冲掉 + #[test] + fn the_instance_id_is_made_once_and_kept() { + let (_d, dir) = tmp(); + let a = instance_id(&dir); + assert!(valid_instance(&a), "{a}"); + assert_eq!(instance_id(&dir), a); + update(&dir, |p| p.check_updates = false).unwrap(); + assert_eq!(load(&dir).instance.as_deref(), Some(a.as_str())); + std::fs::write( + dir.join(PREFS_FILE), + r#"{"check_updates":true,"instance":"not-hex"}"#, + ) + .unwrap(); + let b = instance_id(&dir); + assert!(valid_instance(&b) && b != "not-hex", "{b}"); + } + #[test] fn with_no_settings_file_the_check_is_on_and_the_language_and_theme_follow_the_system() { let (_tmp, dir) = tmp(); @@ -245,6 +296,7 @@ mod tests { scan: Some("/work/claude".into()), }, )]), + instance: Some("0123456789abcdef".into()), }; save(&dir, &want).unwrap(); assert_eq!(load(&dir), want); diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index c6f99230..1a9fa864 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -237,6 +237,10 @@ pub fn restart_client_watch(handle: &tauri::AppHandle) { let h = handle.clone(); let notices = state.notices.clone(); let emit = move |ev: wire::LocalEvent| { + // 客户端的配置动了(接管、还原、手改):在用的密钥可能变了,报给 core + if let wire::LocalEvent::ClientsChanged { .. } = &ev { + crate::sync::pinned_soon(&h); + } if let wire::LocalEvent::ScanAlert { alerts, .. } = &ev && let Some(signal) = crate::notices::rules::scan_alert(alerts.len()) { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4813aaf7..3fc8792c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -60,6 +60,7 @@ pub fn app_language() -> LanguageView { #[tauri::command] pub async fn set_language(app: tauri::AppHandle, setting: Option) -> Out { prefs::change(move |p| p.language = setting).await?; + crate::client_prefs::changed(&app); i18n::set(i18n::effective(setting)); // 菜单栏的文案跟着换,不等下一个事件 if let Some(state) = app.try_state::() { @@ -98,6 +99,7 @@ pub fn app_theme() -> ThemeView { #[tauri::command] pub async fn set_theme(app: tauri::AppHandle, setting: Option) -> Out { prefs::change(move |p| p.theme = setting).await?; + crate::client_prefs::changed(&app); theme::apply(&app, setting); Ok(theme_view()) } @@ -197,6 +199,7 @@ pub async fn set_notice_mode( mode: notices::Mode, ) -> Out { prefs::change(move |p| p.notices = mode).await?; + crate::client_prefs::changed(&app); // 关掉时要清空列表,清空也要落盘 let n = notices.inner().clone(); crate::clients::blocking(move || n.set_mode(mode)).await?; @@ -218,6 +221,7 @@ pub async fn set_menubar_style( style: menubar::Style, ) -> Out { prefs::change(move |p| p.menubar = style).await?; + crate::client_prefs::changed(&app); menubar::set_style(style); state.menubar_now.notify_one(); let _ = app.emit("menubar-style-changed", style); diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs new file mode 100644 index 00000000..7a0fa573 --- /dev/null +++ b/src-tauri/src/sync.rs @@ -0,0 +1,671 @@ +//! 同步(方案第 6 节)在这一侧的几件事。**同步本身在 core**:存储、合并、冲突、版本都是 +//! core 的,界面照着 `/sync` 画、按端点改。这里做 core 做不了的: +//! +//! - **需要处理的状态发一次系统通知**(冲突、同步位置连不上、拒绝访问、口令在别处改了、 +//! 版本过新)。正常运转时什么都不说;判定照例交给通知总线; +//! - **网关密钥换了值,改写这台机器上接管着的客户端**(6.12):同步、恢复、导入覆盖改了 +//! 密钥时 core 说一声(`gateway_keys_changed`),这里走和「更换密钥」同一条路 +//! (`clients::sync_rotated`)。远程模式下也一样:密钥是连着的那个 core 发的; +//! - **哪几把密钥正被接管着的客户端用着**,告诉 core(`PUT /pinned-keys`):同步要删、 +//! 恢复要删的时候,core 据此保留它们; +//! - **打开主窗口时立刻同步一次**(6.5); +//! - 选同步文件夹的系统对话框、本机的计算机名(设备名称的缺省值)。 + +use std::collections::BTreeMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tauri::Manager; +use tw_api::{Msg, SyncState, SyncView}; + +use crate::AppState; +use crate::error::Out; +use crate::notices::{self, Level, Signal}; + +/// 事件流上同步那三种(`gateway` 的事件桥调)。界面那一份由调用方照常转发 +pub fn on_event(app: &tauri::AppHandle, ev: &tw_api::Event) { + match ev { + tw_api::Event::SyncStatus { view, .. } => tell(app, view), + tw_api::Event::GatewayKeysChanged { names, .. } => { + let (app, names) = (app.clone(), names.clone()); + tauri::async_runtime::spawn(async move { keys_changed(&app, names).await }); + } + tw_api::Event::ClientPrefsChanged { .. } => crate::client_prefs::pull_soon(app), + _ => {} + } +} + +/// 事件流接上了(启动、重连、换了连接):按现状对一次。事件流只说「那一刻」,接上之前 +/// 发生的(别的设备改了偏好、同步早就进了冲突)这里补上 +pub fn on_link(app: &tauri::AppHandle) { + pinned_soon(app); + crate::client_prefs::pull_soon(app); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(st) = app.try_state::() else { + return; + }; + match st.control.call::(&[], &()).await { + Ok(v) => tell(&app, &v), + Err(e) => tracing::debug!("没问到同步状态:{e:#}"), + } + }); +} + +// ───────────────────────────────────────────── 通知 + +/// 同步的几种需要处理的状态,各一个键。**都在 `sync:` 下面**,点开落在设置的「同步与备份」 +const KEYS: [&str; 8] = [ + "sync:conflict", + "sync:unreachable", + "sync:auth", + "sync:locked", + "sync:too_new", + "sync:keyring", + "sync:emptied", + "sync:misconfigured", +]; + +/// 每个键此刻说的是什么(标题和正文)。没开着的不在里面 +type Said = BTreeMap<&'static str, (String, String)>; + +/// 这一份同步状态该开着哪几条、各怎么说 +pub fn wanted(b: &SyncView) -> Said { + let mut out = Said::new(); + // 冲突和状态分开看:连不上的时候冲突照样还在 + if b.conflicts > 0 { + let n = b.conflicts; + out.insert( + "sync:conflict", + ( + tr!( + format!("同步有 {n} 项冲突"), + if n == 1 { + "1 Sync Conflict".to_string() + } else { + format!("{n} Sync Conflicts") + } + ), + tr!( + "本机与其他设备将同一对象修改为不同内容。处理之前,本机保留当前内容。", + "This device and another one changed the same item in different ways. Until it is resolved, this device keeps its own version." + ) + .to_string(), + ), + ); + } + let why = b + .error + .as_ref() + .map(crate::core_text::clause) + .unwrap_or_default(); + let said: Option<(&'static str, String, String)> = match b.state { + SyncState::Unreachable => Some(( + "sync:unreachable", + tr!("无法连接同步位置", "Sync Location Unreachable").to_string(), + if why.is_empty() { + tr!( + "恢复后自动继续,本机的修改在恢复后上传。", + "Syncing continues once it is reachable again; changes made on this device are uploaded then." + ) + .to_string() + } else { + tr!( + format!("{why}。恢复后自动继续,本机的修改在恢复后上传。"), + format!( + "{why}. Syncing continues once it is reachable again; changes made on this device are uploaded then." + ) + ) + }, + )), + SyncState::AuthFailed => Some(( + "sync:auth", + tr!("同步位置拒绝访问", "Sync Location Denied Access").to_string(), + tr!( + "请在设置中修改同步位置的连接参数。", + "Change the sync location's connection details in Settings." + ) + .to_string(), + )), + SyncState::Locked => Some(( + "sync:locked", + tr!( + "同步口令已在其他设备上更改", + "Sync Passphrase Changed on Another Device" + ) + .to_string(), + tr!( + "在设置中输入新的同步口令后继续同步。", + "Enter the new sync passphrase in Settings to continue syncing." + ) + .to_string(), + )), + SyncState::TooNew => Some(( + "sync:too_new", + tr!("同步已暂停", "Sync Paused").to_string(), + tr!( + "其他设备使用较新版本,更新 ThinkWatch Lite 后继续同步。", + "Another device uses a newer version. Update ThinkWatch Lite to continue syncing." + ) + .to_string(), + )), + SyncState::KeyringMissing => Some(( + "sync:keyring", + tr!( + "同步位置上的同步密钥不可用", + "Sync Key at the Sync Location Unavailable" + ) + .to_string(), + tr!( + "同步已暂停。在设置中重新上传本机配置,或停止同步。", + "Syncing is paused. Upload this device's configuration again in Settings, or stop syncing." + ) + .to_string(), + )), + SyncState::Emptied => Some(( + "sync:emptied", + tr!("同步位置为空", "Sync Location Empty").to_string(), + tr!( + "同步已暂停。在设置中重新上传本机配置,或停止同步。", + "Syncing is paused. Upload this device's configuration again in Settings, or stop syncing." + ) + .to_string(), + )), + SyncState::Misconfigured => Some(( + "sync:misconfigured", + tr!("同步设置无法使用", "Sync Settings Unusable").to_string(), + if why.is_empty() { + tr!( + "请在设置中修改同步位置的连接参数或出站代理。", + "Change the sync location's connection details or outbound proxy in Settings." + ) + .to_string() + } else { + tr!( + format!("{why}。请在设置中修改同步位置的连接参数或出站代理。"), + format!( + "{why}. Change the sync location's connection details or outbound proxy in Settings." + ) + ) + }, + )), + _ => None, + }; + if let Some((k, title, body)) = said { + out.insert(k, (title, body)); + } + out +} + +/// 上一次说到哪儿了。`None`:这次运行里还没说过(上一次运行留下的那几条要按现状收起) +static SAID: Mutex> = Mutex::new(None); + +/// 从上一份到这一份要发的几条:新开的、说法变了的开起来,不再成立的收起 +pub fn changes(before: Option<&Said>, now: &Said) -> Vec { + let mut out = Vec::new(); + for k in KEYS { + match (before.and_then(|b| b.get(k)), now.get(k)) { + (prev, Some((title, body))) if prev != Some(&(title.clone(), body.clone())) => out + .push( + Signal::raised(k, Level::Warning, title.clone()) + .body(body.clone()) + .view(SYNC_VIEW) + // core 已经替这几件事等过了(连不上要连续两小时):到了就说 + .now(), + ), + (_, Some(_)) => {} + // 第一次(`before` 是空)也收:上一次运行留下的、现在已经不成立的 + (Some(_), None) => out.push(Signal::cleared(k)), + (None, None) if before.is_none() => out.push(Signal::cleared(k)), + (None, None) => {} + } + } + out +} + +/// 通知点开落在哪:设置的「同步与备份」一节 +pub const SYNC_VIEW: &str = "settings:sync-backup"; + +fn tell(app: &tauri::AppHandle, b: &SyncView) { + let Some(n) = app.try_state::>() else { + return; + }; + let now = wanted(b); + let signals = { + let mut said = SAID.lock().unwrap_or_else(|e| e.into_inner()); + let s = changes(said.as_ref(), &now); + *said = Some(now); + s + }; + let at = notices::now_ms(); + for s in signals { + n.ingest(s, at); + } +} + +// ───────────────────────────────────────────── 网关密钥换了值 + +/// 一次只改一批:两条事件接连到时,同一个客户端的配置不在两处同时写 +static REWRITING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// 这几把密钥的值被同步、恢复或导入换掉了:接管着的客户端里写着旧值,改成新值。 +/// +/// 和「更换密钥」是同一条路(`clients::sync_rotated`),只是新值由这里去问 core。**成功且 +/// 当场生效的不说**;要重启客户端才生效的、没改成的发一条通知 —— 那是要人处理的 +async fn keys_changed(app: &tauri::AppHandle, names: Vec) { + let Some(st) = app.try_state::() else { + return; + }; + let _one = REWRITING.lock().await; + let keys = match st.control.call::(&[], &()).await { + Ok(k) => k, + Err(e) => { + tracing::warn!("网关密钥换了值,但没问到密钥列表:{e:#}"); + return; + } + }; + for name in names { + let owner = match crate::clients::adopted_owner(&keys, &name) { + Ok(Some(o)) => o, + Ok(None) => continue, + Err(e) => { + tracing::warn!(key = %name, "看不出这把密钥的主人还接管着没有:{}", e.text); + continue; + } + }; + let fresh = match st.control.call::(&[&name], &()).await { + Ok(v) => v.key, + Err(e) => { + tracing::warn!(key = %name, "没问到新的密钥值:{e:#}"); + continue; + } + }; + let signal = match crate::clients::sync_rotated(&st, &owner, &fresh).await { + Ok(s) if s.takes_effect == crate::wire::TakesEffect::Immediately => None, + Ok(s) => Some(restart_needed(&s.client, &s.name)), + Err(f) => Some(rewrite_failed(&f.client, &f.name, &f.error)), + }; + if let (Some(s), Some(n)) = (signal, app.try_state::>()) { + n.ingest(s, notices::now_ms()); + } + } +} + +/// 改好了,但要重启客户端才用上新值 +fn restart_needed(client: &str, name: &str) -> Signal { + Signal::raised( + format!("keysync:{client}"), + Level::Warning, + tr!( + format!("{name} 的网关密钥已更新"), + format!("“{name}” Gateway Key Updated") + ), + ) + .body(tr!( + format!("网关密钥经同步或恢复更换,已写入 {name} 的配置。重新启动 {name} 后生效。"), + format!( + "The gateway key changed through sync or a restore and has been written to {name}'s configuration. Restart {name} for it to take effect." + ) + )) + .view("clients") + .event() +} + +/// 没改成:它配置里还是旧值,下一个请求就是 401 +fn rewrite_failed(client: &str, name: &str, why: &Msg) -> Signal { + let why = crate::core_text::clause(why); + Signal::raised( + format!("keysync:{client}"), + Level::Warning, + tr!( + format!("未能更新 {name} 的网关密钥"), + format!("Gateway Key Not Updated for “{name}”") + ), + ) + .body(tr!( + format!("网关密钥经同步或恢复更换,但未能写入 {name} 的配置:{why}。"), + format!( + "The gateway key changed through sync or a restore, but it could not be written to {name}'s configuration: {why}." + ) + )) + .view("clients") + .event() +} + +// ───────────────────────────────────────────── 固定密钥 + +/// 接管着的客户端变了(文件监视、接管、还原)一阵子里只报一次 +const PIN_SETTLE: Duration = Duration::from_millis(800); + +static PIN_DUE: Mutex> = Mutex::new(None); + +/// 过一会儿把固定密钥报给 core。接连叫好几次(文件监视一次改动报好几下)只报一次 +pub fn pinned_soon(app: &tauri::AppHandle) { + let due = Instant::now() + PIN_SETTLE; + let first = { + let mut g = PIN_DUE.lock().unwrap_or_else(|e| e.into_inner()); + let first = g.is_none(); + *g = Some(due); + first + }; + if !first { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + let due = PIN_DUE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(Instant::now); + let now = Instant::now(); + if due <= now { + break; + } + tokio::time::sleep(due - now).await; + } + *PIN_DUE.lock().unwrap_or_else(|e| e.into_inner()) = None; + if let Err(e) = report_pinned(&app).await { + tracing::debug!("固定密钥没报上:{}", e.into_msg().text); + } + }); +} + +/// 算出此刻接管着的客户端在用的密钥,告诉 core +async fn report_pinned(app: &tauri::AppHandle) -> Out> { + let st = app + .try_state::() + .ok_or_else(|| crate::error::CmdError::plain("not ready"))?; + let keys = st + .control + .call::(&[], &()) + .await + .map_err(crate::error::text)?; + let names = crate::clients::blocking(move || crate::clients::pinned_keys(&keys)).await?; + // 几个 Lite 连着同一个 core 时各报各的:按这一份 Lite 的标识分开记 + let source = crate::clients::blocking(|| crate::prefs::instance_id(&crate::data_dir())).await?; + let pins = fit_pins(source, names); + st.control + .call::(&[], &pins) + .await + .map_err(crate::error::text)?; + Ok(pins.names) +} + +/// core 收固定密钥的上限(`PUT /pinned-keys`,超出是 400 `backup.pinned.*`):来源最长 128 字节 +const PIN_SOURCE_MAX: usize = 128; +/// 一次最多几把 +const PIN_NAMES_MAX: usize = 1024; +/// 一个名字最长几个字节 +const PIN_NAME_MAX: usize = 256; + +/// 照 core 的上限收拾一遍再报:来源截到 128 字节(按字符边界),名字超长的去掉(截断之后就是 +/// 另一个名字了,保护不到它),多于 1024 把的只报前面的。**都只记日志,不报错给人**:报不全 +/// 只是少保护几把,而这件事本来就是后台做的 +fn fit_pins(source: String, names: Vec) -> tw_api::PinnedKeys { + let mut source = source; + if source.len() > PIN_SOURCE_MAX { + let mut end = PIN_SOURCE_MAX; + while !source.is_char_boundary(end) { + end -= 1; + } + source.truncate(end); + tracing::warn!("实例标识超过 {PIN_SOURCE_MAX} 字节,已截断后上报"); + } + let before = names.len(); + let mut names: Vec = names + .into_iter() + .filter(|n| n.len() <= PIN_NAME_MAX) + .collect(); + if names.len() < before { + tracing::warn!( + "{} 把网关密钥的名字超过 {PIN_NAME_MAX} 字节,未作为固定密钥上报", + before - names.len() + ); + } + if names.len() > PIN_NAMES_MAX { + tracing::warn!( + "接管的客户端在用 {} 把网关密钥,只上报前 {PIN_NAMES_MAX} 把", + names.len() + ); + names.truncate(PIN_NAMES_MAX); + } + tw_api::PinnedKeys { source, names } +} + +/// 开启同步、加入同步之前先报一次(「使用同步数据替换本机配置」时 core 据此保留在用的密钥) +#[tauri::command] +pub async fn sync_pin_keys(app: tauri::AppHandle) -> Out> { + report_pinned(&app).await +} + +// ───────────────────────────────────────────── 打开主窗口时同步一次 + +/// 两次之间至少隔这么久:来回切窗口不该每一下都去问同步位置 +const NUDGE_EVERY: Duration = Duration::from_secs(30); + +static NUDGED: Mutex> = Mutex::new(None); + +/// 打开主窗口:立刻检查一次同步位置(6.5「打开 Lite 主窗口时立即检查」)。没开同步的 +/// core 答「没开」,不管它 +pub fn nudge(app: &tauri::AppHandle) { + { + let mut g = NUDGED.lock().unwrap_or_else(|e| e.into_inner()); + if g.is_some_and(|at| at.elapsed() < NUDGE_EVERY) { + return; + } + *g = Some(Instant::now()); + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(st) = app.try_state::() else { + return; + }; + if let Err(e) = st.control.call::(&[], &()).await { + tracing::debug!("打开窗口时没同步上:{e:#}"); + } + }); +} + +// ───────────────────────────────────────────── 向导里的两样 + +/// 选同步文件夹:系统的「选择文件夹」对话框。**只在连着本机时**:远程 core 的文件夹在 +/// 服务器上,这台电脑上选出来的路径对它没有意义(界面上手填)。取消了是 `None` +#[tauri::command] +pub async fn pick_sync_folder( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Out> { + use tauri_plugin_dialog::DialogExt; + if state.link.is_remote() { + return Err(tr!( + "连接远程 core 时,同步文件夹是服务器上的路径,请手动填写。", + "With a remote core, the sync folder is a path on the server; type it in." + ) + .into()); + } + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_parent(&window) + .pick_folder(move |picked| { + let _ = tx.send(picked); + }); + let picked = rx.await.map_err(|_| { + tr!( + "无法打开选择文件夹的对话框。", + "The folder dialog could not be opened." + ) + })?; + let Some(picked) = picked else { + return Ok(None); + }; + let path = picked.into_path().map_err(|e| { + tr!( + format!("无法使用所选的文件夹:{e}"), + format!("The chosen folder cannot be used: {e}") + ) + })?; + Ok(Some(path.display().to_string())) +} + +/// 本设备名称的缺省值:这台电脑的名字(macOS 上是「电脑名称」,如「Mac mini」)。连着远程 +/// core 时界面用连接的名字,不问这里 +#[tauri::command] +pub async fn sync_device_name() -> Out { + crate::clients::blocking(computer_name).await +} + +fn computer_name() -> String { + #[cfg(target_os = "macos")] + { + // 系统设置 › 通用 › 关于本机 › 名称。主机名是它去掉空格的样子(`Mac-mini.local`) + if let Ok(out) = std::process::Command::new("/usr/sbin/scutil") + .args(["--get", "ComputerName"]) + .output() + && out.status.success() + { + let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + #[cfg(windows)] + { + if let Some(name) = std::env::var_os("COMPUTERNAME") { + let name = name.to_string_lossy().trim().to_string(); + if !name.is_empty() { + return name; + } + } + } + #[cfg(unix)] + { + let mut buf = [0u8; 256]; + // SAFETY: 缓冲区由这里拥有、长度如实给出;gethostname 至多写这么多字节 + let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if r == 0 { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).trim().to_string(); + // `Mac-mini.local` 去掉域名那一段 + let name = name.split('.').next().unwrap_or_default().to_string(); + if !name.is_empty() { + return name; + } + } + } + String::from("ThinkWatch") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn brief(state: &str, conflicts: u32) -> SyncView { + SyncView { + state: SyncState::from_slug(state).unwrap(), + target: None, + encrypted: false, + device: None, + last_synced_ms: None, + failing_since_ms: None, + error: None, + devices: vec![], + conflicts, + excluded: vec![], + excluded_cascade: vec![], + skipped: 0, + notices: vec![], + } + } + + fn keys(signals: &[Signal]) -> Vec<(String, bool)> { + signals + .iter() + .map(|s| (s.key.clone(), s.change == notices::Change::Raised)) + .collect() + } + + /// 正常运转什么都不说;需要处理的才开一条,好了收起 + #[test] + fn only_states_that_need_a_person_raise_a_notice() { + for s in ["idle", "syncing", "off", "paused"] { + assert!(wanted(&brief(s, 0)).is_empty(), "{s}"); + } + let w = wanted(&brief("unreachable", 2)); + assert_eq!( + w.keys().copied().collect::>(), + ["sync:conflict", "sync:unreachable"] + ); + for (s, k) in [ + ("auth_failed", "sync:auth"), + ("locked", "sync:locked"), + ("too_new", "sync:too_new"), + ("keyring_missing", "sync:keyring"), + ("emptied", "sync:emptied"), + ("misconfigured", "sync:misconfigured"), + ] { + assert_eq!( + wanted(&brief(s, 0)).keys().copied().collect::>(), + [k] + ); + } + } + + /// 同一件事只说一次:状态再推一遍不再开;冲突数变了换一句;好了收起;第一次把上次 + /// 运行留下的也收掉 + #[test] + fn a_state_pushed_again_is_not_raised_again() { + let first = changes(None, &wanted(&brief("idle", 0))); + assert_eq!(keys(&first).len(), KEYS.len()); + assert!(keys(&first).iter().all(|(_, raised)| !raised)); + + let a = wanted(&brief("conflict", 2)); + let up = changes(Some(&Said::new()), &a); + assert_eq!(keys(&up), [("sync:conflict".to_string(), true)]); + assert_eq!(changes(Some(&a), &a).len(), 0); + + let b = wanted(&brief("conflict", 3)); + let more = changes(Some(&a), &b); + assert_eq!(keys(&more), [("sync:conflict".to_string(), true)]); + assert!(more[0].title.contains('3'), "{}", more[0].title); + + let gone = changes(Some(&b), &wanted(&brief("idle", 0))); + assert_eq!(keys(&gone), [("sync:conflict".to_string(), false)]); + } + + /// 固定密钥照 core 的上限收拾:来源按字符边界截断,超长的名字去掉,多出来的只报前面的 + #[test] + fn pinned_keys_fit_the_limits() { + let ok = fit_pins( + "0123456789abcdef".into(), + vec!["codex".into(), "default".into()], + ); + assert_eq!(ok.source, "0123456789abcdef"); + assert_eq!(ok.names, ["codex", "default"]); + let long_source = "源".repeat(50); // 150 字节 + let p = fit_pins(long_source, vec![]); + assert!(p.source.len() <= PIN_SOURCE_MAX && p.source.chars().all(|c| c == '源')); + let mut names: Vec = (0..1100).map(|i| format!("k{i}")).collect(); + names.insert(3, "x".repeat(PIN_NAME_MAX + 1)); + let p = fit_pins("a".into(), names); + assert_eq!(p.names.len(), PIN_NAMES_MAX); + assert!(p.names.iter().all(|n| n.len() <= PIN_NAME_MAX)); + assert_eq!(p.names[3], "k3"); + } + + /// 前端的清单里有这几个同步端点(`PinKeys`、客户端偏好的两个只给 Rust 用) + #[test] + fn the_webview_reaches_the_sync_endpoints_but_not_the_pins() { + let names = tw_api::ep::ALL.iter().map(|e| e.name).filter(|n| { + n.starts_with("Sync") || matches!(*n, "PinKeys" | "GetClientPrefs" | "PutClientPrefs") + }); + for name in names { + let webview = crate::call::ALLOWED.contains(&name); + let rust_only = matches!(name, "PinKeys" | "GetClientPrefs" | "PutClientPrefs"); + assert_eq!(webview, !rust_only, "{name}"); + } + } +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 4315abb4..45532456 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -44,6 +44,7 @@ pub fn update_state(app: tauri::AppHandle) -> UpdateView { #[tauri::command] pub async fn set_update_check(app: tauri::AppHandle, on: bool) -> Out { prefs::change(move |p| p.check_updates = on).await?; + crate::client_prefs::changed(&app); Ok(update_view(&app)) } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index b0fa1133..d6d7ca5c 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -102,6 +102,8 @@ pub(crate) mod relaunch_token { /// /// 冷启动(core 还没到运行中)照旧马上出现,由启动画面说网关走到了哪一步。 pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + // 打开主窗口时立刻检查一次同步位置(方案 6.5) + crate::sync::nudge(app); if let Some(w) = app.get_webview_window("main") { // 热启动还藏着的时候又点了一次,也是这里:不等了,直接出来 return reveal(app, &w); diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 658bd88e..8661ec16 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -837,7 +837,7 @@ pub enum ImportLink { /// 导入链接能选的服务类型:新建对话框第一步的那几格(`src/upstreams/presets.ts`,顺序也 /// 一样)。线上是那一格的 ID -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] pub enum ImportService { Anthropic, @@ -855,7 +855,7 @@ pub enum ImportService { } /// 导入链接能选的认证方式。AWS profile 不在里面:它让应用读这台机器上的凭证文件 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "kebab-case")] pub enum ImportAuth { Key, @@ -866,7 +866,7 @@ pub enum ImportAuth { } /// OAuth 凭据(`auth=oauth`) -#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] pub struct ImportOAuth { /// Token 端点,规范化之后(主机名是 ASCII) pub endpoint: String, @@ -876,7 +876,7 @@ pub struct ImportOAuth { } /// Bedrock 的访问密钥(`auth=aws-keys`) -#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] pub struct ImportAwsKeys { pub access_key_id: String, pub secret_access_key: String, @@ -884,14 +884,14 @@ pub struct ImportAwsKeys { } /// 一行请求头 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] pub struct ImportHeader { pub name: String, pub value: String, } /// 一个模型的规格。空着的一项不写(用价目表的);至少有一项 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] pub struct ImportSpec { pub model: String, pub context_window: Option, @@ -901,7 +901,7 @@ pub struct ImportSpec { } /// 新价目表里一个模型的覆盖价,美元 / 百万 tokens。链接里没写的缓存价已经按输入价补上 -#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub struct ImportPrice { pub model: String, pub input: f64, @@ -912,7 +912,7 @@ pub struct ImportPrice { } /// 链接带来的新价目表:和上游一起创建,名称跟着上游 -#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub struct ImportSheet { /// 作用于默认价目表的倍率。链接没写是 1 pub multiplier: f64, @@ -923,7 +923,7 @@ pub struct ImportSheet { /// 每一项都是这种服务、这种认证方式的新建对话框里显示的一栏,值合规矩,不含 `$`、`{`、`}` /// (不会被当成 `${变量名}` 展开)。界面拿它预填新建对话框;之后由人决定,按「下一步」 /// 或开始登录之前不写配置、不发任何请求。没给的一项是 null(或空的),由对话框照常填 -#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub struct ImportProposal { pub service: ImportService, pub auth: ImportAuth, @@ -958,6 +958,42 @@ pub struct ImportProposal { pub price_sheet: Option, } +/// 分享一个上游时生成的导入链接(`share_import_link`)。界面按上游的配置拼一份提议交过来, +/// 这里写成链接、再按同一套规矩解析一遍:**交回去的是解析出来的那一份**,和接收方点开链接 +/// 之后看到的完全一样 +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ShareLink { + /// `app` 是 `thinkwatch://import?…`,`web` 是官网的 `https://thinkwat.ch/import#…`(`#` 之后 + /// 的参数和 `app` 的一样,不发给网站的服务器) + Ready { + app: String, + web: String, + proposal: Box, + }, + /// 超过链接的上限(8192 个字符):改用配置包 + TooLong { length: u32 }, + /// 这一项写不进链接(值不合链接的规矩)。`param` 是那一项的参数名 + Invalid { param: Option }, +} + +/// 打开的、拖进窗口的配置包(`open_import`、`import-file` 事件)。内容按 Base64 交给界面, +/// 界面原样交给 core 的导入预览 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +pub struct PickedFile { + /// 文件名(不含目录) + pub name: String, + pub data_b64: String, +} + +/// 拖进主窗口的文件(`import-file` 事件):读到的配置包,或者读不了的原因 +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DroppedFile { + File { file: PickedFile }, + Failed { error: Msg }, +} + // ---------------------------------------------------------- 插件:在系统的确认框里点头的几步 // // 改得了回答里工具调用的插件,装上它、打开它、改它的代码、批准它磁盘上改过的文件,要在系统 diff --git a/src-tauri/tests/ts_bindings.rs b/src-tauri/tests/ts_bindings.rs index 753174e1..b8e36c34 100644 --- a/src-tauri/tests/ts_bindings.rs +++ b/src-tauri/tests/ts_bindings.rs @@ -140,6 +140,9 @@ fn lite_typescript() -> String { c.root::(); c.root::(); c.root::(); + c.root::(); + c.root::(); + c.root::(); c.root::(); c.root::(); c.root::(); diff --git a/src/App.i18n.tsx b/src/App.i18n.tsx index 8ef4bbec..a0ca13b1 100644 --- a/src/App.i18n.tsx +++ b/src/App.i18n.tsx @@ -45,7 +45,8 @@ export const appText = messages( collapseRail: "收起源列表", expandRail: "展开源列表", configFile: "配置文件", - versionHistory: "版本历史", + /** 原来的「版本历史」:备份取代了修改记录(`src/backup/BackupsDialog.tsx`) */ + versionHistory: "备份", // 配置没通过校验。`rejectedAt` 后面直接接 core 给的那句错误 rejectedTitle: "配置校验未通过,仍在使用上一版本", @@ -91,7 +92,7 @@ export const appText = messages( collapseRail: "Collapse sidebar", expandRail: "Expand sidebar", configFile: "Config file", - versionHistory: "Version history", + versionHistory: "Backups", rejectedTitle: "Config validation failed; the previous version is still in use", rejectedAt: (stage: string, line: number | null) => `${stage} error${line != null ? ` (line ${line})` : ""}: `, diff --git a/src/ConfigDialogs.i18n.ts b/src/ConfigDialogs.i18n.ts index 9ef2ce9f..7f5a1370 100644 --- a/src/ConfigDialogs.i18n.ts +++ b/src/ConfigDialogs.i18n.ts @@ -3,35 +3,15 @@ import { messages } from "@/i18n"; export const configDialogsText = messages( { fileTitle: "配置文件", - fileDescription: "保存前会校验;校验未通过时仍使用上一版本,并在版本历史中保留每一次保存。", + fileDescription: "保存前会校验;校验未通过时仍使用上一版本。每次保存都在备份中生成一个版本。", readingFile: "正在读取配置文件", fileFailed: "配置文件读取失败", - historyTitle: "版本历史", - historyDescription: "恢复某个版本会生成一个新版本,当前版本保留在历史中。", - historyFailed: "版本历史读取失败", - noVersions: "暂无历史版本", - noVersionsHint: "每次保存配置都会在此生成一个版本。", - time: "时间", - origin: "来源", - version: "版本", - current: "当前版本", - restore: "恢复此版本", }, { fileTitle: "Config file", fileDescription: - "Changes are validated before saving. If validation fails, the previous version stays in use, and every save is kept in the version history.", + "Changes are validated before saving. If validation fails, the previous version stays in use. Every save creates a version in the backups.", readingFile: "Reading the config file", fileFailed: "Could not read the config file", - historyTitle: "Version history", - historyDescription: "Restoring a version creates a new version; the current version stays in the history.", - historyFailed: "Could not read the version history", - noVersions: "No versions yet", - noVersionsHint: "Every save of the config creates a version here.", - time: "Time", - origin: "Source", - version: "Version", - current: "Current version", - restore: "Restore this version", }, ); diff --git a/src/ConfigDialogs.tsx b/src/ConfigDialogs.tsx index b4c98411..d64f4711 100644 --- a/src/ConfigDialogs.tsx +++ b/src/ConfigDialogs.tsx @@ -1,9 +1,5 @@ -import { useState } from "react"; -import { HistoryIcon } from "lucide-react"; import { call } from "@/control"; import { useResource } from "@/lib/resource"; -import { Badge } from "@/ui/badge"; -import { Button } from "@/ui/button"; import { Dialog, DialogContent, @@ -11,28 +7,18 @@ import { DialogHeader, DialogTitle, } from "@/ui/dialog"; -import { notify } from "@/ui/notify"; -import { EmptyState, Loadable, LoadingState, TableSkeleton } from "@/ui/states"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/ui/table"; +import { Loadable, LoadingState } from "@/ui/states"; import { useText } from "@/i18n"; -import ConfigTextMode, { PluginConfirmNotice } from "./ConfigText"; +import ConfigTextMode from "./ConfigText"; import type { ConfigFocus } from "./configLocate"; import { configDialogsText } from "./ConfigDialogs.i18n"; -import { when } from "./format"; -import { originLabel } from "./labels"; -import { errorText } from "@/i18n/core.i18n"; -import { needsConfirmation } from "@/plugins/write"; /** * 配置文件。**各配置页共用这一个入口** —— 文件只有一份,表单是它的几种 * 视图,而不是几个互不相干的东西。 + * + * 旁边原来的「版本历史」由备份取代(`src/backup/BackupsDialog.tsx`):每次保存都是备份里的 + * 一个版本,连同插件一起。 */ export function ConfigFileDialog({ reloads, @@ -83,98 +69,3 @@ export function ConfigFileDialog({ ); } - -/** 版本历史。**每一次保存都在这里** —— 恢复也是一次保存,不会丢掉当前版本 */ -export function VersionHistoryDialog({ - reloads, - onClose, - onOpenPlugins, -}: { - /** 配置换入过几次。换了就重读 */ - reloads: number; - onClose: () => void; - /** 关掉这里、去插件页(恢复的版本要装上、启用改得了工具调用的插件时) */ - onOpenPlugins?: () => void; -}) { - const t = useText(configDialogsText); - const versions = useResource("config-history", () => call("ConfigHistory", null), { deps: [reloads] }); - const [busy, setBusy] = useState(null); - /** core 不让这次恢复装上、启用、换掉批准的代码的那个插件(它的那句话) */ - const [pluginRefusal, setPluginRefusal] = useState(null); - - async function restore(version: string) { - setBusy(version); - setPluginRefusal(null); - try { - await call("ConfigRollback", { version }); - } catch (e) { - // 和保存配置文件一样:恢复这条路没有点过头的那一条,只能去插件页 - if (needsConfirmation(e)) setPluginRefusal(errorText(e)); - else notify.error(e); - } finally { - setBusy(null); - } - } - - return ( - !open && onClose()}> - - - {t.historyTitle} - {t.historyDescription} - - {pluginRefusal && ( - - )} -
- } - errorTitle={t.historyFailed} - isEmpty={(v) => v.length === 0} - empty={ - } title={t.noVersions} description={t.noVersionsHint} /> - } - > - {(list) => ( - - - - {t.time} - {t.origin} - {t.version} - - - - - {list.map((v) => ( - - {when(v.at_ms)} - {originLabel(v.origin)} - {v.version.slice(7, 19)} - - {v.current ? ( - {t.current} - ) : ( - - )} - - - ))} - -
- )} -
-
-
-
- ); -} diff --git a/src/aliases/aliases.i18n.ts b/src/aliases/aliases.i18n.ts index fc81a69e..9ffaf45a 100644 --- a/src/aliases/aliases.i18n.ts +++ b/src/aliases/aliases.i18n.ts @@ -64,7 +64,7 @@ export const aliasesText = messages( usedByRule: (route: string, rule: string) => `路由「${route}」· 规则「${rule}」`, usedByClients: (names: readonly string[]) => `已接管的 ${names.join("、")} 的模型列表`, show: "查看", - kept: (models: readonly string[]) => `上游模型 ${models.join("、")} 照常可用。可在版本历史中恢复。`, + kept: (models: readonly string[]) => `上游模型 ${models.join("、")} 照常可用。可从备份中恢复。`, }, { tab: "Aliases", @@ -124,6 +124,6 @@ export const aliasesText = messages( `The model ${names.length === 1 ? "list" : "lists"} of ${enList(names)} (connected)`, show: "Show", kept: (models: readonly string[]) => - `The upstream ${models.length === 1 ? "model" : "models"} ${enList(models)} remain${models.length === 1 ? "s" : ""} available. The alias can be restored from version history.`, + `The upstream ${models.length === 1 ? "model" : "models"} ${enList(models)} remain${models.length === 1 ? "s" : ""} available. The alias can be restored from the backups.`, }, ); diff --git a/src/backup/BackupList.tsx b/src/backup/BackupList.tsx new file mode 100644 index 00000000..695a5425 --- /dev/null +++ b/src/backup/BackupList.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { HistoryIcon } from "lucide-react"; +import { useResource } from "@/lib/resource"; +import { Badge } from "@/ui/badge"; +import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; +import { EmptyState, Loadable, TableSkeleton } from "@/ui/states"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useText } from "@/i18n"; +import { whenMinute } from "@/format"; +import type { BackupEntry } from "@/types"; +import { BACKUPS_KEY, backupApi } from "./api"; +import { backupText } from "./backup.i18n"; +import { originLabel } from "./items"; +import { keepInRow, openRow } from "@/upstreams/parts"; +import { RestoreDialog } from "./RestoreDialog"; +import { SaveVersionDialog } from "./SaveVersionDialog"; + +/** 备份版本的列表,配置一换就重读(新的那一版排到最前) */ +export function useBackups() { + return useResource(BACKUPS_KEY, backupApi.list, { events: ["config_reloaded"] }); +} + +/** + * 备份版本(规格 7.1、6.13):按时间倒序,本机与同步位置的同一版本是一行,「存放位置」两处都标。 + * 当前的那一版在时间后面标「当前」。行尾按钮和右键是「恢复…」「另存为…」(当前的那一版只能另存);点一行 + * 就是「恢复…」—— 先看差异,不会直接恢复。 + * + * 设置「同步与备份」里一份,工具栏的「备份」、安全模式出错页的「从备份恢复…」打开的对话框里 + * 一份,是同一张表。 + */ +export function BackupList({ + onRestored, + onOpenPlugins, +}: { + onRestored?: () => void; + onOpenPlugins?: () => void; +}) { + const t = useText(backupText); + const list = useBackups(); + const [restoring, setRestoring] = useState(null); + const [saving, setSaving] = useState(null); + + return ( + <> + } + errorTitle={t.listFailed} + isEmpty={(v) => v.length === 0} + empty={} title={t.noBackups} description={t.noBackupsHint} />} + > + {(entries) => ( + + + + {t.time} + {t.origin} + {t.location} + {t.contents} + {t.coreVersion} + + {t.actionsColumn} + + + + + {entries.map((e) => ( + setRestoring(e)} onSave={() => setSaving(e)} /> + ))} + +
+ )} +
+ {restoring && ( + setRestoring(null)} + onRestored={() => { + void list.reload(); + onRestored?.(); + }} + onOpenPlugins={onOpenPlugins} + /> + )} + {saving && setSaving(null)} />} + + ); +} + +/** 一个版本一行。当前的那一版不能恢复(点了也不开预览),只能另存 */ +function BackupRow({ e, onRestore, onSave }: { e: BackupEntry; onRestore: () => void; onSave: () => void }) { + const t = useText(backupText); + const items: MenuItems = [ + ...(e.current ? [] : [{ kind: "item" as const, label: t.restore, onSelect: onRestore }]), + { kind: "item", label: t.saveAs, onSelect: onSave }, + ]; + return ( + + + + + {whenMinute(e.at_ms)} + {e.current && {t.current}} + + + {originLabel(e.origin, e.device)} + + {[e.local && t.local, e.sync && t.synced].filter(Boolean).join(" · ")} + + + {t.counts(e.counts.providers, e.counts.plugins)} + + {e.core_version} + + + + + + ); +} diff --git a/src/backup/BackupPart.tsx b/src/backup/BackupPart.tsx new file mode 100644 index 00000000..b1c18b41 --- /dev/null +++ b/src/backup/BackupPart.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { Button } from "@/ui/button"; +import { useText } from "@/i18n"; +import { SettingsCard } from "@/settings/kit"; +import type { ProviderView } from "@/types"; +import { backupText } from "./backup.i18n"; +import { BackupList } from "./BackupList"; +import { ExportDialog } from "./ExportDialog"; +import { PartHeading } from "./parts"; +import { useImport } from "./useImport"; + +/** + * 设置「同步与备份」里的备份部分(规格 6.13):「导入配置…」「导出配置…」,下面是备份版本的表, + * 再下面一句保留规则。连着远程 core 时这些都是服务器上的(文件在这台设备上读写)。 + * + * `providers`:配置里的上游,导出对话框数 ChatGPT 登录用。 + */ +export function BackupPart({ providers, onChanged }: { providers: readonly ProviderView[]; onChanged: () => void }) { + const t = useText(backupText); + const [exporting, setExporting] = useState(false); + const importer = useImport(onChanged); + return ( +
+ + + + + } + /> + + + +

{t.keeps}

+ {exporting && setExporting(false)} />} + {importer.dialog} +
+ ); +} diff --git a/src/backup/BackupsDialog.tsx b/src/backup/BackupsDialog.tsx new file mode 100644 index 00000000..c70a8a87 --- /dev/null +++ b/src/backup/BackupsDialog.tsx @@ -0,0 +1,37 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { useText } from "@/i18n"; +import { backupText } from "./backup.i18n"; +import { BackupList } from "./BackupList"; + +/** + * 备份版本的对话框:工具栏的「备份」(取代原来的「版本历史」)、命令面板,和安全模式出错页的 + * 「从备份恢复…」都打开它(`restore`:标题写「从备份恢复」)。表和设置里的是同一张。 + * + * **安全模式下也能用**:配置读不了的时候,core 的备份接口照常答,恢复之后配置换入、退出安全模式。 + */ +export function BackupsDialog({ + restore = false, + onClose, + onOpenPlugins, +}: { + restore?: boolean; + onClose: () => void; + /** 关掉这里、去插件页(恢复的版本要装上、启用改得了工具调用的插件时) */ + onOpenPlugins?: () => void; +}) { + const t = useText(backupText); + return ( + !open && onClose()}> + + + {restore ? t.restoreTitle : t.dialogTitle} + {t.dialogDescription} + +
+ +
+

{t.keeps}

+
+
+ ); +} diff --git a/src/backup/DropImport.tsx b/src/backup/DropImport.tsx new file mode 100644 index 00000000..9386e7ea --- /dev/null +++ b/src/backup/DropImport.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { FileDownIcon } from "lucide-react"; +import { useTauriEvent } from "@/lib/tauriEvent"; +import { notify } from "@/ui/notify"; +import { modalOpen } from "@/palette/keys"; +import { useText } from "@/i18n"; +import type { DroppedFile, PickedFile } from "@/types"; +import { backupText } from "./backup.i18n"; +import { ImportDialog } from "./ImportDialog"; + +/** Rust 那边的两个事件(`src-tauri/src/export.rs`):拖着配置包进出窗口、放下的那一个 */ +const DRAG_EVENT = "import-drag"; +const DROP_EVENT = "import-file"; + +/** + * 配置包拖进主窗口(规格 5.1):拖着的时候整窗盖一层提示,放下打开导入对话框。**文件由 Rust + * 读**,这里拿到的是内容。 + * + * `ready`:连上了、配置能用(不在安全模式、远程没断)。不然拖进来不理 —— 导入要对着一份读得了的 + * 配置比。已经开着别的对话框时也不理,不把正在填的表单盖住。 + */ +export function DropImport({ ready, onImported }: { ready: boolean; onImported: () => void }) { + const t = useText(backupText); + const [over, setOver] = useState(false); + const [file, setFile] = useState(null); + const accepting = ready && file === null; + + useTauriEvent(DRAG_EVENT, (e) => setOver(e.payload && accepting && !modalOpen())); + useTauriEvent(DROP_EVENT, (e) => { + setOver(false); + if (!accepting || modalOpen()) return; + if (e.payload.kind === "failed") notify.error(e.payload.error); + else setFile(e.payload.file); + }); + + return ( + <> + {over && ( +
+
+ +

{t.dropHere}

+
+
+ )} + {file && setFile(null)} onImported={onImported} />} + + ); +} diff --git a/src/backup/ExportDialog.tsx b/src/backup/ExportDialog.tsx new file mode 100644 index 00000000..0dcdc14a --- /dev/null +++ b/src/backup/ExportDialog.tsx @@ -0,0 +1,264 @@ +import { useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useResource } from "@/lib/resource"; +import { FormRow, FormRows } from "@/settings/form"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { notify } from "@/ui/notify"; +import { Segmented } from "@/ui/segmented"; +import { Skeleton } from "@/ui/skeleton"; +import { ErrorState } from "@/ui/states"; +import { Switch } from "@/ui/switch"; +import { Tip } from "@/ui/tip"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { DialogError } from "@/upstreams/parts"; +import { errorText } from "@/i18n/core.i18n"; +import type { ItemNode, ItemRef, ProviderView } from "@/types"; +import { useRemote } from "@/connection/useRemote"; +import { backupApi } from "./api"; +import { backupText } from "./backup.i18n"; +import { closure, countOf, groupBy, itemLabel, kindLabel, namedById, refKey, refOf } from "./items"; +import { PassphraseRow, usePassphrase } from "./Passphrase"; + +/** 配置里有哪些项目(导出时选、导入时查重名)。配置一换就重读 */ +export function useConfigItems() { + return useResource("config-items", backupApi.items, { events: ["config_reloaded"] }); +} + +/** + * 导出配置(规格 5.2)。从设置进入时缺省「全部配置」;从上游页多选、分享进入时缺省「选择项目」 + * 并勾好那几个上游。所选项目引用的其他项目自动勾上、标「随附」。 + * + * 点「导出…」:core 打好配置包,Rust 弹「存储」对话框写盘(`backupApi.save`)。存储对话框里取消 + * 了,这个对话框留着。 + * + * `providers`:配置里的上游(「不随文件导出」那一行数 ChatGPT 登录要用)。 + */ +export function ExportDialog({ + providers, + preselect, + onClose, +}: { + providers: readonly ProviderView[]; + /** 预先勾上的项目:给了就从「选择项目」开始 */ + preselect?: readonly ItemRef[]; + onClose: () => void; +}) { + const t = useText(backupText); + const c = useText(commonText); + const items = useConfigItems(); + const remote = useRemote() !== null; + const [scope, setScope] = useState<"all" | "items">(preselect && preselect.length > 0 ? "items" : "all"); + const [picked, setPicked] = useState>(() => new Set((preselect ?? []).map(refKey))); + const [secrets, setSecrets] = useState(true); + const pass = usePassphrase(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const nodes = items.data ?? []; + const { included, along } = useMemo(() => closure(picked, nodes), [picked, nodes]); + // 登录得来的凭据留在本机:ChatGPT 账号、OAuth 上游换发的令牌(core 的 `omitted_logins`) + const logins = providers.filter( + (p) => + (p.protocol === "chatgpt" || p.oauth != null) && + (scope === "all" || included.has(refKey({ kind: "provider", name: p.name }))), + ).length; + const empty = scope === "items" && included.size === 0; + const ready = !empty && pass.problem === null && !(scope === "items" && items.data === undefined); + + async function run() { + setBusy(true); + setError(null); + try { + const f = await backupApi.export({ + scope: scope === "all" ? "all" : { items: [...included].map(refOf) }, + secrets, + passphrase: pass.value, + // 远程模式下由 Lite 附上这台设备上的通用偏好(规格 4.4);本机模式下 core 存着它 + client_prefs: remote ? await backupApi.clientPrefs() : null, + }); + const path = await backupApi.save(f); + if (path) { + notify.success(t.exported(path)); + onClose(); + } + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()}> + e.preventDefault()}> + + {t.exportTitle} + + +
+ + + + {scope === "items" && + (items.error !== undefined && items.data === undefined ? ( + void items.reload()} + /> + ) : ( + + ))} + + + + + + +
    + {logins > 0 &&
  • {t.omitLogins(logins)}
  • } + {!secrets &&
  • {t.omitSecrets}
  • } +
  • {t.omitLocal}
  • +
  • {t.omitData}
  • +
+
+
+
+ + + + + {secrets && !pass.on && {t.plaintext}} + + + +
+
+ ); +} + +/** + * 「选择项目」的清单:按类分组,每组一个总勾选框。随附的勾着、不能取消(取消掉引用它的那一项 + * 才会跟着去掉),名字后面标「随附」 + */ +function ItemPicker({ + nodes, + picked, + along, + disabled, + onChange, +}: { + nodes: readonly ItemNode[] | undefined; + picked: ReadonlySet; + along: ReadonlySet; + disabled: boolean; + onChange: (next: ReadonlySet) => void; +}) { + const t = useText(backupText); + if (!nodes) { + return ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ); + } + const groups = groupBy(nodes, (n) => n.item); + const toggle = (keys: string[], on: boolean) => { + const next = new Set(picked); + for (const k of keys) { + if (on) next.add(k); + else next.delete(k); + } + onChange(next); + }; + return ( +
+ {groups.map((g) => { + const keys = g.items.map((n) => refKey(n.item)); + const own = keys.filter((k) => picked.has(k)).length; + const all = keys.every((k) => picked.has(k) || along.has(k)); + const head = own === 0 && !keys.some((k) => along.has(k)) ? false : all ? true : "indeterminate"; + return ( +
+ + {g.items.map((n) => { + const k = refKey(n.item); + const isAlong = along.has(k); + return ( + + ); + })} +
+ ); + })} +
+ ); +} diff --git a/src/backup/ImportDialog.tsx b/src/backup/ImportDialog.tsx new file mode 100644 index 00000000..78ac764c --- /dev/null +++ b/src/backup/ImportDialog.tsx @@ -0,0 +1,481 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { invalidate } from "@/lib/resource"; +import { Badge } from "@/ui/badge"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { notify } from "@/ui/notify"; +import { SecretInput } from "@/ui/secret-input"; +import { Segmented } from "@/ui/segmented"; +import { LoadingState } from "@/ui/states"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { coreText, errorText } from "@/i18n/core.i18n"; +import { whenMinute } from "@/format"; +import { DialogError } from "@/upstreams/parts"; +import type { ImportAction, ImportPreview, ImportRow, ItemKind, ItemRef, PickedFile } from "@/types"; +import { FixList } from "@/repair"; +import { useRemote } from "@/connection/useRemote"; +import { BACKUPS_KEY, backupApi } from "./api"; +import { backupText } from "./backup.i18n"; +import { useConfigItems } from "./ExportDialog"; +import { + actionsFor, + blocked, + bulk, + carryOver, + decisions, + initialChoices, + listed, + nameProblems, + tally, + unresolved, + rowNotes, + writing, + type Choices, + type RowNote, +} from "./importFlow"; +import { differsLabel, groupBy, itemLabel, kindLabel, namedById, refKey } from "./items"; + +/** core 说配置包要口令、口令不对(tw-pack 的 `NeedPassphrase` / `WrongPassphrase`) */ +const NEED_PASSPHRASE = "pack.need_passphrase"; +const WRONG_PASSPHRASE = "pack.wrong_passphrase"; +/** 预览之后配置被改过:导入被拒,重新预览 */ +const STALE = "control.config_stale"; + +const codeOf = (e: unknown) => + typeof e === "object" && e !== null ? (e as { code?: unknown }).code : undefined; + +type Phase = + | { kind: "reading" } + | { kind: "passphrase"; error: string | null } + | { kind: "failed"; error: unknown } + | { kind: "ready"; preview: ImportPreview }; + +/** + * 导入配置(规格 5.3–5.5):先预览,逐项列出新增、冲突、错误(相同的只计数),冲突逐项选 + * 覆盖 / 改名导入 / 跳过,没选完「导入」按不下去。加密的配置包先要口令。 + * + * 预览之后配置被改过(别处改了、同步来了):导入被拒,说一声、给「重新预览」。 + */ +export function ImportDialog({ + file, + onClose, + onImported, +}: { + file: PickedFile; + onClose: () => void; + /** 导入成功:外面重读概览 */ + onImported?: () => void; +}) { + const t = useText(backupText); + const c = useText(commonText); + const [phase, setPhase] = useState({ kind: "reading" }); + const [passphrase, setPassphrase] = useState(""); + /** 预览用过的口令:导入时带同一个 */ + const used = useRef(null); + const [choices, setChoices] = useState({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const items = useConfigItems(); + const remote = useRemote() !== null; + + const preview = useCallback( + async (pass: string | null) => { + setError(null); + setStale(false); + try { + const p = await backupApi.preview(file, pass); + used.current = pass; + // 重新预览(配置在预览之后被改过):已经选好的处理留着 + setChoices((before) => carryOver(p.rows, initialChoices(p.rows), before)); + setPhase({ kind: "ready", preview: p }); + } catch (e) { + const code = codeOf(e); + if (code === NEED_PASSPHRASE) setPhase({ kind: "passphrase", error: null }); + else if (code === WRONG_PASSPHRASE) setPhase({ kind: "passphrase", error: errorText(e) }); + else setPhase({ kind: "failed", error: e }); + } + }, + [file], + ); + useEffect(() => { + void preview(null); + }, [preview]); + + const taken = useMemo(() => { + const m = new Map>(); + for (const n of items.data ?? []) { + const s = m.get(n.item.kind) ?? new Set(); + s.add(n.item.name); + m.set(n.item.kind, s); + } + return m; + }, [items.data]); + + async function run(p: ImportPreview) { + setBusy(true); + setError(null); + try { + const ds = decisions(p.rows, choices); + await backupApi.import(file, used.current, p.base_version, ds); + // 连着远程 core:通用偏好在这台设备上,配置包里带着的由这里应用(本机模式下 core 存着, + // 变了它自己说一声) + if (remote && p.client_prefs != null) { + await backupApi.applyClientPrefs(p.client_prefs).catch((e: unknown) => notify.error(e)); + } + notify.success(t.imported(writing(ds))); + invalidate(BACKUPS_KEY); + onImported?.(); + onClose(); + } catch (e) { + if (codeOf(e) === STALE) setStale(true); + else setError(errorText(e)); + } finally { + setBusy(false); + } + } + + const ready = phase.kind === "ready" ? phase.preview : null; + const rows = ready ? ready.rows : []; + const problems = ready ? nameProblems(rows, choices, taken) : {}; + const stuck = ready ? blocked(rows, choices) : {}; + const open = ready ? unresolved(rows, choices) : 0; + const ds = ready ? decisions(rows, choices) : []; + const count = writing(ds); + const bad = Object.keys(problems).length > 0; + const n = tally(rows); + const footNote = !ready + ? null + : open > 0 + ? t.unresolved(open) + : bad + ? t.badNames + : listed(rows).length === 0 + ? t.allSame + : count === 0 + ? t.nothing + : null; + + return ( + !o && onClose()}> + e.preventDefault()}> + + {t.importTitle} + + {ready ? : {file.name}} + + + + {phase.kind === "reading" && } + + {phase.kind === "failed" && ( + + {errorText(phase.error)} + + )} + + {phase.kind === "passphrase" && ( +
{ + e.preventDefault(); + if (passphrase !== "") void preview(passphrase); + }} + > +

{t.encrypted}

+
+ setPassphrase(e.target.value)} + /> + +
+ {phase.error &&

{phase.error}

} +
+ )} + + {ready && ( + <> +
+ + {[ + t.countNew(n.new), + t.countConflict(n.conflict), + t.countSame(n.same), + ...(n.error > 0 ? [t.countError(n.error)] : []), + ].join(" · ")} + + {n.conflict > 0 && ( +
+ {(["overwrite", "rename", "skip"] as const).map((a) => ( + + ))} +
+ )} +
+ void preview(used.current)}> + {t.previewAgain} + + } + /> +
+ {ready.repairs.length > 0 && ( + + + + )} + setChoices((cs) => ({ ...cs, [key]: { ...cs[key]!, ...next } }))} + /> + {n.same > 0 && listed(rows).length > 0 && ( +

{t.sameHidden(n.same)}

+ )} +
+ + )} + + + + + {footNote && {footNote}} + + {ready && ( + + )} + +
+
+ ); +} + +/** 对话框顶上那一句:哪个文件、完整还是部分、含不含密钥、什么时候由哪一版 core 导出 */ +function PackLine({ name, p }: { name: string; p: ImportPreview }) { + const t = useText(backupText); + const m = p.manifest; + return ( + <> + {name} + {" · "} + {m.kind === "full" ? t.packFull : t.packPartial} + {" · "} + {m.secrets ? t.withSecrets : t.withoutSecrets} + {m.encrypted && <> · {t.packEncrypted}} + {" · "} + {t.exportedBy(whenMinute(m.created_at_ms), m.core_version)} + + ); +} + +/** 逐项的表:按类分组,一行一项。相同的不列 */ +function RowsTable({ + rows, + choices, + problems, + stuck, + disabled, + onChange, +}: { + rows: readonly ImportRow[]; + choices: Choices; + problems: Record; + /** 因为引用了不导入的项目而导不进来的行 → 缺的那一项 */ + stuck: Record; + disabled: boolean; + onChange: (key: string, next: Partial) => void; +}) { + const t = useText(backupText); + const groups = groupBy(listed(rows), (r) => r.item); + if (groups.length === 0) return null; + return ( + + + + {t.name} + {t.status} + {t.action} + + + + {groups.map((g) => ( + + + + {kindLabel(g.kind)} + + + {g.items.map((r) => { + const key = refKey(r.item); + return ( + + + {itemLabel(r.item)} + + + + + + onChange(key, next)} + /> + + + ); + })} + + ))} + +
+ ); +} + +/** 状态那一格:新增 / 冲突 / 错误,加上导入后停用的几种标记;后面一句是哪里不同、为什么不行 */ +function StatusCell({ r, missing, notes = [] }: { r: ImportRow; missing?: ItemRef; notes?: RowNote[] }) { + const t = useText(backupText); + if (missing) { + return ( + + {t.statusError} + {t.refSkipped(kindLabel(missing.kind), itemLabel(missing))} + + ); + } + const hint = + r.status === "error" + ? r.error + ? coreText(r.error) + : null + : r.status === "conflict" + ? differsLabel(r.differs) + : (r.flags.map((f) => t.flagHints[f])[0] ?? null); + return ( + + {r.status === "new" && r.flags.length === 0 && {t.statusNew}} + {r.status === "conflict" && {t.statusConflict}} + {r.status === "error" && {t.statusError}} + {r.status !== "error" && + r.flags.map((f) => ( + + {t.flags[f]} + + ))} + {hint && {hint}} + {notes.map((n) => ( + + {t.rowNotes[n]} + + ))} + + ); +} + +/** 处理那一格:新增的一个勾选框,冲突的三选一(改名导入带一个名字),错误的写「不可导入」 */ +function ActionCell({ + r, + stuck, + choice, + problem, + disabled, + onChange, +}: { + r: ImportRow; + stuck: boolean; + choice: Choices[string] | undefined; + problem: "empty" | "taken" | undefined; + disabled: boolean; + onChange: (next: Partial) => void; +}) { + const t = useText(backupText); + if (r.status === "error" || stuck) { + return {t.cannotImport}; + } + if (r.status === "new") { + return ( + + ); + } + const label: Record = { + import: t.doImport, + overwrite: t.overwrite, + rename: t.rename, + skip: t.skip, + }; + return ( +
+ + // 没选的冲突:一段都不亮 + value={(choice?.action ?? "") as ImportAction} + label={itemLabel(r.item)} + disabled={disabled} + options={actionsFor(r).map((a) => ({ id: a, label: label[a] }))} + onChange={(a) => onChange({ action: a })} + /> + {choice?.action === "rename" && ( + <> + onChange({ name: e.target.value })} + /> + {problem && ( + {problem === "empty" ? t.nameEmpty : t.nameTaken} + )} + + )} +
+ ); +} diff --git a/src/backup/Passphrase.tsx b/src/backup/Passphrase.tsx new file mode 100644 index 00000000..3a46c1ce --- /dev/null +++ b/src/backup/Passphrase.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { FormRow } from "@/settings/form"; +import { SecretInput } from "@/ui/secret-input"; +import { Switch } from "@/ui/switch"; +import { useText } from "@/i18n"; +import { backupText } from "./backup.i18n"; + +/** 导出时设的口令最短几个字符(规格 5.2) */ +export const MIN_PASSPHRASE = 8; + +export interface Passphrase { + on: boolean; + setOn: (on: boolean) => void; + first: string; + setFirst: (v: string) => void; + again: string; + setAgain: (v: string) => void; + /** 打开了加密、口令不合要求时那一句。没打开或者没毛病是 null */ + problem: string | null; + /** 交给 core 的口令:没打开加密是 null */ + value: string | null; +} + +/** 「口令加密」那一行的状态:开关缺省关;开了之后口令必填、至少 8 个字符、两次一致。口令不保存 */ +export function usePassphrase(): Passphrase { + const t = useText(backupText); + const [on, setOn] = useState(false); + const [first, setFirst] = useState(""); + const [again, setAgain] = useState(""); + const problem = !on + ? null + : first.length < MIN_PASSPHRASE + ? t.passphraseShort + : first !== again + ? t.passphraseMismatch + : null; + return { on, setOn, first, setFirst, again, setAgain, problem, value: on ? first : null }; +} + +/** + * 两列表单里的「口令加密」一行:开关和说明,开了之后下面两个密码框。**没填完不报错**: + * 第二格还空着的时候不说「不一致」,说明照旧是那一句 + */ +export function PassphraseRow({ p, disabled }: { p: Passphrase; disabled?: boolean }) { + const t = useText(backupText); + const typing = p.on && p.again === "" && p.first.length >= MIN_PASSPHRASE; + const shown = p.on && p.problem && (p.first !== "" || p.again !== "") && !typing ? p.problem : null; + return ( + {shown} : t.encryptHint}> + + {p.on && ( +
+ p.setFirst(e.target.value)} + /> + p.setAgain(e.target.value)} + /> +
+ )} +
+ ); +} diff --git a/src/backup/RestoreDialog.tsx b/src/backup/RestoreDialog.tsx new file mode 100644 index 00000000..ad7c564d --- /dev/null +++ b/src/backup/RestoreDialog.tsx @@ -0,0 +1,338 @@ +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { invalidate } from "@/lib/resource"; +import { Badge } from "@/ui/badge"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { notify } from "@/ui/notify"; +import { ErrorState, TableSkeleton } from "@/ui/states"; +import { Spinner } from "@/ui/spinner"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { whenMinute } from "@/format"; +import { PluginConfirmNotice } from "@/ConfigText"; +import { needsConfirmation } from "@/plugins/write"; +import { DialogError } from "@/upstreams/parts"; +import { FixList } from "@/repair"; +import type { BackupEntry, ItemRef, RestorePreview, RestoreRow } from "@/types"; +import { BACKUPS_KEY, backupApi } from "./api"; +import { backupText } from "./backup.i18n"; +import { groupBy, itemLabel, kindLabel, namedById, originLabel, refKey } from "./items"; +import { planRows, selectable, selection, tallyWritten, type PlanRow } from "./restorePlan"; + +const STALE = "control.config_stale"; + +/** 改了勾选之后等这么久再去要计划:连着点几下只要一次 */ +const REPLAN_MS = 300; + +/** + * 恢复一个备份版本(规格 7.2):按项目列出该版本与当前配置的差异,缺省全勾,即整体换成该版本; + * 取消勾选的保持当前内容。本机设置和同名 ChatGPT 上游的登录由 core 保留当前的。 + * + * **改了勾选,表上的变化以 core 的计划为准**:用所选的几项重新预览(`items`),一并恢复的、因 + * 被引用而保留的照它写(`restorePlan.ts`)。恢复按的是同一个计划;完成后的提示按实际动了的写。 + * + * 恢复本身也是一个新版本,可以再恢复回来。 + */ +export function RestoreDialog({ + entry, + onClose, + onRestored, + onOpenPlugins, +}: { + entry: BackupEntry; + onClose: () => void; + onRestored?: () => void; + /** 关掉这里、去插件页(恢复的版本要装上、启用改得了工具调用的插件时) */ + onOpenPlugins?: () => void; +}) { + const t = useText(backupText); + const c = useText(commonText); + /** 整个版本的差异:表的骨架 */ + const [base, setBase] = useState(null); + const [baseError, setBaseError] = useState(undefined); + /** 此刻所选的计划(和 `planFor` 对应),没取到之前是 null */ + const [plan, setPlan] = useState(null); + /** 计划是按哪一份所选取的(`JSON` 写法);和此刻的所选不一样时,计划还在路上 */ + const [planFor, setPlanFor] = useState(null); + /** 取消勾选的(其余都恢复) */ + const [unchecked, setUnchecked] = useState>(() => new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [pluginRefusal, setPluginRefusal] = useState(null); + const at = whenMinute(entry.at_ms); + /** 只认最后一次要的计划 */ + const asked = useRef(0); + + const loadBase = useCallback(() => { + setBaseError(undefined); + setStale(false); + const n = ++asked.current; + backupApi.restorePreview(entry.id, null).then( + (p) => { + if (n !== asked.current) return; + setBase(p); + setPlan(p); + setPlanFor(JSON.stringify(null)); + // 差异变了:取消勾选的那几项里不在了的去掉 + const keys = new Set(p.rows.map((r) => refKey(r.item))); + setUnchecked((u) => (Array.from(u).every((k) => keys.has(k)) ? u : new Set(Array.from(u).filter((k) => keys.has(k))))); + }, + (e: unknown) => n === asked.current && setBaseError(e), + ); + }, [entry.id]); + useEffect(loadBase, [loadBase]); + + const items: ItemRef[] | null = base ? selection(base.rows, unchecked) : null; + const want = JSON.stringify(items); + + // 勾选变了:所选的几项重新要一次计划(全选就是整个版本,用骨架那一份) + useEffect(() => { + if (!base || want === planFor) return; + if (items === null) { + setPlan(base); + setPlanFor(want); + return; + } + if (items.length === 0) return; + const n = ++asked.current; + const h = setTimeout(() => { + backupApi.restorePreview(entry.id, items).then( + (p) => { + if (n !== asked.current) return; + setPlan(p); + setPlanFor(want); + }, + (e: unknown) => n === asked.current && setError(errorText(e)), + ); + }, REPLAN_MS); + return () => clearTimeout(h); + // `items` 由 `want` 决定 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [base, want, planFor, entry.id]); + + async function run(p: RestorePreview) { + setBusy(true); + setError(null); + setPluginRefusal(null); + try { + const w = await backupApi.restore(entry.id, p.base_version, items); + const n = tallyWritten(w.rows); + notify.success(t.restored(at, n.restored, n.kept)); + invalidate(BACKUPS_KEY); + onRestored?.(); + onClose(); + } catch (e) { + if (typeof e === "object" && e !== null && (e as { code?: unknown }).code === STALE) setStale(true); + else if (needsConfirmation(e)) setPluginRefusal(errorText(e)); + else setError(errorText(e)); + } finally { + setBusy(false); + } + } + + const chosen = base ? base.rows.filter((r) => selectable(r) && !unchecked.has(refKey(r.item))).length : 0; + /** 计划和此刻的勾选对得上(没在路上)才能恢复 */ + const settled = plan !== null && planFor === want; + const shown = base && plan ? planRows(base.rows, plan.rows, unchecked) : []; + + return ( + !o && onClose()}> + e.preventDefault()}> + + {t.restoreVersion(at)} + + {originLabel(entry.origin, entry.device)} · {t.restoreDescription} + + + + + {t.previewAgain} + + } + /> + {pluginRefusal && ( + + )} + +
+ {base === null ? ( + baseError !== undefined ? ( + + ) : ( + + ) + ) : base.rows.length === 0 ? ( +

{t.unchanged}

+ ) : ( + <> + {(plan ?? base).repairs.length > 0 && ( + + + + )} + + setUnchecked((u) => { + const next = new Set(u); + for (const k of keys) { + if (on) next.delete(k); + else next.add(k); + } + return next; + }) + } + /> + + )} +
+ + + + + + {base && base.rows.length > 0 && chosen === 0 ? ( + t.nothingChecked + ) : !settled && base ? ( + <> + + {t.replanning} + + ) : plan && plan.sync_devices > 0 ? ( + t.syncNote(plan.sync_devices) + ) : null} + + + + +
+
+ ); +} + +/** + * 差异表:按类分组,一行一项,前面是勾选框;表头的勾选框管全部。变化一列照 core 的计划写: + * 勾着的写新增、删除、修改;没勾的写「保持当前内容」,被所选项目带着的写「一并恢复」;保留的 + * 写原因 + */ +function ChangesTable({ + rows, + disabled, + onToggle, +}: { + rows: readonly PlanRow[]; + disabled: boolean; + onToggle: (keys: string[], on: boolean) => void; +}) { + const t = useText(backupText); + const keys = rows.filter((r) => r.box).map((r) => refKey(r.row.item)); + const on = rows.filter((r) => r.box && r.checked).length; + const all = on === keys.length ? true : on === 0 ? false : "indeterminate"; + return ( + + + + + onToggle(keys, v === true)} + /> + + {t.item} + {t.change} + + + + {groupBy(rows, (r) => r.row.item).map((g) => ( + + + + {kindLabel(g.kind)} + + + {g.items.map((p) => { + const r = p.row; + const k = refKey(r.item); + return ( + + + {p.box && ( + onToggle([k], v === true)} + /> + )} + + + {itemLabel(r.item)} + + + + + + ); + })} + + ))} + +
+ ); +} + +function Change({ p }: { p: PlanRow }) { + const t = useText(backupText); + const r: RestoreRow = p.row; + if (p.note === "kept" || r.kept) { + return ( + + {r.kept === "key_in_use" ? t.keptInUse : t.keptReferenced} + + ); + } + if (p.note === "untouched") return {t.untouched}; + const badge = ( + + {t.changes[r.change]} + + ); + if (p.note === "along") { + return ( + + {badge} + {t.along2} + + ); + } + return badge; +} diff --git a/src/backup/SaveVersionDialog.tsx b/src/backup/SaveVersionDialog.tsx new file mode 100644 index 00000000..b8de52af --- /dev/null +++ b/src/backup/SaveVersionDialog.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { FormRows } from "@/settings/form"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { notify } from "@/ui/notify"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { whenMinute } from "@/format"; +import { DialogError } from "@/upstreams/parts"; +import type { BackupEntry } from "@/types"; +import { backupApi } from "./api"; +import { backupText } from "./backup.i18n"; +import { originLabel } from "./items"; +import { PassphraseRow, usePassphrase } from "./Passphrase"; + +/** + * 某一版本的「另存为…」(规格 7.1):把这一版存成一个配置包,放到任意位置。本机的版本含密钥、 + * 不加密,所以和导出一样可以选口令加密;不加密时提示文件里是明文密钥。 + */ +export function SaveVersionDialog({ entry, onClose }: { entry: BackupEntry; onClose: () => void }) { + const t = useText(backupText); + const c = useText(commonText); + const pass = usePassphrase(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run() { + setBusy(true); + setError(null); + try { + const f = await backupApi.file(entry.id, pass.value); + const path = await backupApi.save(f); + if (path) { + notify.success(t.saved(path)); + onClose(); + } + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + return ( + !o && onClose()}> + e.preventDefault()}> + + {t.saveTitle} + + {[ + whenMinute(entry.at_ms), + originLabel(entry.origin, entry.device), + t.counts(entry.counts.providers, entry.counts.plugins), + ].join(" · ")} + + + + + + + + {!pass.on && {t.plaintext}} + + + + + + ); +} diff --git a/src/backup/api.ts b/src/backup/api.ts new file mode 100644 index 00000000..65cf1419 --- /dev/null +++ b/src/backup/api.ts @@ -0,0 +1,45 @@ +/** + * 导入导出与备份要用的调用:控制面的那几个端点,和 Rust 这一侧读写文件的两个命令。 + * + * **文件只在 Rust 这一侧读写**(`src-tauri/src/export.rs`):保存弹系统的「存储」对话框,打开 + * 弹「打开」对话框、只列配置包;网页拿到的是读出来的内容(Base64),交给 core 的导入预览, + * 不能指定读写哪个路径。远程模式下也是这样:配置在服务器上,文件在这台设备上。 + */ +import { invoke } from "@tauri-apps/api/core"; +import { call } from "@/control"; +import type { + ExportRequest, + ExportedFile, + ImportDecision, + ItemRef, + PickedFile, +} from "@/types"; + +export const backupApi = { + items: () => call("ConfigItems", null), + export: (req: ExportRequest) => call("ExportConfig", req), + preview: (file: PickedFile, passphrase: string | null) => + call("ImportPreview", { data_b64: file.data_b64, passphrase }), + import: (file: PickedFile, passphrase: string | null, baseVersion: string, decisions: ImportDecision[]) => + call("ImportConfig", { data_b64: file.data_b64, passphrase, base_version: baseVersion, decisions }), + list: () => call("Backups", null), + file: (id: string, passphrase: string | null) => call("BackupFile", { id, passphrase }), + /** 恢复会动什么。`items`:只恢复这几项时(null 是整个版本) */ + restorePreview: (id: string, items: ItemRef[] | null) => call("RestorePreview", items ? { id, items } : { id }), + restore: (id: string, baseVersion: string, items: ItemRef[] | null) => + call("Restore", { id, base_version: baseVersion, items }), + /** + * 远程模式下 Lite 自己的通用偏好(导出时附上,规格 4.4)。本机模式下 core 存着它,不用附 + */ + clientPrefs: () => invoke("client_prefs"), + /** 远程模式下导入的配置包带着通用偏好:应用到这台设备上 */ + applyClientPrefs: (prefs: unknown) => invoke("apply_client_prefs", { prefs }), + /** 弹「打开」对话框,读所选的配置包。取消了是 `null` */ + open: () => invoke("open_import"), + /** 弹「存储」对话框,把配置包写到所选的位置。交回写到的路径;取消了是 `null` */ + save: (f: ExportedFile) => + invoke("save_export", { name: f.file_name, contents: f.data_b64, base64: true }), +}; + +/** 备份列表在 `useResource` 里的键 */ +export const BACKUPS_KEY = "backups"; diff --git a/src/backup/backup.i18n.ts b/src/backup/backup.i18n.ts new file mode 100644 index 00000000..b7693836 --- /dev/null +++ b/src/backup/backup.i18n.ts @@ -0,0 +1,440 @@ +import { messages } from "@/i18n"; + +const s = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n} ${many}`); + +/** + * 导入导出与备份:设置「同步与备份」里的备份部分、导出与导入对话框、恢复预览、另存版本、 + * 拖放提示。项目的类别与名称、来源的叫法也在这里(`items.ts`)。 + */ +export const backupText = messages( + { + // ------------------------------------------------------------ 设置里的「备份」 + heading: "备份", + importConfig: "导入配置…", + exportConfig: "导出配置…", + time: "时间", + origin: "来源", + location: "存放位置", + contents: "内容", + coreVersion: "Core 版本", + actionsColumn: "操作", + actionsFor: (at: string) => `${at} 的版本的操作`, + current: "当前", + restore: "恢复…", + saveAs: "另存为…", + local: "本机", + synced: "同步位置", + counts: (providers: number, plugins: number) => `${providers} 个上游,${plugins} 个插件`, + keeps: "本机保留最近 10 个版本;同步位置保留最新与上一个版本。", + listFailed: "备份列表读取失败", + noBackups: "暂无备份版本", + noBackupsHint: "每次配置变更都会在此生成一个版本。", + /** 工具栏、命令面板、安全模式的出错页打开的那一个对话框 */ + dialogTitle: "备份", + dialogDescription: "恢复前列出该版本与当前配置的差异。恢复本身也会生成一个版本,可再次恢复。", + restoreTitle: "从备份恢复", + + // ------------------------------------------------------------ 来源 + origins: { + ui: "界面修改", + cli: "命令行", + external: "手动编辑文件", + defaults: "默认插件", + sync: "同步", + syncFrom: (device: string) => `同步自 ${device}`, + import: "导入", + restore: "恢复", + repair: "一键修复", + export: "导出", + share: "分享", + rotation: "凭据轮换", + other: "其他", + }, + + // ------------------------------------------------------------ 项目 + kinds: { + provider: "上游", + proxy: "出站代理", + key: "网关密钥", + alias: "别名", + group: "策略组", + route: "路由", + price_sheet: "价目表", + security_rule: "安全规则", + plugin: "插件", + global: "全局设置", + provider_order: "上游顺序", + route_order: "路由顺序", + plugin_order: "插件顺序", + client_prefs: "Lite 通用偏好", + }, + globals: { + failover: "故障转移", + security: "防护状态", + default_route: "默认路由", + default_key: "默认密钥", + client_probes: "辅助请求", + pricing: "价目表设置", + }, + guards: { + redact: "密钥与隐私", + inspect_tools: "危险命令", + content: "内容过滤", + }, + /** 冲突时不同的合并单位 */ + units: { + conn: "连接", + models: "模型", + billing: "计费", + other: "其他设置", + all: "内容", + script: "脚本", + settings: "设置", + enabled: "启用状态", + }, + // 字段名(英文)后面隔一个空格 + differ: (what: string, _n: number) => (/[A-Za-z0-9_]$/.test(what) ? `${what} 不同` : `${what}不同`), + /** 列名字时的分隔 */ + sep: "、", + + // ------------------------------------------------------------ 导出 + exportTitle: "导出配置", + scope: "范围", + scopeAll: "全部配置", + scopeItems: "选择项目", + total: (providers: number, keys: number, routes: number, plugins: number) => + `${providers} 个上游、${keys} 把网关密钥、${routes} 条路由、${plugins} 个插件。`, + selected: (n: number) => `已选 ${n} 项。`, + noneSelected: "未选择项目。", + along: "随附", + alongTip: "被所选项目引用,随之导出", + itemsFailed: "配置项目读取失败", + secrets: "包含密钥", + secretsWhat: "上游密钥、网关密钥与代理密码", + encrypt: "口令加密", + encryptWhat: "以口令加密文件", + encryptHint: "加密后,导入时需输入口令。", + passphrase: "口令", + passphraseAgain: "再次输入", + passphraseShort: "口令至少 8 个字符。", + passphraseMismatch: "两次输入的口令不一致。", + omitted: "不随文件导出", + /** ChatGPT 账号和 OAuth 上游的登录凭据留在本机 */ + omitLogins: (n: number) => `ChatGPT 账号与 OAuth 的登录凭据(${n} 个上游),导入后需重新登录或填写。`, + omitSecrets: "上游密钥、网关密钥与代理密码。导入后上游为停用状态,须补填密钥;网关密钥重新生成。", + omitLocal: "监听、远程控制与同步设置。", + omitData: "请求记录。", + plaintext: "文件包含明文密钥,请妥善保管。", + exportAction: "导出…", + exported: (path: string) => `已导出到 ${path}`, + + // ------------------------------------------------------------ 导入 + importTitle: "导入配置", + reading: "正在读取配置包", + readFailed: "无法读取配置包", + encrypted: "此配置包已加密,须输入导出时设置的口令。", + continue: "继续", + packFull: "完整配置包", + packPartial: "部分配置包", + withSecrets: "包含密钥", + withoutSecrets: "不含密钥", + packEncrypted: "已加密", + exportedBy: (at: string, version: string) => `${at} 由 Core ${version} 导出`, + countNew: (n: number) => `新增 ${n}`, + countConflict: (n: number) => `冲突 ${n}`, + countSame: (n: number) => `相同 ${n}`, + countError: (n: number) => `错误 ${n}`, + overwriteAll: "全部覆盖", + renameAll: "全部改名导入", + skipAll: "全部跳过", + name: "名称", + status: "状态", + action: "处理", + statusNew: "新增", + statusConflict: "冲突", + statusError: "错误", + flags: { + missing_key: "缺少密钥", + needs_login: "需要登录", + plugin_disabled: "插件", + }, + flagHints: { + missing_key: "导入后为停用状态,须补填密钥", + needs_login: "导入后为停用状态,须重新登录", + plugin_disabled: "导入后为停用状态;可改写工具调用的插件启用时须批准", + }, + doImport: "导入", + overwrite: "覆盖", + rename: "改名导入", + skip: "跳过", + cannotImport: "不可导入", + newName: (name: string) => `${name} 的新名称`, + nameEmpty: "名称不能为空。", + nameTaken: "名称已被使用。", + unresolved: (n: number) => `尚有 ${n} 项冲突未选择处理方式。`, + badNames: "改名导入的名称有误。", + nothing: "没有要导入的项目。", + allSame: "配置包中的项目与当前配置相同。", + sameHidden: (n: number) => `另有 ${n} 项与当前配置相同,不再列出。`, + imported: (n: number) => `已导入 ${n} 项`, + /** 引用的项目不导入,这一项也导不进来 */ + refSkipped: (kind: string, name: string) => `引用的${kind}「${name}」不导入`, + /** 冲突行按所选处理补的一句:覆盖时目的地不同(core 标了 missing_key);改名导入带的标记(rename_flags) */ + rowNotes: { + destination: "地址与本机不同,不沿用本机密钥。导入后为停用状态,需填写密钥。", + renameKey: "改名导入不沿用本机密钥。导入后为停用状态,需填写密钥。", + renamePass: "改名导入不沿用本机的代理密码,导入后需填写。", + renameLogin: "改名导入不沿用本机的登录。导入后为停用状态,需重新登录。", + renamePlugin: "改名导入的插件导入后为停用状态。", + }, + /** 较旧版本写的、这一版读不进来的几处,按一键修复的规矩修好了 */ + repaired: (n: number) => `${n} 处内容由较旧版本写入,已按一键修复的规则修正:`, + stale: "配置在预览之后已被修改,须重新预览。", + previewAgain: "重新预览", + + // ------------------------------------------------------------ 恢复 + restoreVersion: (at: string) => `恢复 ${at} 的版本`, + restoreDescription: "勾选的项目恢复为该版本的内容,未勾选的保持当前内容。本机设置保留当前值。", + previewFailed: "无法读取该版本与当前配置的差异", + item: "项目", + change: "变化", + changes: { + add: "新增", + remove: "删除", + modify: "修改", + }, + keptInUse: "保留:本机客户端正在使用", + keptReferenced: "保留:仍被保留的项目引用", + unchanged: "该版本与当前配置相同。", + syncNote: (n: number) => `恢复后的配置将同步到其他 ${n} 台设备。`, + restoreAction: "恢复", + /** 按实际动了的写(`RestoreWritten.rows`) */ + restored: (at: string, n: number, kept: number) => + `已恢复 ${at} 的版本中的 ${n} 项${kept > 0 ? `,保留 ${kept} 项` : ""}`, + /** 改了勾选,正在按所选重新计算会动什么 */ + replanning: "正在按所选项目计算", + untouched: "保持当前内容", + /** 没勾、但所选的项目引用它 */ + along2: "随所选项目一并恢复", + nothingChecked: "未勾选项目。", + + // ------------------------------------------------------------ 另存版本 + saveTitle: "另存版本", + saveAction: "另存为…", + saved: (path: string) => `已保存到 ${path}`, + + // ------------------------------------------------------------ 拖放 + dropHere: "松开以导入配置包", + }, + { + heading: "Backups", + importConfig: "Import config…", + exportConfig: "Export config…", + time: "Time", + origin: "Source", + location: "Stored in", + contents: "Contents", + coreVersion: "Core", + actionsColumn: "Actions", + actionsFor: (at: string) => `Actions for the version from ${at}`, + current: "Current", + restore: "Restore…", + saveAs: "Save as…", + local: "Local", + synced: "Sync location", + counts: (providers: number, plugins: number) => + `${s(providers, "upstream", "upstreams")}, ${s(plugins, "plugin", "plugins")}`, + keeps: "This device keeps the last 10 versions; the sync location keeps the latest and the one before it.", + listFailed: "Could not read the backups", + noBackups: "No backups yet", + noBackupsHint: "Every change to the config creates a version here.", + dialogTitle: "Backups", + dialogDescription: + "Restoring first lists how the version differs from the current config. The restore itself creates a version that can be restored as well.", + restoreTitle: "Restore from backup", + + origins: { + ui: "Edited in the app", + cli: "Command line", + external: "File edited by hand", + defaults: "Default plugins", + sync: "Sync", + syncFrom: (device: string) => `Synced from ${device}`, + import: "Import", + restore: "Restore", + repair: "One-click repair", + export: "Export", + share: "Share", + rotation: "Credential rotation", + other: "Other", + }, + + kinds: { + provider: "Upstreams", + proxy: "Outbound proxies", + key: "Gateway keys", + alias: "Aliases", + group: "Groups", + route: "Routes", + price_sheet: "Price sheets", + security_rule: "Security rules", + plugin: "Plugins", + global: "Global settings", + provider_order: "Upstream order", + route_order: "Route order", + plugin_order: "Plugin order", + client_prefs: "Lite preferences", + }, + globals: { + failover: "Failover", + security: "Protection modes", + default_route: "Default route", + default_key: "Default key", + client_probes: "Auxiliary requests", + pricing: "Price sheet settings", + }, + guards: { + redact: "Secrets & privacy", + inspect_tools: "Dangerous commands", + content: "Content filter", + }, + units: { + conn: "connection", + models: "models", + billing: "billing", + other: "other settings", + all: "content", + script: "script", + settings: "settings", + enabled: "enabled state", + }, + differ: (what: string, n: number) => `${what.charAt(0).toUpperCase()}${what.slice(1)} ${n === 1 ? "differs" : "differ"}`, + sep: ", ", + + exportTitle: "Export config", + scope: "Scope", + scopeAll: "Entire config", + scopeItems: "Selected items", + total: (providers: number, keys: number, routes: number, plugins: number) => + `${s(providers, "upstream", "upstreams")}, ${s(keys, "gateway key", "gateway keys")}, ${s(routes, "route", "routes")}, ${s(plugins, "plugin", "plugins")}.`, + selected: (n: number) => `${n} selected.`, + noneSelected: "No items selected.", + along: "Included", + alongTip: "Referenced by a selected item and exported with it", + itemsFailed: "Could not read the config items", + secrets: "Include keys", + secretsWhat: "Upstream keys, gateway keys and proxy passwords", + encrypt: "Encryption", + encryptWhat: "Encrypt the file with a passphrase", + encryptHint: "An encrypted file asks for the passphrase when it is imported.", + passphrase: "Passphrase", + passphraseAgain: "Repeat", + passphraseShort: "The passphrase has at least 8 characters.", + passphraseMismatch: "The two passphrases differ.", + omitted: "Not exported", + omitLogins: (n: number) => + `ChatGPT account and OAuth sign-ins (${s(n, "upstream", "upstreams")}); sign in or fill them in again after importing.`, + omitSecrets: + "Upstream keys, gateway keys and proxy passwords. Imported upstreams stay disabled until their keys are filled in; gateway keys are generated anew.", + omitLocal: "Listening, remote control and sync settings.", + omitData: "Request history.", + plaintext: "The file contains keys in plain text. Keep it safe.", + exportAction: "Export…", + exported: (path: string) => `Exported to ${path}`, + + importTitle: "Import config", + reading: "Reading the config pack", + readFailed: "Could not read the config pack", + encrypted: "This config pack is encrypted. Enter the passphrase set when it was exported.", + continue: "Continue", + packFull: "Full config pack", + packPartial: "Partial config pack", + withSecrets: "Includes keys", + withoutSecrets: "Without keys", + packEncrypted: "Encrypted", + exportedBy: (at: string, version: string) => `Exported by Core ${version} at ${at}`, + countNew: (n: number) => `${n} new`, + countConflict: (n: number) => s(n, "conflict", "conflicts"), + countSame: (n: number) => `${n} same`, + countError: (n: number) => s(n, "error", "errors"), + overwriteAll: "Overwrite all", + renameAll: "Rename all", + skipAll: "Skip all", + name: "Name", + status: "Status", + action: "Action", + statusNew: "New", + statusConflict: "Conflict", + statusError: "Error", + flags: { + missing_key: "Key missing", + needs_login: "Sign-in required", + plugin_disabled: "Plugin", + }, + flagHints: { + missing_key: "Imported disabled until the key is filled in", + needs_login: "Imported disabled until signed in again", + plugin_disabled: "Imported disabled; a plugin that rewrites tool calls needs approval when enabled", + }, + doImport: "Import", + overwrite: "Overwrite", + rename: "Rename", + skip: "Skip", + cannotImport: "Cannot import", + newName: (name: string) => `New name for ${name}`, + nameEmpty: "The name is empty.", + nameTaken: "The name is in use.", + unresolved: (n: number) => + n === 1 ? "1 conflict has no action selected." : `${n} conflicts have no action selected.`, + badNames: "A new name is not valid.", + nothing: "Nothing to import.", + allSame: "Every item in the pack matches the current config.", + sameHidden: (n: number) => + n === 1 ? "1 more item matches the current config and is not listed." : `${n} more items match the current config and are not listed.`, + imported: (n: number) => `Imported ${s(n, "item", "items")}`, + refSkipped: (kind: string, name: string) => `References ${name} (${kind.toLowerCase()}), which is not imported`, + rowNotes: { + destination: + "The address differs from the one here, so the key here is not reused. Imported disabled until a key is filled in.", + renameKey: "Imported under a new name, the key here is not reused. Imported disabled until a key is filled in.", + renamePass: "Imported under a new name, the proxy password here is not reused; fill it in after importing.", + renameLogin: "Imported under a new name, the sign-in here is not reused. Imported disabled until signed in again.", + renamePlugin: "A plugin imported under a new name is disabled.", + }, + repaired: (n: number) => + n === 1 + ? "1 entry written by an older version was fixed the way one-click repair fixes it:" + : `${n} entries written by an older version were fixed the way one-click repair fixes them:`, + stale: "The config changed after the preview. Preview again.", + previewAgain: "Preview again", + + restoreVersion: (at: string) => `Restore the version from ${at}`, + restoreDescription: + "Checked items are restored to this version; unchecked items keep their current content. Settings bound to this device keep their current values.", + previewFailed: "Could not compare this version with the current config", + item: "Item", + change: "Change", + changes: { + add: "Add", + remove: "Remove", + modify: "Change", + }, + keptInUse: "Kept: in use by a client on this device", + keptReferenced: "Kept: still referred to by items that stay", + unchanged: "This version is the same as the current config.", + syncNote: (n: number) => `The restored config will sync to ${s(n, "other device", "other devices")}.`, + restoreAction: "Restore", + restored: (at: string, n: number, kept: number) => + `Restored ${s(n, "item", "items")} from the version of ${at}${kept > 0 ? `; kept ${kept}` : ""}`, + replanning: "Working out what the selection changes", + untouched: "Stays as it is", + along2: "Restored with the selected items", + nothingChecked: "No items checked.", + + saveTitle: "Save version", + saveAction: "Save as…", + saved: (path: string) => `Saved to ${path}`, + + dropHere: "Drop to import the config pack", + }, +); diff --git a/src/backup/importFlow.test.ts b/src/backup/importFlow.test.ts new file mode 100644 index 00000000..ec7bcd6a --- /dev/null +++ b/src/backup/importFlow.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { ImportRow, ItemKind } from "@/types"; +import { + actionsFor, + blocked, + bulk, + carryOver, + decisions, + initialChoices, + rowNotes, + listed, + nameProblems, + tally, + unresolved, + writing, +} from "./importFlow"; + +function row(kind: ItemKind, name: string, status: ImportRow["status"], patch: Partial = {}): ImportRow { + return { + item: { kind, name }, + status, + global: kind === "global", + flags: [], + differs: [], + suggested_name: status === "conflict" && kind !== "global" ? `${name} (2)` : null, + error: null, + refs: [], + rename_flags: [], + ...patch, + }; +} + +const ROWS: ImportRow[] = [ + row("provider", "OpenRouter", "new"), + row("provider", "公司中转", "conflict", { differs: ["conn", "models"] }), + row("provider", "ChatGPT", "new", { flags: ["needs_login"] }), + row("provider", "Bedrock 东京", "error", { + error: { code: "x", text: "no profile" }, + }), + row("provider", "anthropic", "same"), + row("global", "failover", "conflict"), + row("plugin", "headers", "new", { flags: ["plugin_disabled"] }), +]; + +/** 规格 5.4 的逐项状态与处理 */ +describe("导入的逐项处理", () => { + it("新增的缺省导入,冲突的没有缺省值,相同与错误的不处理", () => { + const c = initialChoices(ROWS); + expect(c["provider:OpenRouter"]?.action).toBe("import"); + expect(c["provider:公司中转"]?.action).toBeNull(); + expect(c["provider:公司中转"]?.name).toBe("公司中转 (2)"); + expect(c["provider:Bedrock 东京"]?.action).toBe("skip"); + expect(unresolved(ROWS, c)).toBe(2); + expect(tally(ROWS)).toEqual({ new: 3, conflict: 2, same: 1, error: 1 }); + // 相同的只计数,不列出 + expect(listed(ROWS).some((r) => r.status === "same")).toBe(false); + }); + + it("全局设置只能覆盖或跳过;新增的只能导入或跳过", () => { + expect(actionsFor(ROWS[1]!)).toEqual(["overwrite", "rename", "skip"]); + expect(actionsFor(ROWS[5]!)).toEqual(["overwrite", "skip"]); + expect(actionsFor(ROWS[0]!)).toEqual(["import", "skip"]); + expect(actionsFor(ROWS[3]!)).toEqual([]); + }); + + it("「全部改名导入」只改能改名的冲突,全局设置留着待选", () => { + const c = bulk(ROWS, initialChoices(ROWS), "rename"); + expect(c["provider:公司中转"]?.action).toBe("rename"); + expect(c["global:failover"]?.action).toBeNull(); + expect(unresolved(ROWS, c)).toBe(1); + const all = bulk(ROWS, c, "skip"); + expect(all["provider:公司中转"]?.action).toBe("skip"); + expect(all["global:failover"]?.action).toBe("skip"); + // 新增的不受「全部跳过」影响 + expect(all["provider:OpenRouter"]?.action).toBe("import"); + expect(unresolved(ROWS, all)).toBe(0); + }); + + it("改名的名字不能空、不能和本机或配置包里同类的重名", () => { + let c = bulk(ROWS, initialChoices(ROWS), "rename"); + const taken = new Map>([["provider", new Set(["公司中转", "local-only"])]]); + expect(nameProblems(ROWS, c, taken)).toEqual({}); + c = { ...c, "provider:公司中转": { action: "rename", name: " " } }; + expect(nameProblems(ROWS, c, taken)).toEqual({ "provider:公司中转": "empty" }); + c = { ...c, "provider:公司中转": { action: "rename", name: "local-only" } }; + expect(nameProblems(ROWS, c, taken)).toEqual({ "provider:公司中转": "taken" }); + c = { ...c, "provider:公司中转": { action: "rename", name: "OpenRouter" } }; + expect(nameProblems(ROWS, c, taken)).toEqual({ "provider:公司中转": "taken" }); + // 别的类里同名不算 + c = { ...c, "provider:公司中转": { action: "rename", name: "headers" } }; + expect(nameProblems(ROWS, c, taken)).toEqual({}); + }); + + it("重新预览之后选过的留着,状态变了的按新的来", () => { + const before = bulk(ROWS, initialChoices(ROWS), "overwrite"); + const rows = ROWS.map((r) => (r.item.name === "OpenRouter" ? { ...r, status: "conflict" as const } : r)); + const c = carryOver(rows, initialChoices(rows), before); + expect(c["provider:公司中转"]?.action).toBe("overwrite"); + expect(c["global:failover"]?.action).toBe("overwrite"); + // 原来是新增(导入),现在成了冲突:导入不是冲突的处理,要重新选 + expect(c["provider:OpenRouter"]?.action).toBeNull(); + }); + + /** 规格 5.4:引用了跳过的、不可导入的项目的,随之不可导入;改名导入的、本机已有同名的不算 */ + it("引用了不导入的项目的随之不可导入,一层层传下去", () => { + const rows: ImportRow[] = [ + row("provider", "relay", "new"), + row("provider", "bad", "error"), + row("provider", "anthropic", "conflict"), + row("group", "main", "new", { refs: [{ kind: "provider", name: "relay" }] }), + row("route", "long", "new", { refs: [{ kind: "group", name: "main" }] }), + row("route", "tokyo", "new", { refs: [{ kind: "provider", name: "bad" }] }), + row("route", "direct", "conflict", { refs: [{ kind: "provider", name: "anthropic" }] }), + ]; + let c = initialChoices(rows); + expect(Object.keys(blocked(rows, c))).toEqual(["route:tokyo"]); + c = { ...c, "provider:relay": { action: "skip", name: "" } }; + expect(blocked(rows, c)).toEqual({ + "group:main": { kind: "provider", name: "relay" }, + "route:long": { kind: "group", name: "main" }, + "route:tokyo": { kind: "provider", name: "bad" }, + }); + // 跳过冲突的上游:本机有同名的,引用它的照样能导入 + c = { ...c, "provider:anthropic": { action: "skip", name: "" }, "route:direct": { action: "overwrite", name: "" } }; + expect(blocked(rows, c)["route:direct"]).toBeUndefined(); + // 不可导入的交「跳过」,不算没选 + const ds = decisions(rows, c); + expect(ds.find((d) => d.item.name === "long")?.action).toBe("skip"); + expect(unresolved(rows, { ...c, "route:direct": { action: null, name: "" } })).toBe(1); + }); + + /** 改名导入是另一个项目:带什么标记由 core 说(rename_flags);覆盖时目的地不同由 missing_key 说 */ + it("冲突行按所选处理补的说明", () => { + const r = row("provider", "relay", "conflict", { flags: ["missing_key"], rename_flags: ["missing_key", "needs_login"] }); + expect(rowNotes(r, { action: "overwrite", name: "" })).toEqual(["destination"]); + expect(rowNotes(r, { action: "rename", name: "relay (2)" })).toEqual(["renameKey", "renameLogin"]); + expect(rowNotes(r, { action: "skip", name: "" })).toEqual([]); + const proxy = row("proxy", "office", "conflict", { rename_flags: ["missing_key"] }); + expect(rowNotes(proxy, { action: "rename", name: "office (2)" })).toEqual(["renamePass"]); + expect(rowNotes(proxy, { action: "overwrite", name: "" })).toEqual([]); + }); + + it("交给 core 的:新增与冲突各一条,改名的带新名字,取消勾选的是跳过", () => { + let c = bulk(ROWS, initialChoices(ROWS), "rename"); + c = bulk(ROWS, c, "overwrite"); + c = { ...c, "provider:公司中转": { action: "rename", name: " 公司中转-备 " }, "plugin:headers": { action: "skip", name: "" } }; + const ds = decisions(ROWS, c); + expect(ds).toEqual([ + { item: { kind: "provider", name: "OpenRouter" }, action: "import" }, + { item: { kind: "provider", name: "公司中转" }, action: "rename", new_name: "公司中转-备" }, + { item: { kind: "provider", name: "ChatGPT" }, action: "import" }, + { item: { kind: "global", name: "failover" }, action: "overwrite" }, + { item: { kind: "plugin", name: "headers" }, action: "skip" }, + ]); + expect(writing(ds)).toBe(4); + }); +}); diff --git a/src/backup/importFlow.ts b/src/backup/importFlow.ts new file mode 100644 index 00000000..7b6e6d28 --- /dev/null +++ b/src/backup/importFlow.ts @@ -0,0 +1,205 @@ +/** + * 导入对话框里逐项的处理(规格 5.4):新增的缺省导入、可取消勾选;冲突的**没有缺省值**, + * 必须选覆盖 / 改名导入 / 跳过(全局设置只有覆盖 / 跳过);相同的不处理;错误的不可导入。 + * 改名导入的名字缺省是 core 给的建议,可改,须唯一。 + * + * 纯逻辑,不碰界面:对话框按它画、按它判断「导入」按不按得下去。 + */ +import type { ImportAction, ImportDecision, ImportRow, ItemKind, ItemRef } from "@/types"; +import { refKey } from "./items"; + +/** 一行此刻选的处理。`action` 为 null:冲突还没选 */ +export interface Choice { + action: ImportAction | null; + /** 改名导入的新名字(选了改名导入才用) */ + name: string; +} + +export type Choices = Record; + +/** 列在表里的行:相同的只计数,不列出 */ +export function listed(rows: readonly ImportRow[]): ImportRow[] { + return rows.filter((r) => r.status !== "same"); +} + +/** 改名导入的缺省名字:core 的建议,没有就是「原名 (2)」 */ +export function suggested(r: ImportRow): string { + return r.suggested_name ?? `${r.item.name} (2)`; +} + +/** 刚预览完:新增的导入,冲突的待选,别的跳过 */ +export function initialChoices(rows: readonly ImportRow[]): Choices { + const out: Choices = {}; + for (const r of rows) { + out[refKey(r.item)] = { + action: r.status === "new" ? "import" : r.status === "conflict" ? null : "skip", + name: suggested(r), + }; + } + return out; +} + +/** + * 重新预览之后:还在、状态没变的行留着原来选的(选过的冲突不必再选一遍),别的照刚预览完的 + */ +export function carryOver(rows: readonly ImportRow[], fresh: Choices, before: Choices): Choices { + const out = { ...fresh }; + for (const r of rows) { + const k = refKey(r.item); + const old = before[k]; + if (old?.action && actionsFor(r).includes(old.action)) out[k] = old; + } + return out; +} + +/** 这一行能选哪几种处理。全局设置、路由顺序、通用偏好没有改名导入 */ +export function actionsFor(r: ImportRow): ImportAction[] { + if (r.status === "new") return ["import", "skip"]; + if (r.status !== "conflict") return []; + return r.global || !renamable(r.item.kind) ? ["overwrite", "skip"] : ["overwrite", "rename", "skip"]; +} + +function renamable(kind: ItemKind): boolean { + return !["global", "provider_order", "route_order", "plugin_order", "client_prefs"].includes(kind); +} + +/** 「全部覆盖」「全部改名导入」「全部跳过」:只改冲突的行;不能改名的行在「全部改名导入」时不动 */ +export function bulk(rows: readonly ImportRow[], choices: Choices, action: "overwrite" | "rename" | "skip"): Choices { + const out = { ...choices }; + for (const r of rows) { + if (!actionsFor(r).includes(action) || r.status !== "conflict") continue; + const k = refKey(r.item); + out[k] = { action, name: out[k]?.name ?? suggested(r) }; + } + return out; +} + +/** + * 引用了不导入的项目、因而也导不进来的行(规格 5.4):配置包里新增的项目跳过了或者本身不可 + * 导入,引用它的路由、别名、策略组随之不可导入 —— 本机没有同名的可以引用。改名导入的不算 + * (引用跟着改名),本机已有同名的(相同、冲突)也不算。一层层传下去。交回:行 → 缺的那一项 + */ +export function blocked(rows: readonly ImportRow[], choices: Choices): Record { + const byKey = new Map(rows.map((r) => [refKey(r.item), r])); + const out: Record = {}; + const missing = (x: ItemRef) => { + const k = refKey(x); + const r = byKey.get(k); + if (!r) return false; + if (r.status === "error") return true; + return r.status === "new" && (choices[k]?.action === "skip" || out[k] !== undefined); + }; + for (let changed = true; changed; ) { + changed = false; + for (const r of rows) { + const k = refKey(r.item); + if (out[k] || (r.status !== "new" && r.status !== "conflict") || choices[k]?.action === "skip") continue; + const miss = r.refs.find(missing); + if (miss) { + out[k] = miss; + changed = true; + } + } + } + return out; +} + +/** 还没选处理方式的冲突有几项(因为引用而不可导入的不算:它没得选) */ +export function unresolved(rows: readonly ImportRow[], choices: Choices): number { + const stuck = blocked(rows, choices); + return rows.filter((r) => { + const k = refKey(r.item); + return r.status === "conflict" && choices[k]?.action == null && !stuck[k]; + }).length; +} + +/** + * 改名导入的名字哪些不行:空的,或者和同类里已有的(本机的、配置包里别的、别的行改成的) + * 重名。`taken`:本机每一类已有的名字 + */ +export function nameProblems( + rows: readonly ImportRow[], + choices: Choices, + taken: ReadonlyMap>, +): Record { + const out: Record = {}; + const stuck = blocked(rows, choices); + const renames = rows.filter((r) => choices[refKey(r.item)]?.action === "rename" && !stuck[refKey(r.item)]); + for (const r of renames) { + const k = refKey(r.item); + const name = (choices[k]?.name ?? "").trim(); + if (name === "") { + out[k] = "empty"; + continue; + } + const sameKind = (x: ImportRow) => x.item.kind === r.item.kind && x !== r; + const clash = + taken.get(r.item.kind)?.has(name) || + rows.some((x) => sameKind(x) && x.item.name === name) || + renames.some((x) => sameKind(x) && (choices[refKey(x.item)]?.name ?? "").trim() === name); + if (clash) out[k] = "taken"; + } + return out; +} + +/** + * 交给 core 的逐项处理:新增与冲突的行各一条(跳过的也交,core 照它不动)。因为引用而不可 + * 导入的交「跳过」 + */ +export function decisions(rows: readonly ImportRow[], choices: Choices): ImportDecision[] { + const out: ImportDecision[] = []; + const stuck = blocked(rows, choices); + for (const r of rows) { + if (r.status !== "new" && r.status !== "conflict") continue; + const k = refKey(r.item); + if (stuck[k]) { + out.push({ item: r.item, action: "skip" }); + continue; + } + const c = choices[k]; + if (!c?.action) continue; + out.push( + c.action === "rename" + ? { item: r.item, action: "rename", new_name: c.name.trim() } + : { item: r.item, action: c.action }, + ); + } + return out; +} + +/** 真的会写进配置的有几项 */ +export function writing(ds: readonly ImportDecision[]): number { + return ds.filter((d) => d.action !== "skip").length; +} + +/** 预览顶上那一行的几个数 */ +export function tally(rows: readonly ImportRow[]): Record { + const out = { new: 0, conflict: 0, same: 0, error: 0 }; + for (const r of rows) out[r.status] += 1; + return out; +} + +/** 冲突行上按所选处理补的一句(`RowNote`) */ +export type RowNote = "destination" | "renameKey" | "renamePass" | "renameLogin" | "renamePlugin"; + +/** + * 冲突行上 core 的标记之外要说的几句,按所选处理: + * + * - 覆盖、core 标了 `missing_key`:这一项的目的地(地址、区域、代理……)和本机的不一样,不沿用 + * 本机的密钥; + * - 改名导入:是另一个项目,不沿用本机的密钥和登录,带的标记由 core 给(`rename_flags`)。 + */ +export function rowNotes(r: ImportRow, c: Choices[string] | undefined): RowNote[] { + if (r.status !== "conflict") return []; + if (c?.action === "overwrite") return r.flags.includes("missing_key") ? ["destination"] : []; + if (c?.action !== "rename") return []; + return r.rename_flags.map((f) => + f === "missing_key" + ? r.item.kind === "proxy" + ? "renamePass" + : "renameKey" + : f === "needs_login" + ? "renameLogin" + : "renamePlugin", + ); +} diff --git a/src/backup/items.test.ts b/src/backup/items.test.ts new file mode 100644 index 00000000..be50c05a --- /dev/null +++ b/src/backup/items.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ItemNode, ItemRef } from "@/types"; +import { closure, differsLabel, groupBy, itemLabel, originLabel, refKey, refOf } from "./items"; + +beforeAll(() => setLang("zh")); + +const node = (kind: ItemRef["kind"], name: string, refs: ItemRef[] = []): ItemNode => ({ item: { kind, name }, refs }); + +describe("配置项目", () => { + it("键能原样拆回去,名字里有冒号也一样", () => { + for (const r of [ + { kind: "provider", name: "relay:cn" }, + { kind: "security_rule", name: "content/项目代号" }, + { kind: "route_order", name: "" }, + ] as ItemRef[]) { + expect(refOf(refKey(r))).toEqual(r); + } + }); + + /** 规格 5.2:所选项目引用的项目自动勾上,一层层往下找;标「随附」的只是因为被引用才进来的 */ + it("随附按引用一层层补齐", () => { + const nodes = [ + node("route", "长上下文", [{ kind: "provider", name: "公司中转" }, { kind: "group", name: "main" }]), + node("group", "main", [{ kind: "provider", name: "anthropic" }]), + node("provider", "公司中转", [{ kind: "proxy", name: "clash" }]), + node("provider", "anthropic"), + node("proxy", "clash"), + node("provider", "ollama"), + ]; + const { included, along } = closure(new Set([refKey({ kind: "route", name: "长上下文" })]), nodes); + expect([...included].sort()).toEqual( + ["group:main", "provider:anthropic", "provider:公司中转", "proxy:clash", "route:长上下文"].sort(), + ); + expect(along.has("route:长上下文")).toBe(false); + expect(along.has("proxy:clash")).toBe(true); + // 自己选了的不算随附 + const both = closure(new Set(["route:长上下文", "proxy:clash"]), nodes); + expect(both.along.has("proxy:clash")).toBe(false); + // 引用成环也停得下来 + const loop = [node("group", "a", [{ kind: "group", name: "b" }]), node("group", "b", [{ kind: "group", name: "a" }])]; + expect(closure(new Set(["group:a"]), loop).included.size).toBe(2); + }); + + it("全局设置写节名,安全规则写分项和规则名,认不出的照原样", () => { + expect(itemLabel({ kind: "global", name: "failover" })).toBe("故障转移"); + expect(itemLabel({ kind: "global", name: "something_new" })).toBe("something_new"); + expect(itemLabel({ kind: "security_rule", name: "content/项目代号" })).toBe("内容过滤 · 项目代号"); + expect(itemLabel({ kind: "security_rule", name: "other/x" })).toBe("other · x"); + expect(itemLabel({ kind: "client_prefs", name: "" })).toBe("Lite 通用偏好"); + expect(itemLabel({ kind: "provider", name: "relay" })).toBe("relay"); + }); + + it("按类分组:类的先后固定,组里保持原来的顺序,路由顺序归进全局设置", () => { + const groups = groupBy( + [ + { kind: "plugin", name: "p" }, + { kind: "provider", name: "b" }, + { kind: "route_order", name: "" }, + { kind: "provider", name: "a" }, + { kind: "global", name: "failover" }, + ] as ItemRef[], + (r) => r, + ); + expect(groups.map((g) => g.kind)).toEqual(["provider", "plugin", "global"]); + expect(groups[0]!.items.map((r) => r.name)).toEqual(["b", "a"]); + expect(groups[2]!.items.map((r) => r.kind)).toEqual(["route_order", "global"]); + }); + + it("来源与不同之处的写法", () => { + expect(originLabel("sync", "MacBook Air")).toBe("同步自 MacBook Air"); + expect(originLabel("sync", null)).toBe("同步"); + expect(originLabel("external", null)).toBe("手动编辑文件"); + expect(differsLabel(["conn", "models"])).toBe("连接、模型不同"); + expect(differsLabel(["retries"])).toBe("retries 不同"); + expect(differsLabel([])).toBeNull(); + }); +}); diff --git a/src/backup/items.ts b/src/backup/items.ts new file mode 100644 index 00000000..c4ce4ddf --- /dev/null +++ b/src/backup/items.ts @@ -0,0 +1,135 @@ +/** + * 配置里的「项目」:导出时选的、导入预览逐项列的、恢复预览逐项勾的,都是它(core 的 + * `ItemRef`:类别 + 名字)。这里是它们在界面上的叫法、排法,和导出时「随附」的算法。 + */ +import { textOf } from "@/i18n"; +import type { ConfigOrigin, Guard, ItemKind, ItemNode, ItemRef, PackOrigin } from "@/types"; +import { backupText } from "./backup.i18n"; + +/** 列表里各类的先后:和导出对话框里的分组一样 */ +export const KIND_ORDER: readonly ItemKind[] = [ + "provider", + "proxy", + "key", + "alias", + "group", + "route", + "price_sheet", + "security_rule", + "plugin", + "global", + "provider_order", + "route_order", + "plugin_order", + "client_prefs", +]; + +/** 不按名字、整份一项的几类:几样东西的先后、Lite 的通用偏好 */ +const WHOLE: readonly ItemKind[] = ["provider_order", "route_order", "plugin_order", "client_prefs"]; + +/** 列表里当一类显示的:几样东西的先后、Lite 通用偏好并进「全局设置」那一组 */ +export function groupOf(kind: ItemKind): ItemKind { + return WHOLE.includes(kind) ? "global" : kind; +} + +/** 一个项目的键:类别里没有 `:`,所以第一个 `:` 之后都是名字 */ +export function refKey(r: ItemRef): string { + return `${r.kind}:${r.name}`; +} + +export function refOf(key: string): ItemRef { + const i = key.indexOf(":"); + return { kind: key.slice(0, i) as ItemKind, name: key.slice(i + 1) }; +} + +export function kindLabel(kind: ItemKind): string { + return textOf(backupText).kinds[kind]; +} + +const GUARDS: readonly Guard[] = ["redact", "inspect_tools", "content"]; + +/** + * 一个项目在列表里写成什么。全局设置写节的名字,安全规则写「分项 · 规则名」,路由顺序、 + * Lite 通用偏好写类别。**认不出的节名、分项照原样写**:core 以后多一节,界面照样列得出来 + */ +export function itemLabel(r: ItemRef): string { + const t = textOf(backupText); + switch (r.kind) { + case "global": + return (t.globals as Record)[r.name] ?? r.name; + case "provider_order": + case "route_order": + case "plugin_order": + case "client_prefs": + return t.kinds[r.kind]; + case "security_rule": { + const i = r.name.indexOf("/"); + if (i < 0) return r.name; + const guard = r.name.slice(0, i); + const rule = r.name.slice(i + 1); + const g = GUARDS.includes(guard as Guard) ? t.guards[guard as Guard] : guard; + return `${g} · ${rule}`; + } + default: + return r.name; + } +} + +/** 名字是配置里的标识(上游名、路由名……):等宽字体写。全局设置、安全规则写的是界面上的叫法 */ +export function namedById(r: ItemRef): boolean { + return r.kind !== "global" && r.kind !== "security_rule" && !WHOLE.includes(r.kind); +} + +/** 一版备份是谁写的:同步来的写上是哪台设备 */ +export function originLabel(origin: PackOrigin | ConfigOrigin, device: string | null): string { + const t = textOf(backupText).origins; + if (origin === "sync") return device ? t.syncFrom(device) : t.sync; + return t[origin]; +} + +/** 冲突时哪几处不同:「连接、模型不同」。认不出的单位(全局设置的字段路径)照原样写 */ +export function differsLabel(units: readonly string[]): string | null { + if (units.length === 0) return null; + const t = textOf(backupText); + const names = units.map((u) => (t.units as Record)[u] ?? u); + return t.differ(names.join(t.sep), names.length); +} + +/** 按类分组,组内保持原来的先后。只给有东西的组 */ +export function groupBy(items: readonly T[], ref: (t: T) => ItemRef): { kind: ItemKind; items: T[] }[] { + const by = new Map(); + for (const it of items) { + const k = groupOf(ref(it).kind); + const list = by.get(k); + if (list) list.push(it); + else by.set(k, [it]); + } + return KIND_ORDER.filter((k) => by.has(k)).map((kind) => ({ kind, items: by.get(kind)! })); +} + +/** + * 选了这几项,实际导出的是哪些:所选的,加上它们(一层层)引用到的 —— 路由引用的上游、 + * 上游引用的出站代理。`along` 是因为被引用才进来的那几项(列表上标「随附」、勾着不能取消)。 + * core 导出时同样会补齐,这里算出来是为了让人在导出之前看见 + */ +export function closure( + picked: ReadonlySet, + nodes: readonly ItemNode[], +): { included: Set; along: Set } { + const refs = new Map(nodes.map((n) => [refKey(n.item), n.refs.map(refKey)])); + const included = new Set(); + const queue = [...picked]; + while (queue.length > 0) { + const k = queue.pop()!; + if (included.has(k)) continue; + included.add(k); + for (const r of refs.get(k) ?? []) if (!included.has(r)) queue.push(r); + } + const along = new Set([...included].filter((k) => !picked.has(k))); + return { included, along }; +} + +/** 有几项是这一类的 */ +export function countOf(nodes: readonly ItemNode[], kind: ItemKind): number { + return nodes.filter((n) => n.item.kind === kind).length; +} diff --git a/src/backup/parts.tsx b/src/backup/parts.tsx new file mode 100644 index 00000000..a32a6044 --- /dev/null +++ b/src/backup/parts.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +/** + * 设置「同步与备份」一节里的一部分(同步、备份)的小标题:左边名字,右边这一部分的操作。 + * 和一节的标题(`SettingsGroup`)差一级 + */ +export function PartHeading({ title, actions }: { title: ReactNode; actions?: ReactNode }) { + return ( +
+

{title}

+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/backup/restorePlan.test.ts b/src/backup/restorePlan.test.ts new file mode 100644 index 00000000..0091d354 --- /dev/null +++ b/src/backup/restorePlan.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { ItemRef, RestoreRow } from "@/types"; +import { planRows, selection, tallyWritten } from "./restorePlan"; + +const ref = (kind: ItemRef["kind"], name: string): ItemRef => ({ kind, name }); +const row = (item: ItemRef, change: RestoreRow["change"], kept: RestoreRow["kept"] = null): RestoreRow => ({ + item, + change, + kept, +}); + +const BASE: RestoreRow[] = [ + row(ref("provider", "relay"), "add"), + row(ref("route", "long"), "modify"), + row(ref("alias", "kimi"), "remove"), + row(ref("key", "cursor"), "remove", "key_in_use"), +]; + +/** 规格 7.2 与 core 的计划:勾的是人选的,会动什么以 core 交回的为准 */ +describe("恢复预览的勾选与计划", () => { + it("全勾着交整个版本,取消勾选的不交,保留的本来就不能勾", () => { + expect(selection(BASE, new Set())).toBeNull(); + expect(selection(BASE, new Set(["alias:kimi"]))).toEqual([ref("provider", "relay"), ref("route", "long")]); + }); + + it("没勾的写保持当前内容;被所选项目带着的写一并恢复;core 保留的写原因", () => { + const unchecked = new Set(["provider:relay", "alias:kimi"]); + // 只恢复路由:它用的上游本机没有,一并恢复;别名还被留下的路由引用,保留 + const plan = [row(ref("route", "long"), "modify"), row(ref("provider", "relay"), "add"), row(ref("alias", "kimi"), "remove", "referenced")]; + const shown = planRows(BASE, plan, unchecked); + expect(shown.map((r) => [r.row.item.name, r.note, r.checked])).toEqual([ + ["relay", "along", false], + ["long", "chosen", true], + ["kimi", "kept", false], + ["cursor", "kept", false], + ]); + expect(shown.find((r) => r.row.item.name === "kimi")?.row.kept).toBe("referenced"); + const none = planRows(BASE, [row(ref("route", "long"), "modify")], unchecked); + expect(none.find((r) => r.row.item.name === "relay")?.note).toBe("untouched"); + }); + + it("计划里多出来的(骨架里没有的)接在后面,不给勾", () => { + const shown = planRows(BASE, [...BASE, row(ref("proxy", "clash"), "add")], new Set()); + expect(shown.at(-1)).toMatchObject({ box: false, note: "along" }); + }); + + it("完成后的提示按实际动了的数", () => { + expect(tallyWritten([row(ref("route", "a"), "add"), row(ref("key", "b"), "remove", "key_in_use")])).toEqual({ + restored: 1, + kept: 1, + }); + }); +}); diff --git a/src/backup/restorePlan.ts b/src/backup/restorePlan.ts new file mode 100644 index 00000000..eca4a816 --- /dev/null +++ b/src/backup/restorePlan.ts @@ -0,0 +1,73 @@ +/** + * 恢复预览里勾选与 core 的计划(规格 7.2)。 + * + * **勾选的是人选的,会动什么由 core 说**:只恢复几项时,core 会把它们引用的、本机已经没有的 + * 一起恢复,把留下的项目还引用着的保留(`kept: referenced`)。所以每改一次勾选,就用所选的 + * 几项重新要一次预览(`RestorePreviewRequest.items`),表上的变化以它交回的为准。 + * + * 纯逻辑,导出给测试。 + */ +import type { ItemRef, RestoreRow } from "@/types"; +import { refKey } from "./items"; + +/** 能勾的:core 说要保留的(客户端在用的密钥、还被引用着的)不给勾 */ +export const selectable = (r: RestoreRow) => r.kept === null; + +/** 交给 core 的项目:全勾着是 null(整个版本),否则是勾着的那几项 */ +export function selection(base: readonly RestoreRow[], unchecked: ReadonlySet): ItemRef[] | null { + const rows = base.filter(selectable); + if (rows.every((r) => !unchecked.has(refKey(r.item)))) return null; + return rows.filter((r) => !unchecked.has(refKey(r.item))).map((r) => r.item); +} + +/** 表上的一行怎么写 */ +export type RowNote = + /** 勾着、照所选恢复 */ + | "chosen" + /** 没勾,但所选的项目引用它、本机又没有:一并恢复 */ + | "along" + /** 没勾:保持当前内容 */ + | "untouched" + /** core 保留(`kept` 写明原因) */ + | "kept"; + +export interface PlanRow { + row: RestoreRow; + /** 有勾选框吗 */ + box: boolean; + checked: boolean; + note: RowNote; +} + +/** + * 整个版本的差异(`base`,第一次预览)为表的骨架,叠上此刻所选的计划(`plan`):勾选框跟着人, + * 变化、保留跟着 core。计划里有、骨架里没有的行(一并恢复的)接在后面 + */ +export function planRows( + base: readonly RestoreRow[], + plan: readonly RestoreRow[], + unchecked: ReadonlySet, +): PlanRow[] { + const inPlan = new Map(plan.map((r) => [refKey(r.item), r])); + const out: PlanRow[] = base.map((b) => { + const k = refKey(b.item); + const p = inPlan.get(k); + if (!selectable(b)) return { row: p ?? b, box: false, checked: false, note: "kept" }; + const checked = !unchecked.has(k); + if (p?.kept) return { row: p, box: true, checked, note: "kept" }; + if (checked) return { row: p ?? b, box: true, checked, note: "chosen" }; + return p ? { row: p, box: true, checked, note: "along" } : { row: b, box: true, checked, note: "untouched" }; + }); + const known = new Set(base.map((r) => refKey(r.item))); + for (const p of plan) { + if (known.has(refKey(p.item))) continue; + out.push({ row: p, box: false, checked: false, note: p.kept ? "kept" : "along" }); + } + return out; +} + +/** 恢复之后实际动了几项、保留了几项(`RestoreWritten.rows`) */ +export function tallyWritten(rows: readonly RestoreRow[]): { restored: number; kept: number } { + const kept = rows.filter((r) => r.kept !== null).length; + return { restored: rows.length - kept, kept }; +} diff --git a/src/backup/useImport.tsx b/src/backup/useImport.tsx new file mode 100644 index 00000000..6cd356c8 --- /dev/null +++ b/src/backup/useImport.tsx @@ -0,0 +1,21 @@ +import { useState, type ReactNode } from "react"; +import { usePending } from "@/ui/notify"; +import type { PickedFile } from "@/types"; +import { backupApi } from "./api"; +import { ImportDialog } from "./ImportDialog"; + +/** + * 「导入配置…」:先弹系统的「打开」对话框选配置包,选了才打开导入对话框。设置里、上游页的 + * 空状态各用一份。`dialog` 放进页面里渲染 + */ +export function useImport(onImported?: () => void): { start: () => void; picking: boolean; dialog: ReactNode } { + const [file, setFile] = useState(null); + const [picking, pick] = usePending(); + const start = () => + void pick(async () => { + const f = await backupApi.open(); + if (f) setFile(f); + }); + const dialog = file && setFile(null)} onImported={onImported} />; + return { start, picking, dialog }; +} diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx index 0d9405d0..77bb0568 100644 --- a/src/connection/Unlinked.tsx +++ b/src/connection/Unlinked.tsx @@ -244,7 +244,7 @@ export function ConfigBroken({ {t.openConfigFile} - @@ -532,7 +537,7 @@ export default memo(function Workspace({ onChanged={changed} onRetry={changed} onOpenConfigFile={openConfigFile} - onOpenHistory={openHistory} + onOpenHistory={openRestore} /> @@ -552,16 +557,18 @@ export default memo(function Workspace({ }} /> )} - {historyOpen && HistoryView && ( - setHistoryOpen(false)} + {backups && BackupsView && ( + setBackups(null)} onOpenPlugins={() => { - setHistoryOpen(false); + setBackups(null); open("plugins"); }} /> )} + {/* 配置包拖进窗口:放下时打开导入对话框(读得了的配置才收) */} + {unsupported && CannotImportView && ( )} diff --git a/src/shell/describe.ts b/src/shell/describe.ts index 45097b8b..bbf0543a 100644 --- a/src/shell/describe.ts +++ b/src/shell/describe.ts @@ -38,7 +38,7 @@ export function describeCore(raw: string): CoreLine { } } -/** 编辑 config.yaml 的几页。工具栏上的「配置文件」「版本历史」只在这几页出现 */ +/** 编辑 config.yaml 的几页。工具栏上的「配置文件」「备份」只在这几页出现 */ export const CONFIG_PAGES = new Set(["upstreams", "keys", "routing", "security", "plugins"]); /** 配置文件里的一段由哪一页管理 */ diff --git a/src/sync/ConflictDialog.i18n.ts b/src/sync/ConflictDialog.i18n.ts new file mode 100644 index 00000000..015fe4d5 --- /dev/null +++ b/src/sync/ConflictDialog.i18n.ts @@ -0,0 +1,56 @@ +import { messages } from "@/i18n"; + +export const conflictText = messages( + { + title: "同步冲突", + desc: "以下对象在本机与其他设备上被修改为不同内容。未处理前,本机保留当前内容。点击对象名称查看差异。", + showDiff: "查看差异", + hideDiff: "收起差异", + deleted: "已删除", + object: "对象", + here: "本机", + other: "其他设备", + take: "采用", + changedAt: (when: string) => `${when} 修改`, + deletedAt: (when: string) => `${when} 删除`, + both: "两者都保留", + keyInUse: "本机客户端正在使用该网关密钥,未删除。", + invalid: "合并后的配置未通过校验。", + bothName: (name: string) => `另一方内容的名称(原名「${name}」)`, + bothNameHint: "本机内容保留原名称,另一方的内容以此名称新增。", + bothEmpty: "填写名称。", + bothSame: "名称须与原名称不同。", + later: "稍后处理", + apply: "应用", + left: (n: number) => `尚有 ${n} 项未选择。`, + none: "没有待处理的冲突。", + loadFailed: "未能读取冲突", + failed: "未能处理冲突", + }, + { + title: "Sync conflicts", + desc: "These items were changed differently on this device and on another one. Until resolved, this device keeps its own version. Click an item to see the differences.", + showDiff: "Show differences", + hideDiff: "Hide differences", + deleted: "Deleted", + object: "Item", + here: "This device", + other: "Other device", + take: "Use", + changedAt: (when: string) => `Changed ${when}`, + deletedAt: (when: string) => `Deleted ${when}`, + both: "Keep both", + keyInUse: "A client on this device uses this gateway key; it was not deleted.", + invalid: "The merged configuration did not pass validation.", + bothName: (name: string) => `Name for the other version (was “${name}”)`, + bothNameHint: "This device's version keeps the name; the other one is added under this name.", + bothEmpty: "Enter a name.", + bothSame: "The name must differ from the original.", + later: "Later", + apply: "Apply", + left: (n: number) => (n === 1 ? "1 item has no choice yet." : `${n} items have no choice yet.`), + none: "No conflicts are waiting.", + loadFailed: "The conflicts could not be read", + failed: "The conflicts could not be resolved", + }, +); diff --git a/src/sync/ConflictDialog.tsx b/src/sync/ConflictDialog.tsx new file mode 100644 index 00000000..4143335b --- /dev/null +++ b/src/sync/ConflictDialog.tsx @@ -0,0 +1,269 @@ +import { Fragment, useEffect, useState } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { focusSelf } from "@/ui/dialog-focus"; +import { Input } from "@/ui/input"; +import { Segmented } from "@/ui/segmented"; +import { ErrorState, ListSkeleton } from "@/ui/states"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useResource } from "@/lib/resource"; +import { whenMinute } from "@/format"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import type { SyncConflict, SyncConflictDiff, SyncConflictSide, SyncTake, SyncView } from "@/types"; +import { SYNC_KEYS, syncApi } from "./api"; +import { conflictText } from "./ConflictDialog.i18n"; +import { + initialConflictPicks, + keepBothProblem, + remoteDevice, + resolveDecisions, + type ConflictPicks, +} from "./decide"; +import { itemWithUnits, unitLabel } from "./labels"; + +/** + * 同步冲突(方案 6.7):同一个合并单位在本机和另一台设备上被改成了不同的内容。每一项选 + * 「使用本机」「使用 <那台设备>」或「两者都保留」(另一方的内容以新名字加进来;全局设置、 + * 路由顺序、通用偏好没有这一项)。 + * + * 点对象名称展开差异:每个合并单位两边各是什么(core 给的 `diff`,删掉的一边写「已删除」)。 + * + * **只交选了的**:没选的留着,下次再处理(「稍后处理」)。处理的结果作为新版本上传,别的 + * 设备随之清掉同一条冲突,不用再处理一遍 —— 那是 core 的事,这里只交选择。 + */ +export function ConflictDialog({ onClose, onResolved }: { onClose: () => void; onResolved: (v: SyncView) => void }) { + const t = useText(conflictText); + const list = useResource(SYNC_KEYS.conflicts, syncApi.conflicts, { events: ["sync_status", "config_reloaded"] }); + const [picks, setPicks] = useState({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const conflicts = list.data; + // 新冲突到了(或者别的设备处理了几项):新来的给缺省值,选过的保留 + useEffect(() => { + if (!conflicts) return; + setPicks((prev) => ({ ...initialConflictPicks(conflicts), ...prev })); + }, [conflicts]); + + const decisions = conflicts ? resolveDecisions(conflicts, picks) : []; + const left = conflicts ? conflicts.length - decisions.length : 0; + const device = conflicts ? remoteDevice(conflicts) : null; + + async function apply() { + if (!conflicts) return; + setBusy(true); + setError(null); + try { + const v = await syncApi.resolve(decisions); + onResolved(v); + if (decisions.length === conflicts.length) onClose(); + else void list.reload(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + return ( + !open && !busy && onClose()}> + + + {t.title} + {t.desc} + + +
+ {conflicts === undefined ? ( + list.error !== undefined && !list.loading ? ( + void list.reload()} /> + ) : ( + + ) + ) : conflicts.length === 0 ? ( +

{t.none}

+ ) : ( +
+ + + + {t.object} + {t.here} + {device ?? t.other} + {t.take} + + + + {conflicts.map((c) => ( + setPicks((all) => ({ ...all, [c.id]: { ...all[c.id]!, ...p } }))} + /> + ))} + +
+
+ )} + + {error} + +
+ + + {conflicts && conflicts.length > 0 && left > 0 && ( + {t.left(left)} + )} + + + +
+
+ ); +} + +function Row({ + c, + showDevice, + pick, + disabled, + onPick, +}: { + c: SyncConflict; + /** 冲突来自不同的设备:表头写不出一个名字,每一格写上是哪一台 */ + showDevice: boolean; + pick: { take: SyncTake | null; name: string } | undefined; + disabled: boolean; + onPick: (p: Partial<{ take: SyncTake; name: string }>) => void; +}) { + const t = useText(conflictText); + const [open, setOpen] = useState(false); + const problem = keepBothProblem(c, pick); + // core 说这一处能选哪几种(合并结果过不了校验时没有「使用另一台」,不能两者都保留时没有那一项) + const label: Record = { local: t.here, remote: c.remote.device, both: t.both }; + const options = c.options.map((id) => ({ id, label: label[id] })); + return ( + + + + {c.diff.length > 0 ? ( + + ) : ( +

{itemWithUnits(c.item, c.units)}

+ )} + {c.reason && ( +

{c.reason === "key_in_use" ? t.keyInUse : t.invalid}

+ )} +
+ + + + + {showDevice &&

{c.remote.device}

} + +
+ +
+ + label={t.take} + value={pick?.take ?? ("" as SyncTake)} + disabled={disabled} + options={options} + onChange={(take) => onPick({ take })} + /> + {pick?.take === "both" && ( + <> + onPick({ name: e.target.value })} + /> + {problem && ( + {problem === "empty" ? t.bothEmpty : t.bothSame} + )} + + )} +
+
+
+ {open && ( + + + + + + )} +
+ ); +} + +/** 一个冲突的差异:每个合并单位一行,本机和另一台设备的内容并排 */ +function Diff({ diff, remote }: { diff: SyncConflictDiff[]; remote: string }) { + const t = useText(conflictText); + return ( +
+ {diff.map((d) => ( +
+ {d.unit !== "all" &&

{unitLabel(d.unit)}

} +
+ + +
+
+ ))} +
+ ); +} + +/** 一边的内容:JSON 原样排好;这一边删掉了写「已删除」 */ +function Value({ label, value }: { label: string; value: unknown }) { + const t = useText(conflictText); + return ( +
+

{label}

+ {value === null || value === undefined ? ( +

{t.deleted}

+ ) : ( +
+          {typeof value === "string" ? value : JSON.stringify(value, null, 2)}
+        
+ )} +
+ ); +} + +function Side({ side }: { side: SyncConflictSide }) { + const t = useText(conflictText); + const when = whenMinute(side.at_ms); + return

{side.deleted ? t.deletedAt(when) : t.changedAt(when)}

; +} diff --git a/src/sync/Dialogs.tsx b/src/sync/Dialogs.tsx new file mode 100644 index 00000000..aeb568af --- /dev/null +++ b/src/sync/Dialogs.tsx @@ -0,0 +1,340 @@ +import { useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogConfirm, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { SecretInput } from "@/ui/secret-input"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import type { SyncEncryptionAction, SyncView } from "@/types"; +import { syncApi } from "./api"; +import { syncDialogsText } from "./dialogs.i18n"; +import { NewPassphrase, type RowOf } from "./Fields"; +import { syncFieldsText } from "./fields.i18n"; +import { passphraseProblem, PASSPHRASE_MIN } from "./target"; + +const row: RowOf = ({ id, label, htmlFor, hint, control }) => ( + + {control} + +); + +/** + * 加密:开启(设一个新口令)、关闭(要当前口令)、更改口令(当前的和新的)。方案 6.3。 + * 三件事都由 core 重新上传或重新加密,这里只收口令。 + */ +export function EncryptionDialog({ + action, + onClose, + onDone, +}: { + action: SyncEncryptionAction; + onClose: () => void; + onDone: (v: SyncView) => void; +}) { + const t = useText(syncDialogsText); + const ft = useText(syncFieldsText); + const common = useText(commonText); + const [current, setCurrent] = useState(""); + const [a, setA] = useState(""); + const [b, setB] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const needsCurrent = action !== "enable"; + const needsNew = action !== "disable"; + const p = needsNew ? passphraseProblem(a, b) : null; + const missing = + needsCurrent && current === "" + ? t.enterCurrent + : p === "short" + ? ft.passphraseShort(PASSPHRASE_MIN) + : p === "mismatch" + ? ft.passphraseMismatch + : null; + const title = action === "enable" ? t.enableTitle : action === "disable" ? t.disableTitle : t.changeTitle; + const desc = action === "enable" ? t.enableDesc : action === "disable" ? t.disableDesc : t.changeDesc; + const verb = action === "enable" ? t.enable : action === "disable" ? t.disable : t.change; + + async function run() { + setBusy(true); + setError(null); + try { + // 开启时 `passphrase` 是新口令;关闭时是当前口令;更改时当前的和新的各一个 + const v = await syncApi.encryption( + action, + action === "enable" ? a : current, + action === "change" ? a : null, + ); + onDone(v); + onClose(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + return ( + !open && !busy && onClose()}> + + + {title} + {desc} + +
{ + e.preventDefault(); + if (!missing && !busy) void run(); + }} + > + + {needsCurrent && ( + +
+ setCurrent(e.target.value)} /> +
+
+ )} + {needsNew && ( + + )} +
+
+ + {error} + + + {missing && {missing}} + + + +
+
+ ); +} + +/** 口令在别的设备上改了(`locked`):输入新口令接着同步 */ +export function UnlockDialog({ onClose, onDone }: { onClose: () => void; onDone: (v: SyncView) => void }) { + const t = useText(syncDialogsText); + const ft = useText(syncFieldsText); + const common = useText(commonText); + const [pass, setPass] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run() { + setBusy(true); + setError(null); + try { + onDone(await syncApi.unlock(pass)); + onClose(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + return ( + !open && !busy && onClose()}> + + + {t.unlockTitle} + {t.unlockDesc} + +
{ + e.preventDefault(); + if (pass && !busy) void run(); + }} + > + + +
+ setPass(e.target.value)} /> +
+
+
+
+ + {error} + + + + + +
+
+ ); +} + +/** + * 停止同步(方案 6.11)。「停止同步」只动这台设备;「停止同步并删除同步数据」另需确认 —— + * 它删掉的是所有设备共用的那一份,别的设备随后也停下。 + */ +export function StopDialog({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { + const t = useText(syncDialogsText); + const common = useText(commonText); + const [purging, setPurging] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(purge: boolean) { + setBusy(true); + setError(null); + try { + await syncApi.disable(purge); + onDone(); + onClose(); + } catch (e) { + setError(errorText(e)); + setBusy(false); + } + } + + return ( + !open && !busy && onClose()}> + + + {purging ? t.purgeTitle : t.stopTitle} + {purging ? t.purgeDesc : t.stopDesc} + + + {error} + + + {!purging && ( + + )} + {common.cancel} + {purging ? ( + void run(true)}> + {t.purge} + + ) : ( + void run(false)}> + {t.stop} + + )} + + + + ); +} + +/** + * 不同步的上游:勾选。**只改表单里的草稿**,和这一节别的项一起点「保存」才写进配置。 + */ +export function ExcludeDialog({ + upstreams, + value, + onClose, + onDone, +}: { + upstreams: readonly string[]; + value: readonly string[]; + onClose: () => void; + onDone: (next: string[]) => void; +}) { + const t = useText(syncDialogsText); + const common = useText(commonText); + const [picked, setPicked] = useState>(() => new Set(value)); + // 配置里写着一个已经没有的上游:照样列出来,可以取消 + const all = [...upstreams, ...value.filter((v) => !upstreams.includes(v))]; + return ( + !open && onClose()}> + + + {t.excludeTitle} + {t.excludeDesc} + + {all.length === 0 ? ( +

{t.noUpstreams}

+ ) : ( +
+ {all.map((name) => ( + + ))} +
+ )} + + + + +
+
+ ); +} diff --git a/src/sync/Fields.tsx b/src/sync/Fields.tsx new file mode 100644 index 00000000..36bed0e9 --- /dev/null +++ b/src/sync/Fields.tsx @@ -0,0 +1,249 @@ +import type { ReactNode } from "react"; +import { Button } from "@/ui/button"; +import { Input } from "@/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; +import { SecretInput } from "@/ui/secret-input"; +import { Segmented } from "@/ui/segmented"; +import { cn } from "@/lib/utils"; +import { notify } from "@/ui/notify"; +import { useText } from "@/i18n"; +import { syncFieldsText } from "./fields.i18n"; +import { syncApi } from "./api"; +import { TARGET_KINDS, type TargetDraft, type TargetKind } from "./target"; + +/** 一行怎么画:设置里是名目在左、控件在右的一行,向导里是两列表单的一行 */ +export type RowOf = (p: { id: string; label: ReactNode; htmlFor?: string; hint?: ReactNode; control: ReactNode }) => ReactNode; + +/** + * 同步位置的几格:类型,和这种类型要填的那几项;WebDAV 和 S3 另有「代理」。 + * + * `dense`:设置页里一行一项、控件 28px;向导的表单里 32px。`remote`:连着远程 core,文件夹 + * 是服务器上的路径,不给「选择…」(这台电脑上选出来的路径对服务器没有意义)。 + */ +export function TargetFields({ + draft, + onChange, + proxies, + remote, + disabled, + dense = false, + row, +}: { + draft: TargetDraft; + onChange: (patch: Partial) => void; + /** 能选的代理(名字) */ + proxies: readonly string[]; + remote: boolean; + disabled?: boolean; + dense?: boolean; + row: RowOf; +}) { + const t = useText(syncFieldsText); + const width = dense ? "w-72" : "w-80"; + const text = (k: keyof TargetDraft, opts: { mono?: boolean; placeholder?: string; w?: string } = {}) => ( + onChange({ [k]: e.target.value })} + /> + ); + const secret = (k: "pass" | "secret_access_key") => ( + // 设置里一行一项的控件是 28px:输入框那一圈(`input-group`)跟着收 +
+ onChange({ [k]: e.target.value })} + /> +
+ ); + + async function pick() { + try { + const path = await syncApi.pickFolder(); + if (path) onChange({ path }); + } catch (e) { + notify.error(e); + } + } + + return ( + <> + {row({ + id: "type", + label: t.location, + control: ( + + label={t.location} + value={draft.type} + disabled={disabled} + options={TARGET_KINDS.map((k) => ({ id: k, label: t.kinds[k] }))} + onChange={(type) => onChange({ type })} + /> + ), + })} + {draft.type === "folder" && + row({ + id: "path", + label: t.folder, + htmlFor: "sync-path", + hint: remote ? t.folderRemoteHint : t.folderHint, + control: ( + + {text("path", { mono: true, w: remote ? width : dense ? "w-56" : "w-64" })} + {!remote && ( + + )} + + ), + })} + {draft.type === "webdav" && ( + <> + {row({ + id: "url", + label: t.url, + htmlFor: "sync-url", + control: text("url", { mono: true, placeholder: "https://dav.jianguoyun.com/dav/ThinkWatch" }), + })} + {row({ id: "user", label: t.user, htmlFor: "sync-user", control: text("user") })} + {row({ id: "pass", label: t.pass, htmlFor: "sync-pass", hint: t.passHint, control: secret("pass") })} + + )} + {draft.type === "s3" && ( + <> + {row({ + id: "endpoint", + label: t.endpoint, + htmlFor: "sync-endpoint", + control: text("endpoint", { mono: true, placeholder: "https://s3.amazonaws.com" }), + })} + {row({ + id: "region", + label: t.region, + htmlFor: "sync-region", + hint: t.regionHint, + control: text("region", { mono: true, placeholder: "us-east-1", w: "w-40" }), + })} + {row({ id: "bucket", label: t.bucket, htmlFor: "sync-bucket", control: text("bucket", { mono: true }) })} + {row({ + id: "prefix", + label: t.prefix, + htmlFor: "sync-prefix", + hint: t.prefixHint, + control: text("prefix", { mono: true, placeholder: "ThinkWatch/" }), + })} + {row({ + id: "access_key_id", + label: t.accessKeyId, + htmlFor: "sync-access_key_id", + control: text("access_key_id", { mono: true }), + })} + {row({ + id: "secret_access_key", + label: t.secretAccessKey, + htmlFor: "sync-secret_access_key", + control: secret("secret_access_key"), + })} + + )} + {draft.type !== "folder" && + row({ + id: "proxy", + label: t.proxy, + htmlFor: "sync-proxy", + control: ( + onChange({ proxy: e.target.value })} + > + {t.direct} + {t.system} + {/* 配置里写着一个已经删掉的代理:照样列出来,不显示成直连 */} + {draft.proxy !== "direct" && draft.proxy !== "system" && !proxies.includes(draft.proxy) && ( + {draft.proxy} + )} + {proxies.map((p) => ( + + {p} + + ))} + + ), + })} + + ); +} + +/** 两次输入的新口令。`id` 区分同一页上的几组 */ +export function NewPassphrase({ + id, + a, + b, + onA, + onB, + hint, + labels, + disabled, + row, +}: { + id: string; + a: string; + b: string; + onA: (v: string) => void; + onB: (v: string) => void; + /** 第二格下面那一行 */ + hint?: ReactNode; + /** 两格的名目。缺省「同步口令」「再次输入」;改口令时是「新口令」 */ + labels?: { a: ReactNode; b: ReactNode }; + disabled?: boolean; + row: RowOf; +}) { + const t = useText(syncFieldsText); + return ( + <> + {row({ + id: `${id}-a`, + label: labels?.a ?? t.passphrase, + htmlFor: `${id}-a`, + control: ( +
+ onA(e.target.value)} /> +
+ ), + })} + {row({ + id: `${id}-b`, + label: labels?.b ?? t.passphraseAgain, + htmlFor: `${id}-b`, + hint, + control: ( +
+ onB(e.target.value)} /> +
+ ), + })} + + ); +} diff --git a/src/sync/JoinWizard.i18n.ts b/src/sync/JoinWizard.i18n.ts new file mode 100644 index 00000000..522c4362 --- /dev/null +++ b/src/sync/JoinWizard.i18n.ts @@ -0,0 +1,84 @@ +import { messages } from "@/i18n"; + +export const joinWizardText = messages( + { + title: "开启同步", + steps: { location: "同步位置", encryption: "加密", merge: "合并" }, + stepOf: (n: number, name: string) => `${n} ${name}`, + skipped: "不加密", + locationDesc: "多台设备共用一份配置,并在同步位置保存最新与上一个版本。", + test: "测试连接", + testing: "正在连接同步位置", + emptyOk: "可以读写。同步位置为空,将以本机配置开始同步。", + existingOk: (n: number, encrypted: boolean) => + `可以读写。同步位置已有同步数据(${n > 0 ? `${n} 台设备,` : ""}${encrypted ? "已加密" : "未加密"})。`, + notWritable: "同步位置不可写入", + testFailed: "无法连接同步位置", + // 第 2 步:新建 + encrypt: "以同步口令加密", + noEncrypt: "不加密", + encryptDesc: "选择是否加密同步内容。同一同步位置上的所有设备采用同一设置。", + encryptHint: "其他设备加入时输入同一口令。口令遗失且没有设备仍在同步时,同步数据无法解密。", + plainTitle: "同步内容不加密", + plainBody: + "同步位置的服务商可读取配置与密钥,对同步位置有写入权限的任何人也可以改动配置。经同步新增或脚本有变化的插件在本设备为停用状态,须在本设备启用。", + // 第 2 步:加入 + joinDesc: "同步位置已有同步数据,以同步口令加密。输入其他设备上设置的同步口令。", + wrongPassphrase: "同步口令不正确。", + devices: (list: string) => `已在同步的设备:${list}`, + enterPassphrase: "输入同步口令。", + deviceLine: (name: string, when: string) => `${name}(${when})`, + deviceSep: "、", + // 第 3 步 + mergeDesc: "本机已有配置。以导入的规则合并同步数据,冲突项逐项选择处理方式。", + mode: { merge: "合并", use_remote: "使用同步数据替换本机配置" }, + replaceNote: "本机当前配置保留在备份中。本机客户端正在使用的网关密钥并入同步数据。", + noDifferences: "同步数据与本机配置一致。", + // 按钮 + back: "上一步", + next: "下一步", + enable: "开启同步", + join: "加入同步", + unresolved: (n: number) => `尚有 ${n} 项冲突未选择处理方式。`, + failed: "未能开启同步", + }, + { + title: "Turn on sync", + steps: { location: "Location", encryption: "Encryption", merge: "Merge" }, + stepOf: (n: number, name: string) => `${n} ${name}`, + skipped: "Not encrypted", + locationDesc: "Devices share one configuration; the sync location keeps the latest and the previous version.", + test: "Test connection", + testing: "Connecting to the sync location", + emptyOk: "Readable and writable. The sync location is empty; syncing starts from this device's configuration.", + existingOk: (n: number, encrypted: boolean) => + `Readable and writable. The sync location already holds sync data (${n > 0 ? `${n === 1 ? "1 device" : `${n} devices`}, ` : ""}${encrypted ? "encrypted" : "not encrypted"}).`, + notWritable: "The sync location is not writable", + testFailed: "The sync location cannot be reached", + encrypt: "Encrypt with a passphrase", + noEncrypt: "Do not encrypt", + encryptDesc: "Choose whether synced content is encrypted. All devices on a sync location use the same setting.", + encryptHint: + "Other devices enter the same passphrase when they join. If the passphrase is lost and no device is still syncing, the sync data cannot be decrypted.", + plainTitle: "Synced content is not encrypted", + plainBody: + "The storage provider can read the configuration and its keys, and anyone who can write to the sync location can change the configuration. Plugins that arrive or change their script through sync stay off on this device until turned on here.", + joinDesc: "The sync location already holds sync data encrypted with a sync passphrase. Enter the passphrase set on the other devices.", + wrongPassphrase: "The sync passphrase is not correct.", + devices: (list: string) => `Devices already syncing: ${list}`, + enterPassphrase: "Enter the sync passphrase.", + deviceLine: (name: string, when: string) => `${name} (${when})`, + deviceSep: ", ", + mergeDesc: "This device already has a configuration. The sync data is merged with the import rules; choose what to do with each conflict.", + mode: { merge: "Merge", use_remote: "Replace this device's configuration with the sync data" }, + replaceNote: + "The current configuration stays in the backups. Gateway keys used by clients on this device are added to the sync data.", + noDifferences: "The sync data matches this device's configuration.", + back: "Back", + next: "Next", + enable: "Turn on sync", + join: "Join sync", + unresolved: (n: number) => (n === 1 ? "1 conflict has no choice yet." : `${n} conflicts have no choice yet.`), + failed: "Sync could not be turned on", + }, +); diff --git a/src/sync/JoinWizard.tsx b/src/sync/JoinWizard.tsx new file mode 100644 index 00000000..859e3f27 --- /dev/null +++ b/src/sync/JoinWizard.tsx @@ -0,0 +1,456 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { focusSelf } from "@/ui/dialog-focus"; +import { Input } from "@/ui/input"; +import { SecretInput } from "@/ui/secret-input"; +import { Segmented } from "@/ui/segmented"; +import { Spinner } from "@/ui/spinner"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { coreText, errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import { useRemote } from "@/connection/useRemote"; +import type { SyncDevice, SyncJoin, SyncJoinPreview, SyncProbe } from "@/types"; +import { syncApi } from "./api"; +import { initialPicks, mergeDecisions, unresolved, type MergePicks } from "./decide"; +import { NewPassphrase, TargetFields, type RowOf } from "./Fields"; +import { syncFieldsText } from "./fields.i18n"; +import { joinWizardText } from "./JoinWizard.i18n"; +import { ago } from "./labels"; +import { MergeRows } from "./MergeRows"; +import { EMPTY_TARGET, passphraseProblem, PASSPHRASE_MIN, sameTarget, targetOf, targetProblem, type TargetDraft } from "./target"; + +type Step = "location" | "encryption" | "merge"; + +/** 测试连接的结果,连同测的是哪一份(改了之后就不作数) */ +interface Tested { + draft: TargetDraft; + probe: SyncProbe; +} + +/** + * 开启同步(方案 6.8),三步: + * + * 1. **同步位置**:类型与连接参数、本设备名称;「测试连接」验证读写。同步位置为空 → 第 2 步 + * 新建;已有加密的同步数据 → 第 2 步输入口令;已有未加密的 → 跳过第 2 步。 + * 2. **加密**:新建时选加密(缺省)或不加密,加密要两次口令、至少 12 个字符;加入时输入一次。 + * 3. **合并**:只在加入已有同步、本机又不是初始配置时出现。和导入同一套预览,另可选「使用 + * 同步数据替换本机配置」。本机是初始配置的,直接用同步数据,不出现这一步。 + * + * 开启、加入之前先把接管着的客户端在用的密钥报给 core(「替换」时 core 据此把它们并入)。 + */ +export function JoinWizard({ + proxies, + onClose, + onDone, +}: { + proxies: readonly string[]; + onClose: () => void; + /** 开好了:外面重读同步状态 */ + onDone: () => void; +}) { + const t = useText(joinWizardText); + const ft = useText(syncFieldsText); + const common = useText(commonText); + const remote = useRemote(); + + const [step, setStep] = useState("location"); + const [draft, setDraft] = useState({ ...EMPTY_TARGET }); + const [device, setDevice] = useState(remote?.name ?? ""); + const [tested, setTested] = useState(null); + const [testError, setTestError] = useState(null); + const [encrypt, setEncrypt] = useState(true); + const [pass, setPass] = useState(""); + const [pass2, setPass2] = useState(""); + const [preview, setPreview] = useState(null); + const [devices, setDevices] = useState([]); + const [join, setJoin] = useState("merge"); + const [picks, setPicks] = useState({}); + const [busy, setBusy] = useState<"test" | "next" | "enable" | null>(null); + const [error, setError] = useState(null); + + // 本设备名称的缺省值:这台电脑的名字(远程 core 用连接的名字)。已经开始填的不盖掉 + useEffect(() => { + if (remote) return; + let alive = true; + syncApi + .computerName() + .then((n) => alive && setDevice((typed) => typed || n)) + .catch(() => {}); + return () => { + alive = false; + }; + }, [remote]); + + const fresh = tested && sameTarget(tested.draft, draft) ? tested : null; + const existing = fresh?.probe.existing ?? null; + const problem = targetProblem(draft); + const deviceOk = device.trim() !== ""; + /** 这一步还差什么:页脚左边那一句 */ + const missing = + step === "location" + ? problem + ? ft.problem[problem] + : !deviceOk + ? ft.deviceNameMissing + : null + : step === "encryption" + ? existing + ? pass === "" + ? t.enterPassphrase + : null + : encrypt + ? passphraseHint(passphraseProblem(pass, pass2)) + : null + : join === "merge" && preview && unresolved(preview.rows, picks, preview.auto) > 0 + ? t.unresolved(unresolved(preview.rows, picks, preview.auto)) + : null; + + function passphraseHint(p: "short" | "mismatch" | null): string | null { + if (p === "short") return ft.passphraseShort(PASSPHRASE_MIN); + if (p === "mismatch") return ft.passphraseMismatch; + return null; + } + + async function test(): Promise { + setTestError(null); + setTested(null); + try { + const probe = await syncApi.test(targetOf(draft), null); + const r = { draft, probe }; + setTested(r); + return probe.writable ? r : null; + } catch (e) { + setTestError(errorText(e)); + return null; + } + } + + async function run(kind: "test" | "next" | "enable", f: () => Promise) { + setBusy(kind); + setError(null); + try { + return await f(); + } catch (e) { + setError(errorText(e)); + return undefined; + } finally { + setBusy(null); + } + } + + /** 开启或加入。`how`:新建时无所谓,加入时是合并还是替换 */ + async function enable(how: SyncJoin, decisions = mergeDecisions(preview?.rows ?? [], picks, preview?.auto ?? [])) { + // 在用的密钥先报上去;报不上不挡着(core 用上一次报的) + await syncApi.pinKeys().catch(() => {}); + await syncApi.enable({ + target: targetOf(draft), + encrypt: existing ? existing.encrypted : encrypt, + passphrase: (existing ? existing.encrypted : encrypt) ? pass : null, + device_name: device.trim(), + join: how, + decisions: how === "merge" ? decisions : [], + }); + onDone(); + onClose(); + } + + /** 加入已有的:先看合并预览。本机是初始配置的直接用同步数据 */ + async function previewJoin(passphrase: string | null) { + if (passphrase !== null) { + // 先验口令,顺带拿到已在同步的设备 + const probe = await syncApi.test(targetOf(draft), passphrase); + if (!probe.existing?.unlocked) { + setError(probe.error ? coreText(probe.error) : t.wrongPassphrase); + return; + } + setDevices(probe.existing.devices); + } else { + setDevices(existing?.devices ?? []); + } + const p = await syncApi.joinPreview(targetOf(draft), passphrase); + if (p.initial) { + await enable("use_remote", []); + return; + } + setPreview(p); + setPicks(initialPicks(p.rows, p.auto)); + setStep("merge"); + } + + async function next() { + if (step === "location") { + const r = fresh ?? (await test()); + if (!r?.probe.writable) return; + const ex = r.probe.existing; + if (ex && !ex.encrypted) return previewJoin(null); + setStep("encryption"); + return; + } + if (step === "encryption") { + if (existing) return previewJoin(pass); + return enable("merge", []); + } + return enable(join); + } + + const row: RowOf = ({ id, label, htmlFor, hint, control }) => ( + + {control} + + ); + + const finish = step === "merge" || (step === "encryption" && !existing); + const steps: { id: Step; label: string; skipped?: boolean }[] = [ + { id: "location", label: t.steps.location }, + { id: "encryption", label: existing && !existing.encrypted ? t.skipped : t.steps.encryption, skipped: !!existing && !existing.encrypted }, + // 测过了才知道:同步位置是空的就没有合并这一步 + { id: "merge", label: t.steps.merge, skipped: !!fresh && !existing }, + ]; + + return ( + !open && busy !== "enable" && onClose()}> + + + {t.title} + + + +
+ {step === "location" && ( + <> + {t.locationDesc} + + { + setDraft((d) => ({ ...d, ...patch })); + setError(null); + }} + row={row} + /> + + setDevice(e.target.value)} + /> + + + + + )} + + {step === "encryption" && !existing && ( + <> + {t.encryptDesc} + + block + label={t.steps.encryption} + value={encrypt ? "on" : "off"} + disabled={busy !== null} + options={[ + { id: "on", label: t.encrypt }, + { id: "off", label: t.noEncrypt }, + ]} + onChange={(v) => setEncrypt(v === "on")} + /> + {encrypt ? ( + + + + ) : ( + + {t.plainBody} + + )} + + )} + + {step === "encryption" && existing && ( + <> + {t.joinDesc} + + +
+ { + setPass(e.target.value); + setError(null); + }} + /> +
+
+
+ + )} + + {step === "merge" && preview && ( + <> + {t.mergeDesc} + {devices.length > 0 && ( +

+ {t.devices(devices.map((d) => t.deviceLine(d.name, ago(d.last_seen_ms))).join(t.deviceSep))} +

+ )} + + block + label={t.steps.merge} + value={join} + disabled={busy !== null} + options={[ + { id: "merge", label: t.mode.merge }, + { id: "use_remote", label: t.mode.use_remote }, + ]} + onChange={setJoin} + /> + {join === "merge" ? ( + preview.rows.some((r) => r.status !== "same") ? ( + + ) : ( +

{t.noDifferences}

+ ) + ) : ( + + {t.replaceNote} + + )} + + )} + + + {error} + +
+ + + {step === "location" ? ( + + ) : ( + + )} + {missing && {missing}} + + + +
+
+ ); +} + +/** 第几步:做过的、当前的、跳过的(未加密时的「加密」,同步位置为空时的「合并」) */ +function Steps({ steps, current }: { steps: { id: Step; label: string; skipped?: boolean }[]; current: Step }) { + const t = useText(joinWizardText); + const at = steps.findIndex((s) => s.id === current); + return ( +
    + {steps.map((s, i) => ( +
  1. + {i > 0 && } + {t.stepOf(i + 1, s.label)} +
  2. + ))} +
+ ); +} + +/** 测试连接的结果:在连就转圈;可以读写说同步位置是空的还是已有数据;不行说原因 */ +function TestResult({ testing, tested, error }: { testing: boolean; tested: Tested | null; error: string | null }) { + const t = useText(joinWizardText); + let body: ReactNode = null; + if (testing) { + body = ( +
+ + {t.testing}… +
+ ); + } else if (error) { + body = ( + + {error} + + ); + } else if (tested && !tested.probe.writable) { + body = ( + + {tested.probe.error ? coreText(tested.probe.error) : null} + + ); + } else if (tested) { + const ex = tested.probe.existing; + body = ( +
+ + {ex ? t.existingOk(ex.devices.length, ex.encrypted) : t.emptyOk} +
+ ); + } + return body; +} diff --git a/src/sync/MergeRows.i18n.ts b/src/sync/MergeRows.i18n.ts new file mode 100644 index 00000000..0e9a11ba --- /dev/null +++ b/src/sync/MergeRows.i18n.ts @@ -0,0 +1,51 @@ +import { messages } from "@/i18n"; + +export const mergeRowsText = messages( + { + counts: (n: number, c: number, s: number, e: number) => + [n && `新增 ${n}`, c && `冲突 ${c}`, s && `相同 ${s}`, e && `错误 ${e}`].filter(Boolean).join(" · "), + all: { overwrite: "全部覆盖", rename: "全部改名导入", skip: "全部跳过" }, + name: "名称", + status: "状态", + action: "处理", + statusOf: { new: "新增", same: "相同", conflict: "冲突", error: "错误" }, + differs: (units: string) => `${units}不同`, + flag: { missing_key: "缺少密钥", needs_login: "需要登录", plugin_disabled: "导入后为停用状态" }, + choice: { overwrite: "覆盖", rename: "改名导入", skip: "跳过" }, + importIt: "导入", + cannot: "不可导入", + /** 两边的默认网关密钥同名、值不同:采用同步数据的值,不用选 */ + takeRemote: "采用同步数据", + defaultKey: "默认网关密钥", + clientsFollow: "采用同步数据的值。本机已接管的客户端将自动更新。", + newName: (name: string) => `「${name}」的新名称`, + renameFlags: (flags: string) => `改名导入后:${flags}`, + renameEmpty: "填写新名称。", + renameSame: "新名称与原名称相同。", + renameTaken: "已有同名项。", + }, + { + counts: (n: number, c: number, s: number, e: number) => + [n && `${n} new`, c && `${c} ${c === 1 ? "conflict" : "conflicts"}`, s && `${s} same`, e && `${e} ${e === 1 ? "error" : "errors"}`] + .filter(Boolean) + .join(" · "), + all: { overwrite: "Overwrite all", rename: "Rename all", skip: "Skip all" }, + name: "Name", + status: "Status", + action: "Action", + statusOf: { new: "New", same: "Same", conflict: "Conflict", error: "Error" }, + differs: (units: string) => `${units} differ`, + flag: { missing_key: "Key missing", needs_login: "Sign-in needed", plugin_disabled: "Off after import" }, + choice: { overwrite: "Overwrite", rename: "Rename", skip: "Skip" }, + importIt: "Import", + cannot: "Cannot import", + takeRemote: "Use sync data", + defaultKey: "Default gateway key", + clientsFollow: "The value from the sync data is used. Clients connected on this device are updated automatically.", + newName: (name: string) => `New name for “${name}”`, + renameFlags: (flags: string) => `After renaming: ${flags}`, + renameEmpty: "Enter a new name.", + renameSame: "The new name is the same as the old one.", + renameTaken: "That name is already used.", + }, +); diff --git a/src/sync/MergeRows.tsx b/src/sync/MergeRows.tsx new file mode 100644 index 00000000..6c0615bf --- /dev/null +++ b/src/sync/MergeRows.tsx @@ -0,0 +1,232 @@ +import { Fragment } from "react"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Input } from "@/ui/input"; +import { Segmented } from "@/ui/segmented"; +import { StatusLabel } from "@/ui/status-dot"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useText } from "@/i18n"; +import { coreText } from "@/i18n/core.i18n"; +import type { ImportRow, ItemKind, SyncJoinAuto } from "@/types"; +import { + autoOf, + choicesOf, + countRows, + pickAll, + refKey, + renameProblem, + type MergeChoice, + type MergePicks, +} from "./decide"; +import { itemLabel, unitLabel } from "./labels"; +import { syncLabelsText } from "./labels.i18n"; +import { mergeRowsText } from "./MergeRows.i18n"; + +/** 分组的先后:和导入对话框、导出的清单同一个顺序(方案 5.2) */ +const KIND_ORDER: readonly ItemKind[] = [ + "provider", + "proxy", + "key", + "alias", + "group", + "route", + "route_order", + "price_sheet", + "security_rule", + "plugin", + "global", + "client_prefs", +]; + +/** + * 加入同步时的合并预览:和导入同一套规则(方案 5.3、5.4)。**相同的不列**,只在统计里 + * 计数;新增的照导入,冲突的逐项选覆盖、改名导入或跳过(全局设置只有覆盖、跳过),没有 + * 缺省值;错误的不可导入,写出原因。 + */ +export function MergeRows({ + rows, + auto = [], + picks, + onPicks, + disabled, +}: { + rows: readonly ImportRow[]; + /** core 替人定了的几项(默认网关密钥采用同步数据):照常列出,不给选择 */ + auto?: readonly SyncJoinAuto[]; + picks: MergePicks; + onPicks: (next: MergePicks) => void; + disabled?: boolean; +}) { + const t = useText(mergeRowsText); + const lt = useText(syncLabelsText); + const c = countRows(rows, auto); + const shown = rows.filter((r) => r.status !== "same"); + const groups = KIND_ORDER.map((k) => ({ kind: k, rows: shown.filter((r) => r.item.kind === k) })).filter( + (g) => g.rows.length > 0, + ); + + const pick = (r: ImportRow, patch: Partial<{ choice: MergeChoice; name: string }>) => { + const k = refKey(r.item); + const cur = picks[k] ?? { choice: null, name: r.suggested_name ?? r.item.name }; + onPicks({ ...picks, [k]: { ...cur, ...patch } }); + }; + + return ( +
+
+ {t.counts(c.new, c.conflict, c.same, c.error)} + {c.conflict > 0 && ( +
+ {(["overwrite", "rename", "skip"] as const).map((x) => ( + + ))} +
+ )} +
+ {shown.length > 0 && ( +
+ + + + {t.name} + {t.status} + {t.action} + + + + {groups.map((g) => ( + + + + {lt.kind[g.kind]} + + + {g.rows.map((r) => { + const p = picks[refKey(r.item)]; + const problem = renameProblem(rows, picks, r); + const decided = autoOf(auto, r); + return ( + + + + {r.item.kind === "global" ? itemLabel(r.item) : r.item.name} + + + + {decided ? : } + + +
+ {decided ? ( + + {t.takeRemote} + + ) : r.status === "conflict" ? ( + <> + + label={t.action} + value={p?.choice ?? ("" as MergeChoice)} + disabled={disabled} + options={choicesOf(r).map((x) => ({ id: x, label: t.choice[x] }))} + onChange={(choice) => pick(r, { choice })} + /> + {p?.choice === "rename" && ( + <> + pick(r, { name: e.target.value })} + /> + {/* 改名导入是另一个项目:不沿用本机的密钥、登录(core 的 rename_flags) */} + {r.rename_flags.length > 0 && ( + + {t.renameFlags(r.rename_flags.map((f) => t.flag[f]).join(lt.unitSep))} + + )} + {problem && ( + + {problem === "empty" + ? t.renameEmpty + : problem === "same" + ? t.renameSame + : t.renameTaken} + + )} + + )} + + ) : ( + + {r.status === "error" ? t.cannot : t.importIt} + + )} +
+
+
+ ); + })} +
+ ))} +
+
+
+ )} +
+ ); +} + +/** + * core 替人定了的那一行(现在只有默认网关密钥):采用同步数据,接管着的客户端随后自动换上新值 + */ +function Decided() { + const t = useText(mergeRowsText); + return ( +
+ + {t.defaultKey} + + {t.clientsFollow} +
+ ); +} + +/** 状态一格:状态,下面一行是不同的部分、错误的原因、导入之后要留意的 */ +function Status({ row }: { row: ImportRow }) { + const t = useText(mergeRowsText); + const lt = useText(syncLabelsText); + const tone = row.status === "conflict" ? "warn" : row.status === "error" ? "error" : "ok"; + const units = row.differs.filter((u) => u !== "all").map(unitLabel); + return ( +
+
+ + {t.statusOf[row.status]} + + {row.flags.map((f) => ( + + {t.flag[f]} + + ))} +
+ {row.status === "conflict" && units.length > 0 && ( + {t.differs(units.join(lt.unitSep))} + )} + {row.error && {coreText(row.error)}} +
+ ); +} diff --git a/src/sync/Notices.tsx b/src/sync/Notices.tsx new file mode 100644 index 00000000..25580e4c --- /dev/null +++ b/src/sync/Notices.tsx @@ -0,0 +1,215 @@ +import { useState } from "react"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { notify } from "@/ui/notify"; +import { noMore, useIgnored } from "@/ignore"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { settingsApi } from "@/settings/api"; +import type { SyncView } from "@/types"; +import { ConflictDialog } from "./ConflictDialog"; +import { StopDialog, UnlockDialog } from "./Dialogs"; +import { syncApi } from "./api"; +import { syncNoticeText } from "./SyncPart.i18n"; +import { syncOn, useSyncView } from "./useSync"; + +/** + * core 的同步原因是可以接在句子里的半句(英文小写开头、没有句号)。单独成行时写成一整句: + * 首字母大写、补上句号。中文译文本来就是整句,不动 + */ +export function sentence(s: string): string { + const t = s.trim(); + if (!t) return t; + const head = t.charAt(0).toUpperCase() + t.slice(1); + return /[.!?。!?]$/.test(head) ? head : `${head}.`; +} + +/** 冲突提示的忽略记号:设置里和上游页顶上是同一条,忽略一处两处都不再出现,直到冲突变多 */ +const CONFLICT_KEY = "sync:conflicts"; +/** 别的几种状态(连不上、拒绝访问、口令改了、版本过新)共用一个,印记是这一次出事的样子 */ +const STATE_KEY = "sync:state"; + +/** 这一次出事的样子:状态、从什么时候起失败、上一次同步到哪儿。好了又坏,印记就换了 */ +export function stateMark(v: SyncView): string { + return `${v.state}:${v.failing_since_ms ?? ""}:${v.last_synced_ms ?? ""}`; +} + +/** + * 同步要人处理的几件事,黄色提示(方案 6.10),**都可以忽略**。`only="conflicts"`:上游页 + * 顶上只说冲突,别的几件在设置里说。`withoutConflicts`:设置里的状态行已经写着冲突数、 + * 带着「处理冲突…」,那里不再重复一条冲突提示。 + * + * 正常运转(已同步、同步中)什么都不说。安全模式下暂停是灰色的告知,不用处理,也不给忽略。 + */ +export function SyncNotices({ + view, + only, + withoutConflicts = false, + onChanged, +}: { + view: SyncView; + only?: "conflicts"; + withoutConflicts?: boolean; + /** 处理了冲突、输了口令:外面换上新的状态 */ + onChanged: (v: SyncView) => void; +}) { + const t = useText(syncNoticeText); + const [dialog, setDialog] = useState<"conflicts" | "unlock" | "stop" | null>(null); + const [republishing, setRepublishing] = useState(false); + const conflicts = useIgnored(CONFLICT_KEY, view.conflicts, noMore); + const state = useIgnored(STATE_KEY, stateMark(view), (a, b) => a === b); + const all = only !== "conflicts"; + const why = view.error ? sentence(errorText(view.error)) : ""; + + /** 同步位置空了、同步密钥不可用:重新上传本机配置,换上 core 交回的状态 */ + async function republish() { + setRepublishing(true); + try { + onChanged(await syncApi.republish()); + } catch (e) { + notify.error(e); + } finally { + setRepublishing(false); + } + } + /** 那两种状态的两个操作:重新上传本机配置、停止同步 */ + const recover = ( + <> + + + + ); + + async function checkUpdate() { + try { + await settingsApi.checkUpdate(); + } catch (e) { + notify.error(e); + } + } + + return ( + <> + 0 && !conflicts.ignored} + title={t.conflictTitle(view.conflicts)} + onIgnore={conflicts.ignore} + actions={ + + } + > + {t.conflictBody} + + {all && ( + <> + + {/* 原因(core 的那一句)单独一行,再说接下来会怎样 */} + {why && {why}} + {t.unreachableBody} + + + {why && {why}} + {t.authBody} + + setDialog("unlock")}> + {t.unlock} + + } + > + {t.lockedBody} + + void checkUpdate()}> + {t.update} + + } + > + {t.tooNewBody} + + + {t.keyringBody} + + + {t.emptiedBody} + + + {why && {why}} + {t.misconfiguredBody} + + + {t.pausedBody} + + + )} + {dialog === "conflicts" && setDialog(null)} onResolved={onChanged} />} + {dialog === "unlock" && setDialog(null)} onDone={onChanged} />} + {dialog === "stop" && ( + setDialog(null)} onDone={() => void syncApi.status().then(onChanged, () => {})} /> + )} + + ); +} + +/** + * 上游页顶上那一条:同步有冲突时说一声,点开处理。没开同步、读不到同步状态(旧版 core) + * 时什么都不画 + */ +export function SyncConflictBanner() { + const r = useSyncView(); + const v = r.data; + if (!syncOn(v)) return null; + return r.mutate(next)} />; +} diff --git a/src/sync/SyncPart.i18n.ts b/src/sync/SyncPart.i18n.ts new file mode 100644 index 00000000..508c4d22 --- /dev/null +++ b/src/sync/SyncPart.i18n.ts @@ -0,0 +1,155 @@ +import { messages } from "@/i18n"; + +export const syncPartText = messages( + { + title: "同步", + offDesc: "多台设备共用一份配置,并在同步位置保存最新与上一个版本。", + turnOn: "开启同步…", + stop: "停止同步…", + now: "立即同步", + status: "状态", + state: { + idle: (when: string) => `已同步 · ${when}`, + never: "尚未同步", + syncing: "正在同步", + conflict: (n: number) => `有 ${n} 项冲突`, + unreachable: "无法连接同步位置", + auth_failed: "同步位置拒绝访问", + locked: "同步口令已在其他设备上更改", + too_new: "已暂停:需更新", + paused: "已暂停:安全模式", + keyring_missing: "同步密钥不可用", + emptied: "同步位置为空", + misconfigured: "同步设置无法使用", + }, + /** 同步位置上用不了、跳过了的文件(不另起提示,状态旁边一句) */ + skipped: (n: number) => `同步位置上有 ${n} 个文件无法使用,已跳过。`, + resolve: "处理冲突…", + unlock: "输入口令…", + encryption: "加密", + encrypted: "以同步口令加密", + plain: "不加密。同步位置的服务商可读取配置与密钥。", + changePassphrase: "更改同步口令…", + excluded: "不同步的上游", + excludedNone: "无", + excludedSep: "、", + pick: "选择…", + cascade: (items: string) => `随之不同步:${items}。`, + devices: "设备", + lastSeen: "最后同步", + coreVersion: "Core 版本", + deviceState: "状态", + thisDevice: (name: string) => `${name}(本设备)`, + /** 服务器上的 core 加入的同步 */ + remoteCore: (name: string) => `${name}(远程 core)`, + ok: "正常", + pausedUpdate: "已暂停:需更新", + saveFailed: "未能保存", + loadFailed: "未能读取同步状态", + nowFailed: "未能同步", + }, + { + title: "Sync", + offDesc: "Devices share one configuration; the sync location keeps the latest and the previous version.", + turnOn: "Turn on sync…", + stop: "Stop syncing…", + now: "Sync now", + status: "Status", + state: { + idle: (when: string) => `Synced · ${when}`, + never: "Not synced yet", + syncing: "Syncing", + conflict: (n: number) => (n === 1 ? "1 conflict" : `${n} conflicts`), + unreachable: "Sync location unreachable", + auth_failed: "Sync location denied access", + locked: "Passphrase changed on another device", + too_new: "Paused: update required", + paused: "Paused: safe mode", + keyring_missing: "Sync key unavailable", + emptied: "Sync location empty", + misconfigured: "Sync settings unusable", + }, + skipped: (n: number) => + n === 1 + ? "1 file at the sync location could not be used and was skipped." + : `${n} files at the sync location could not be used and were skipped.`, + resolve: "Resolve…", + unlock: "Enter passphrase…", + encryption: "Encryption", + encrypted: "Encrypted with the sync passphrase", + plain: "Not encrypted. The storage provider can read the configuration and its keys.", + changePassphrase: "Change passphrase…", + excluded: "Upstreams not synced", + excludedNone: "None", + excludedSep: ", ", + pick: "Choose…", + cascade: (items: string) => `Not synced with them: ${items}.`, + devices: "Device", + lastSeen: "Last sync", + coreVersion: "Core version", + deviceState: "Status", + thisDevice: (name: string) => `${name} (this device)`, + remoteCore: (name: string) => `${name} (remote core)`, + ok: "OK", + pausedUpdate: "Paused: update required", + saveFailed: "Could not save", + loadFailed: "The sync status could not be read", + nowFailed: "Sync failed", + }, +); + +/** 需要处理的状态:黄色提示(都可忽略) */ +export const syncNoticeText = messages( + { + conflictTitle: (n: number) => `同步有 ${n} 项冲突`, + conflictBody: "本机与其他设备将同一对象修改为不同内容。处理之前,本机保留当前内容。", + resolve: "处理冲突…", + unreachableTitle: "无法连接同步位置", + unreachableBody: "恢复后自动继续,本机的修改在恢复后上传。", + authTitle: "同步位置拒绝访问", + authBody: "修改同步位置的连接参数后继续同步。", + lockedTitle: "同步口令已在其他设备上更改", + lockedBody: "输入新口令后继续同步。", + unlock: "输入口令…", + tooNewTitle: "同步已暂停", + tooNewBody: "其他设备使用较新版本,更新 ThinkWatch Lite 后继续同步。", + update: "检查更新", + pausedTitle: "安全模式下不同步", + pausedBody: "配置恢复正常后自动继续。", + keyringTitle: "同步位置上的同步密钥不可用", + keyringBody: "同步密钥缺失、被替换为较早的版本或无法读取,同步已暂停。可重新上传本机配置(同时恢复本设备保存的同步密钥),或停止同步。", + emptiedTitle: "同步位置为空", + emptiedBody: "其他设备可能删除了同步数据,或同步位置已不是原来的位置,同步已暂停。可重新上传本机配置,或停止同步。", + misconfiguredTitle: "同步设置无法使用", + misconfiguredBody: "修改同步位置的连接参数或出站代理后继续同步。", + republish: "重新上传本机配置", + stop: "停止同步…", + }, + { + conflictTitle: (n: number) => (n === 1 ? "1 sync conflict" : `${n} sync conflicts`), + conflictBody: "This device and another one changed the same item in different ways. Until resolved, this device keeps its own version.", + resolve: "Resolve…", + unreachableTitle: "Sync location unreachable", + unreachableBody: "Syncing continues once it is reachable again; changes made on this device are uploaded then.", + authTitle: "Sync location denied access", + authBody: "Change the sync location's connection details to continue syncing.", + lockedTitle: "Sync passphrase changed on another device", + lockedBody: "Enter the new passphrase to continue syncing.", + unlock: "Enter passphrase…", + tooNewTitle: "Sync paused", + tooNewBody: "Another device uses a newer version. Update ThinkWatch Lite to continue syncing.", + update: "Check for updates", + pausedTitle: "No syncing in safe mode", + pausedBody: "Syncing continues once the configuration can be read again.", + keyringTitle: "Sync key at the sync location unavailable", + keyringBody: + "The sync key is missing, was replaced by an older one or cannot be read, so syncing is paused. Upload this device's configuration again (this puts back the sync key saved on this device), or stop syncing.", + emptiedTitle: "Sync location empty", + emptiedBody: + "Another device may have deleted the sync data, or the sync location is no longer the same place, so syncing is paused. Upload this device's configuration again, or stop syncing.", + misconfiguredTitle: "Sync settings unusable", + misconfiguredBody: "Change the sync location's connection details or outbound proxy to continue syncing.", + republish: "Upload this device's configuration", + stop: "Stop syncing…", + }, +); diff --git a/src/sync/SyncPart.tsx b/src/sync/SyncPart.tsx new file mode 100644 index 00000000..75e655b4 --- /dev/null +++ b/src/sync/SyncPart.tsx @@ -0,0 +1,451 @@ +import { useState } from "react"; +import { call } from "@/control"; +import { cn } from "@/lib/utils"; +import { useResource } from "@/lib/resource"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Input } from "@/ui/input"; +import { notify } from "@/ui/notify"; +import { StatusLabel, type StatusTone } from "@/ui/status-dot"; +import { Switch } from "@/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { useText } from "@/i18n"; +import { PartHeading } from "@/backup/parts"; +import { coreText, errorText } from "@/i18n/core.i18n"; +import { useNavParams } from "@/nav"; +import { useNow } from "@/useNow"; +import { useRemote } from "@/connection/useRemote"; +import { useFormDraft } from "@/settings/form"; +import { ErrorRow, RowSkeleton, SaveBar, SettingsCard, SettingsRow, useDirtyMark, useSavedFlash } from "@/settings/kit"; +import type { SyncEncryptionAction, SyncView } from "@/types"; +import { syncApi } from "./api"; +import { ConflictDialog } from "./ConflictDialog"; +import { EncryptionDialog, ExcludeDialog, StopDialog, UnlockDialog } from "./Dialogs"; +import { TargetFields, type RowOf } from "./Fields"; +import { syncFieldsText } from "./fields.i18n"; +import { JoinWizard } from "./JoinWizard"; +import { ago, itemLabel } from "./labels"; +import { SyncNotices, sentence } from "./Notices"; +import { syncPartText } from "./SyncPart.i18n"; +import { draftOf, sameTarget, targetOf, targetProblem, type TargetDraft } from "./target"; +import { syncOn, useSyncView } from "./useSync"; + +/** 目录上挂「有没存的」点的那一节。和「同步与备份」的外壳(`SyncBackupSection`)同一个 id */ +const SECTION = "sync-backup"; + +/** + * 设置 › 同步与备份 › 同步(方案 6.13)。外壳(`settings/SyncBackupSection.tsx`)把它和备份 + * 那一半放在同一节里;本机模式下独立一节,远程模式下归入「服务器」组,改的是远程 core 的同步。 + * + * - **没开同步**:一句说明和「开启同步…」(向导,`JoinWizard`)。上游页空状态的「加入同步…」 + * 经 `open("settings", { section: "sync-backup", action: "join-sync" })` 直接打开它。 + * - **开着**:状态、同步位置与连接参数、加密、不同步的上游、本设备名称;改了点「保存」才写进 + * 配置(`config.yaml` 的 `sync` 节)。加密的开关和口令走对话框(要口令,core 当场重新上传)。 + * 下面是设备列表。要人处理的状态在最上面一条黄色提示,可以忽略。 + */ +export function SyncPart() { + const t = useText(syncPartText); + const r = useSyncView(); + const ov = useResource("overview", () => call("Overview", null), { events: ["config_reloaded"] }); + const [wizard, setWizard] = useState(false); + const v = r.data; + + // 上游页空状态的「加入同步…」:开着同步时只是滚到这一节(外壳那边已经滚了) + useNavParams("settings", (p) => { + if (p.action === "join-sync") setWizard(true); + }); + + const proxies = ov.data?.proxies.map((p) => p.name) ?? []; + const upstreams = ov.data?.providers.map((p) => p.name) ?? []; + const on = syncOn(v); + + return ( +
+ {v === undefined ? ( + <> + + + {r.error !== undefined && !r.loading ? ( + void r.reload()} /> + ) : ( + <> + + + + )} + + + ) : on ? ( + r.mutate(next)} reload={() => void r.reload()} /> + ) : ( + <> + + + setWizard(true)}> + {t.turnOn} + + } + /> + + + )} + {wizard && !on && ( + setWizard(false)} onDone={() => void r.reload()} /> + )} +
+ ); +} + + +interface Draft { + target: TargetDraft; + device: string; + excluded: string[]; +} + +const sameList = (a: readonly string[], b: readonly string[]) => + a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]); + +const same = (a: Draft, b: Draft) => + sameTarget(a.target, b.target) && a.device === b.device && sameList(a.excluded, b.excluded); + +function On({ + view, + proxies, + upstreams, + onView, + reload, +}: { + view: SyncView; + proxies: string[]; + upstreams: string[]; + onView: (v: SyncView) => void; + reload: () => void; +}) { + const t = useText(syncPartText); + const ft = useText(syncFieldsText); + const remote = useRemote(); + const saved: Draft = { + target: draftOf(view.target), + device: view.device?.name ?? "", + excluded: view.excluded, + }; + const { draft, setDraft, dirty, commit, reset } = useFormDraft("sync", saved, same); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [justSaved, flash] = useSavedFlash(); + const [syncing, setSyncing] = useState(false); + const [dialog, setDialog] = useState< + | { kind: "stop" } + | { kind: "exclude" } + | { kind: "conflicts" } + | { kind: "unlock" } + | { kind: "encryption"; action: SyncEncryptionAction } + | null + >(null); + useDirtyMark(SECTION, dirty); + + const problem = targetProblem(draft.target); + const deviceOk = draft.device.trim() !== ""; + const valid = problem === null && deviceOk; + + async function save() { + setBusy(true); + setError(null); + try { + await syncApi.save({ + target: sameTarget(draft.target, saved.target) ? null : targetOf(draft.target), + device_name: draft.device === saved.device ? null : draft.device.trim(), + excluded: sameList(draft.excluded, saved.excluded) ? null : draft.excluded, + }); + commit({ ...draft, device: draft.device.trim() }); + flash(); + reload(); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(false); + } + } + + async function now() { + setSyncing(true); + try { + onView(await syncApi.now()); + } catch (e) { + notify.error(e, t.nowFailed); + } finally { + setSyncing(false); + } + } + + const row: RowOf = ({ id, label, htmlFor, hint, control }) => ( + + ); + const cascade = sameList(draft.excluded, saved.excluded) ? view.excluded_cascade : []; + + return ( + <> + + + + + } + /> + +
{ + e.preventDefault(); + if (dirty && !busy && valid) void save(); + }} + > + + } + control={ + + + {view.conflicts > 0 && ( + + )} + {/* 口令在别处改了:提示可以忽略,这里一直给得到入口 */} + {view.state === "locked" && ( + + )} + + } + /> + { + setError(null); + setDraft((d) => ({ ...d, target: { ...d.target, ...patch } })); + }} + row={row} + /> + + {view.encrypted && ( + + )} + setDialog({ kind: "encryption", action: c === true ? "enable" : "disable" })} + /> + + } + /> + + 0 && "text-foreground")}> + {draft.excluded.length > 0 ? draft.excluded.join(t.excludedSep) : t.excludedNone} + + {cascade.length > 0 && ( + {t.cascade(cascade.map(itemLabel).join(t.excludedSep))} + )} + + } + control={ + + } + /> + {ft.deviceNameMissing}} + control={ + { + setError(null); + setDraft((d) => ({ ...d, device: e.target.value })); + }} + /> + } + /> + {problem && dirty && ( +
{ft.problem[problem]}
+ )} + { + setError(null); + reset(); + }} + /> +
+
+ + {error} + + + + {dialog?.kind === "stop" && setDialog(null)} onDone={reload} />} + {dialog?.kind === "conflicts" && setDialog(null)} onResolved={onView} />} + {dialog?.kind === "unlock" && setDialog(null)} onDone={onView} />} + {dialog?.kind === "encryption" && ( + setDialog(null)} onDone={onView} /> + )} + {dialog?.kind === "exclude" && ( + setDialog(null)} + onDone={(excluded) => setDraft((d) => ({ ...d, excluded }))} + /> + )} + + ); +} + +/** + * 状态行下面的几句说明,**不另起提示**:同步位置上跳过了的文件(`skipped`),上一轮同步成了 + * 但没照办的地方(`notices`,比如通用偏好超出上限、这台留着自己的)。没有就什么都不画 + */ +function StateNotes({ view }: { view: SyncView }) { + const t = useText(syncPartText); + const lines = [ + ...(view.skipped > 0 ? [t.skipped(view.skipped)] : []), + ...view.notices.map((m) => sentence(coreText(m))), + ]; + if (lines.length === 0) return null; + return ( + + {lines.map((l, i) => ( + {l} + ))} + + ); +} + +/** 状态那一格:已同步 · 2 分钟前、正在同步、有几项冲突、连不上…… */ +function State({ view }: { view: SyncView }) { + const t = useText(syncPartText); + const now = useNow(30_000); + let tone: StatusTone = "warn"; + let text: string; + switch (view.state) { + case "idle": + case "off": + tone = "ok"; + text = view.last_synced_ms ? t.state.idle(ago(view.last_synced_ms, now)) : t.state.never; + break; + case "syncing": + tone = "pending"; + text = t.state.syncing; + break; + case "conflict": + text = t.state.conflict(view.conflicts); + break; + case "paused": + tone = "idle"; + text = t.state.paused; + break; + default: + text = t.state[view.state]; + } + return ( + + {text} + + ); +} + +/** + * 设备列表:本设备在前,其余按最后同步的时间。**版本过新而暂停的写「已暂停:需更新」** + * —— 别的设备上看得见是哪一台卡住了 + */ +function Devices({ view }: { view: SyncView }) { + const t = useText(syncPartText); + const now = useNow(30_000); + if (view.devices.length === 0) return null; + const list = [...view.devices].sort((a, b) => Number(b.this) - Number(a.this) || b.last_seen_ms - a.last_seen_ms); + return ( + + + + + {t.devices} + {t.lastSeen} + {t.coreVersion} + {t.deviceState} + + + + {list.map((d) => ( + + + + {d.this ? t.thisDevice(d.name) : d.remote_core ? t.remoteCore(d.name) : d.name} + + + {ago(d.last_seen_ms, now)} + {d.core_version} + + {d.paused_update ? ( + {t.pausedUpdate} + ) : ( + + {t.ok} + + )} + + + ))} + +
+
+ ); +} diff --git a/src/sync/UpstreamBadges.i18n.ts b/src/sync/UpstreamBadges.i18n.ts new file mode 100644 index 00000000..4567f03d --- /dev/null +++ b/src/sync/UpstreamBadges.i18n.ts @@ -0,0 +1,27 @@ +import { messages } from "@/i18n"; + +export const upstreamBadgesText = messages( + { + notSynced: "不同步", + notSyncedTip: "此上游不参与同步:不上传,也不被其他设备的同名上游覆盖。在设置 › 同步与备份中修改。", + urlChanged: "地址经同步变更", + urlChangedTitle: "地址经同步变更", + urlChangedBody: (from: string, to: string) => `地址由 ${from} 变更为 ${to}。`, + by: (device: string, when: string) => `${device},${when}`, + when: (when: string) => when, + plainNote: "同步内容未加密,地址的变更在这里提示一次。", + markRead: "标为已读", + }, + { + notSynced: "Not synced", + notSyncedTip: + "This upstream is left out of sync: it is not uploaded and not replaced by an upstream of the same name from another device. Change this in Settings › Sync & backup.", + urlChanged: "URL changed by sync", + urlChangedTitle: "URL changed by sync", + urlChangedBody: (from: string, to: string) => `The URL changed from ${from} to ${to}.`, + by: (device: string, when: string) => `${device}, ${when}`, + when: (when: string) => when, + plainNote: "Synced content is not encrypted, so a URL change is pointed out here once.", + markRead: "Mark as read", + }, +); diff --git a/src/sync/UpstreamBadges.tsx b/src/sync/UpstreamBadges.tsx new file mode 100644 index 00000000..06e8caca --- /dev/null +++ b/src/sync/UpstreamBadges.tsx @@ -0,0 +1,71 @@ +import type { KeyboardEvent, MouseEvent } from "react"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +import { Tip } from "@/ui/tip"; +import { useIgnored } from "@/ignore"; +import { whenMinute } from "@/format"; +import { useText } from "@/i18n"; +import type { ProviderView } from "@/types"; +import { upstreamBadgesText } from "./UpstreamBadges.i18n"; + +/** 行里自己能点的:点击、按键不冒泡成「打开这一行」(弹层挂在行的 React 树里,也一样) */ +const keep = { + onClick: (e: MouseEvent) => e.stopPropagation(), + onKeyDown: (e: KeyboardEvent) => e.stopPropagation(), +}; + +/** + * 上游行名字后面的两个同步标签(方案 6.2、6.3): + * + * - **不同步**:开着同步、这一家在「不同步的上游」里; + * - **地址经同步变更**:不加密同步时,这一家的地址被别的设备改了。**提示一次**:点开看 + * 从哪儿改到哪儿、哪台设备改的,「标为已读」之后不再出现(记在这台电脑上),再变一次才 + * 又出现。 + * + * 两样都是 core 写在上游上的(`sync_excluded`、`sync_url_changed`)。 + */ +export function SyncBadges({ p }: { p: ProviderView }) { + const t = useText(upstreamBadgesText); + // core 在上游上写明(开着同步、在「不同步的上游」里) + const excluded = p.sync_excluded; + const change = p.sync_url_changed ?? null; + const read = useIgnored(`sync-url:${p.name}`, change ? `${change.at_ms}` : "", (a, b) => a === b); + const showChange = !!change && !read.ignored; + if (!excluded && !showChange) return null; + return ( + <> + {excluded && ( + {t.notSyncedTip}

}> + + {t.notSynced} + +
+ )} + {showChange && change && ( + + + + + + e.stopPropagation()}> +

{t.urlChangedTitle}

+

{t.urlChangedBody(change.from, p.base_url)}

+

+ {change.device ? t.by(change.device, whenMinute(change.at_ms)) : t.when(whenMinute(change.at_ms))} +

+

{t.plainNote}

+
+ +
+
+
+
+ )} + + ); +} diff --git a/src/sync/api.ts b/src/sync/api.ts new file mode 100644 index 00000000..ebf981a5 --- /dev/null +++ b/src/sync/api.ts @@ -0,0 +1,53 @@ +/** + * 同步用到的调用。 + * + * **同步本身在 core**(`/sync…`,类型化的 `call`)。这一侧(Rust)只做 core 看不见的几样: + * 选文件夹的系统对话框、这台电脑的名字、哪几把密钥被接管着的客户端用着(`sync_pin_keys`, + * 开启同步之前报一次,「使用同步数据替换本机配置」时 core 据此保留它们)。 + */ +import { invoke } from "@tauri-apps/api/core"; +import { call } from "@/control"; +import type { + ImportDecision, + SyncEncryptionAction, + SyncJoin, + SyncConflictDecision, + SyncSettingsSave, + SyncTarget, +} from "@/types"; + +export const syncApi = { + status: () => call("SyncStatus", null), + test: (target: SyncTarget, passphrase: string | null) => call("SyncTest", { target, passphrase }), + joinPreview: (target: SyncTarget, passphrase: string | null) => call("SyncJoinPreview", { target, passphrase }), + enable: (req: { + target: SyncTarget; + encrypt: boolean; + passphrase: string | null; + device_name: string; + join: SyncJoin; + decisions: ImportDecision[]; + }) => call("SyncEnable", req), + save: (req: SyncSettingsSave) => call("SyncSave", req), + now: () => call("SyncNow", null), + conflicts: () => call("SyncConflicts", null), + resolve: (decisions: SyncConflictDecision[]) => call("SyncResolve", { decisions }), + encryption: (action: SyncEncryptionAction, passphrase: string | null, newPassphrase: string | null) => + call("SyncEncryption", { action, passphrase, new_passphrase: newPassphrase }), + unlock: (passphrase: string) => call("SyncUnlock", { passphrase }), + disable: (purge: boolean) => call("SyncDisable", { purge }), + /** 同步位置空了、同步密钥不可用之后:重新上传本机配置(放回本设备记着的同步密钥) */ + republish: () => call("SyncRepublish", null), + /** 系统的「选择文件夹」对话框。只在连着本机时有;取消了是 null */ + pickFolder: () => invoke("pick_sync_folder"), + /** 这台电脑的名字(本设备名称的缺省值) */ + computerName: () => invoke("sync_device_name"), + /** 把接管着的客户端在用的密钥报给 core */ + pinKeys: () => invoke("sync_pin_keys"), +}; + +/** `useResource` 的键。属于连着的那个 core,换连接时清掉 */ +export const SYNC_KEYS = { + status: "sync:status", + conflicts: "sync:conflicts", +} as const; diff --git a/src/sync/decide.ts b/src/sync/decide.ts new file mode 100644 index 00000000..75b91dac --- /dev/null +++ b/src/sync/decide.ts @@ -0,0 +1,177 @@ +/** + * 两处要逐项选的:加入同步时的合并(和导入同一套规则,方案 5.3、5.4),和同步冲突 + * (6.7)。只是记哪一项选了什么、写成交给 core 的样子、还差几项没选。纯函数,导出给测试。 + */ +import type { + ImportDecision, + ImportRow, + ItemRef, + SyncConflict, + SyncConflictDecision, + SyncJoinAuto, + SyncTake, +} from "@/types"; + +export const refKey = (r: ItemRef) => `${r.kind}\u0000${r.name}`; + +/** + * core 替人定了、不用选的那一项(`SyncJoinPreview.auto`:两边的默认网关密钥同名、值不同时 + * 采用同步数据的值)。照常列出,但不给选择、不算没选的冲突、不交处理 + */ +export function autoOf(auto: readonly SyncJoinAuto[], row: ImportRow): SyncJoinAuto | undefined { + const k = refKey(row.item); + return auto.find((a) => refKey(a.item) === k); +} + +// ───────────────────────────────────────────── 合并(加入同步的第 3 步) + +/** 冲突的一项怎么处理。**没有缺省值**:没选就是 null */ +export type MergeChoice = "overwrite" | "rename" | "skip"; + +export interface MergePick { + choice: MergeChoice | null; + /** 改名导入时的新名字。缺省是 core 给的建议(「公司中转 (2)」) */ + name: string; +} + +export type MergePicks = Record; + +/** 全局设置的一节只能覆盖或跳过 */ +export function choicesOf(row: ImportRow): MergeChoice[] { + return row.global ? ["overwrite", "skip"] : ["overwrite", "rename", "skip"]; +} + +export function initialPicks(rows: readonly ImportRow[], auto: readonly SyncJoinAuto[] = []): MergePicks { + const out: MergePicks = {}; + for (const r of rows) + if (r.status === "conflict" && !autoOf(auto, r)) out[refKey(r.item)] = { choice: null, name: r.suggested_name ?? `${r.item.name} (2)` }; + return out; +} + +/** 列表上方的「全部覆盖 / 全部改名导入 / 全部跳过」:全局设置那几节不能改名,跳过它们 */ +export function pickAll( + rows: readonly ImportRow[], + picks: MergePicks, + choice: MergeChoice, + auto: readonly SyncJoinAuto[] = [], +): MergePicks { + const out = { ...picks }; + for (const r of rows) { + if (r.status !== "conflict" || autoOf(auto, r) || !choicesOf(r).includes(choice)) continue; + const k = refKey(r.item); + out[k] = { ...(out[k] ?? { name: r.suggested_name ?? r.item.name }), choice }; + } + return out; +} + +/** 改名导入的名字哪儿不对:空的、和原名一样、和另一项改成的名字撞了 */ +export function renameProblem( + rows: readonly ImportRow[], + picks: MergePicks, + row: ImportRow, +): "empty" | "same" | "taken" | null { + const p = picks[refKey(row.item)]; + if (!p || p.choice !== "rename") return null; + const name = p.name.trim(); + if (!name) return "empty"; + if (name === row.item.name) return "same"; + const clash = rows.some((r) => { + if (r === row || r.item.kind !== row.item.kind) return false; + // 同类里已经有这个名字(配置包里另一项,或者另一项改成了它) + if (r.item.name === name) return true; + const q = picks[refKey(r.item)]; + return q?.choice === "rename" && q.name.trim() === name; + }); + return clash ? "taken" : null; +} + +/** 还没选的冲突有几项;改名改得不对的也算没选好 */ +export function unresolved(rows: readonly ImportRow[], picks: MergePicks, auto: readonly SyncJoinAuto[] = []): number { + return rows.filter((r) => { + if (r.status !== "conflict" || autoOf(auto, r)) return false; + const p = picks[refKey(r.item)]; + return !p?.choice || renameProblem(rows, picks, r) !== null; + }).length; +} + +/** + * 交给 core 的逐项处理:新增的导入,冲突的照选的,相同的和错误的不交(core 不处理它们) + */ +export function mergeDecisions( + rows: readonly ImportRow[], + picks: MergePicks, + auto: readonly SyncJoinAuto[] = [], +): ImportDecision[] { + const out: ImportDecision[] = []; + for (const r of rows) { + // core 定了的不交(交了它也不看) + if (autoOf(auto, r)) continue; + if (r.status === "new") out.push({ item: r.item, action: "import" }); + else if (r.status === "conflict") { + const p = picks[refKey(r.item)]; + if (!p?.choice) continue; + out.push( + p.choice === "rename" + ? { item: r.item, action: "rename", new_name: p.name.trim() } + : { item: r.item, action: p.choice }, + ); + } + } + return out; +} + +export interface MergeCounts { + new: number; + conflict: number; + same: number; + error: number; +} + +/** 统计。core 定了的那几项不算冲突(不用选),也不另计 */ +export function countRows(rows: readonly ImportRow[], auto: readonly SyncJoinAuto[] = []): MergeCounts { + const c: MergeCounts = { new: 0, conflict: 0, same: 0, error: 0 }; + for (const r of rows) if (!(r.status === "conflict" && autoOf(auto, r))) c[r.status] += 1; + return c; +} + +// ───────────────────────────────────────────── 同步冲突 + +export interface ConflictPick { + take: SyncTake | null; + /** 两者都保留时另一方用的名字。缺省「名称 (MacBook Air)」 */ + name: string; +} + +export type ConflictPicks = Record; + +export function initialConflictPicks(list: readonly SyncConflict[]): ConflictPicks { + const out: ConflictPicks = {}; + for (const c of list) out[c.id] = { take: null, name: `${c.item.name} (${c.remote.device})` }; + return out; +} + +/** 两者都保留的名字哪儿不对:空的、和原名一样 */ +export function keepBothProblem(c: SyncConflict, p: ConflictPick | undefined): "empty" | "same" | null { + if (p?.take !== "both") return null; + const name = p.name.trim(); + if (!name) return "empty"; + return name === c.item.name ? "same" : null; +} + +/** 交给 core 的:**只交选了的**,没选的留到下次(「稍后处理」) */ +export function resolveDecisions(list: readonly SyncConflict[], picks: ConflictPicks): SyncConflictDecision[] { + const out: SyncConflictDecision[] = []; + for (const c of list) { + const p = picks[c.id]; + // core 给的选项里没有的不交(冲突刷新之后能选的变了) + if (!p?.take || !c.options.includes(p.take) || keepBothProblem(c, p)) continue; + out.push(p.take === "both" ? { id: c.id, take: "both", new_name: p.name.trim() } : { id: c.id, take: p.take }); + } + return out; +} + +/** 冲突那一方的设备:都是同一台时表头写它的名字,否则写「其他设备」 */ +export function remoteDevice(list: readonly SyncConflict[]): string | null { + const names = new Set(list.map((c) => c.remote.device)); + return names.size === 1 ? [...names][0]! : null; +} diff --git a/src/sync/dialogs.i18n.ts b/src/sync/dialogs.i18n.ts new file mode 100644 index 00000000..feebacc7 --- /dev/null +++ b/src/sync/dialogs.i18n.ts @@ -0,0 +1,71 @@ +import { messages } from "@/i18n"; + +export const syncDialogsText = messages( + { + enableTitle: "开启加密", + enableDesc: "设置同步口令后,重新上传最新版本并删除未加密的版本。其他设备在下次同步时须输入口令。", + enable: "开启加密", + disableTitle: "关闭加密", + disableDesc: + "重新上传未加密的版本,各设备保存的同步口令保留。之后同步位置的服务商可读取配置与密钥,对同步位置有写入权限的任何人也可以改动配置。", + disable: "关闭加密", + changeTitle: "更改同步口令", + changeDesc: "只重新加密同步密钥,已有版本不变。其他设备在下次同步时须输入新口令。", + change: "更改口令", + current: "当前口令", + fresh: "新口令", + freshAgain: "再次输入", + enterCurrent: "输入当前口令。", + failed: "未能完成", + unlockTitle: "输入同步口令", + unlockDesc: "同步口令已在其他设备上更改。输入新口令后继续同步。", + unlock: "继续同步", + stopTitle: "停止同步", + stopDesc: "本机保留当前配置,删除本机的同步设置,并从同步位置移除本设备的设备记录。其他设备不受影响。", + stop: "停止同步", + stopPurge: "停止同步并删除同步数据…", + purgeTitle: "删除同步数据", + purgeDesc: + "删除同步位置上的全部版本、设备记录与同步密钥。其他设备随后提示同步位置为空,可重新上传各自的配置或停止同步。此操作无法撤销;本机配置与本机备份不受影响。", + purge: "删除并停止同步", + excludeTitle: "不同步的上游", + excludeDesc: "所选上游不上传,也不被其他设备的同名上游覆盖,适用于本机地址的 Ollama 等。引用它们的路由、别名、策略组随之不同步。", + noUpstreams: "尚无上游。", + done: "完成", + }, + { + enableTitle: "Turn on encryption", + enableDesc: + "Once the sync passphrase is set, the latest version is uploaded again and the unencrypted versions are deleted. Other devices ask for the passphrase on their next sync.", + enable: "Turn on encryption", + disableTitle: "Turn off encryption", + disableDesc: + "An unencrypted version is uploaded again; the sync passphrase saved on each device is kept. From then on the storage provider can read the configuration and its keys, and anyone who can write to the sync location can change the configuration.", + disable: "Turn off encryption", + changeTitle: "Change sync passphrase", + changeDesc: "Only the sync key is encrypted again; existing versions stay as they are. Other devices ask for the new passphrase on their next sync.", + change: "Change passphrase", + current: "Current passphrase", + fresh: "New passphrase", + freshAgain: "Repeat", + enterCurrent: "Enter the current passphrase.", + failed: "This could not be completed", + unlockTitle: "Enter sync passphrase", + unlockDesc: "The sync passphrase was changed on another device. Enter the new passphrase to continue syncing.", + unlock: "Continue syncing", + stopTitle: "Stop syncing", + stopDesc: + "This device keeps its current configuration; its sync settings are removed and its device record is removed from the sync location. Other devices are not affected.", + stop: "Stop syncing", + stopPurge: "Stop and delete sync data…", + purgeTitle: "Delete sync data", + purgeDesc: + "Every version, device record and the sync key are deleted from the sync location. The other devices then report an empty sync location, where their configuration can be uploaded again or syncing stopped. This cannot be undone. This device's configuration and local backups are not affected.", + purge: "Delete and stop syncing", + excludeTitle: "Upstreams not synced", + excludeDesc: + "The chosen upstreams are not uploaded and are not replaced by upstreams of the same name from other devices — for example a local Ollama. Routes, aliases and groups that use them are not synced either.", + noUpstreams: "No upstreams yet.", + done: "Done", + }, +); diff --git a/src/sync/fields.i18n.ts b/src/sync/fields.i18n.ts new file mode 100644 index 00000000..809219dd --- /dev/null +++ b/src/sync/fields.i18n.ts @@ -0,0 +1,86 @@ +import { messages } from "@/i18n"; + +export const syncFieldsText = messages( + { + location: "同步位置", + kinds: { folder: "文件夹", webdav: "WebDAV", s3: "S3 兼容存储" }, + folder: "文件夹", + pick: "选择…", + folderHint: "iCloud Drive、Dropbox、OneDrive、Syncthing 等同步的文件夹均可。同步数据直接存放在该文件夹中,建议使用专用文件夹。", + folderRemoteHint: "服务器上的文件夹路径。", + url: "地址", + user: "用户名", + pass: "密码", + passHint: "坚果云等服务使用应用密码。", + endpoint: "端点", + region: "区域", + regionHint: "Cloudflare R2 填写 auto。", + bucket: "存储桶", + prefix: "前缀", + prefixHint: "可选。多个用途共用一个存储桶时用于区分。", + accessKeyId: "Access Key ID", + secretAccessKey: "Secret Access Key", + proxy: "代理", + direct: "直连", + system: "系统代理", + /** 还差哪一格 */ + problem: { + path: "填写文件夹路径。", + url: "填写 WebDAV 地址。", + url_scheme: "地址须以 https:// 或 http:// 开头。", + endpoint: "填写端点。", + endpoint_scheme: "端点须以 https:// 或 http:// 开头。", + bucket: "填写存储桶。", + access_key_id: "填写 Access Key ID。", + secret_access_key: "填写 Secret Access Key。", + }, + deviceName: "本设备名称", + deviceNameHint: "在其他设备的设备列表中显示。", + deviceNameMissing: "填写本设备名称。", + passphrase: "同步口令", + passphraseAgain: "再次输入", + passphraseShort: (n: number) => `同步口令至少 ${n} 个字符。`, + passphraseMismatch: "两次输入的口令不一致。", + }, + { + location: "Sync location", + kinds: { folder: "Folder", webdav: "WebDAV", s3: "S3-compatible" }, + folder: "Folder", + pick: "Choose…", + folderHint: + "Any folder synced by iCloud Drive, Dropbox, OneDrive, Syncthing or similar. Sync data is stored directly in it, so a dedicated folder is best.", + folderRemoteHint: "A folder path on the server.", + url: "URL", + user: "User name", + pass: "Password", + passHint: "Services such as Jianguoyun use an app password.", + endpoint: "Endpoint", + region: "Region", + regionHint: "For Cloudflare R2, enter auto.", + bucket: "Bucket", + prefix: "Prefix", + prefixHint: "Optional. Keeps this data apart when the bucket is shared.", + accessKeyId: "Access key ID", + secretAccessKey: "Secret access key", + proxy: "Proxy", + direct: "Direct", + system: "System proxy", + problem: { + path: "Enter a folder path.", + url: "Enter the WebDAV URL.", + url_scheme: "The URL must start with https:// or http://.", + endpoint: "Enter the endpoint.", + endpoint_scheme: "The endpoint must start with https:// or http://.", + bucket: "Enter the bucket.", + access_key_id: "Enter the access key ID.", + secret_access_key: "Enter the secret access key.", + }, + deviceName: "Device name", + deviceNameHint: "Shown in the device list on the other devices.", + deviceNameMissing: "Enter a device name.", + passphrase: "Sync passphrase", + passphraseAgain: "Repeat", + passphraseShort: (n: number) => `The sync passphrase needs at least ${n} characters.`, + passphraseMismatch: "The two passphrases differ.", + }, +); diff --git a/src/sync/labels.i18n.ts b/src/sync/labels.i18n.ts new file mode 100644 index 00000000..dd6617a2 --- /dev/null +++ b/src/sync/labels.i18n.ts @@ -0,0 +1,91 @@ +import { messages } from "@/i18n"; + +/** 同步里各处共用的词:对象的种类、合并单位、时间 */ +export const syncLabelsText = messages( + { + kind: { + provider: "上游", + proxy: "代理", + key: "网关密钥", + alias: "别名", + group: "策略组", + route: "路由", + price_sheet: "价目表", + security_rule: "安全规则", + plugin: "插件", + provider_order: "上游顺序", + route_order: "路由顺序", + plugin_order: "插件顺序", + global: "全局设置", + client_prefs: "通用偏好", + }, + /** 全局设置的一节,按节名 */ + global: { + failover: "故障转移", + security: "安全防护", + default_route: "默认路由", + default_key: "默认密钥", + client_probes: "辅助请求", + pricing: "价目表设置", + } as Record, + /** 合并单位 */ + unit: { + conn: "连接", + models: "模型", + billing: "计费", + other: "其他", + script: "脚本", + settings: "设置", + enabled: "启用状态", + } as Record, + /** 「上游「公司中转」」 */ + named: (kind: string, name: string) => `${kind}「${name}」`, + /** 「上游「公司中转」· 连接」:引号后面不再空一格 */ + withUnits: (what: string, units: string) => `${what}${what.endsWith("」") ? "" : " "}· ${units}`, + unitSep: "、", + justNow: "刚刚", + minutesAgo: (n: number) => `${n} 分钟前`, + today: (hm: string) => `今天 ${hm}`, + }, + { + kind: { + provider: "Upstream", + proxy: "Proxy", + key: "Gateway key", + alias: "Alias", + group: "Group", + route: "Route", + price_sheet: "Price sheet", + security_rule: "Security rule", + plugin: "Plugin", + provider_order: "Upstream order", + route_order: "Route order", + plugin_order: "Plugin order", + global: "Global settings", + client_prefs: "General preferences", + }, + global: { + failover: "Failover", + security: "Protection", + default_route: "Default route", + default_key: "Default key", + client_probes: "Auxiliary requests", + pricing: "Price sheet settings", + } as Record, + unit: { + conn: "connection", + models: "models", + billing: "billing", + other: "other", + script: "script", + settings: "settings", + enabled: "on/off", + } as Record, + named: (kind: string, name: string) => `${kind} “${name}”`, + withUnits: (what: string, units: string) => `${what} · ${units}`, + unitSep: ", ", + justNow: "just now", + minutesAgo: (n: number) => (n === 1 ? "1 minute ago" : `${n} minutes ago`), + today: (hm: string) => `today ${hm}`, + }, +); diff --git a/src/sync/labels.ts b/src/sync/labels.ts new file mode 100644 index 00000000..f8879306 --- /dev/null +++ b/src/sync/labels.ts @@ -0,0 +1,58 @@ +/** + * 同步里给人看的几样写法:一个对象叫什么、改了哪几部分、多久之前。 + * + * 在调用的那一刻按当前语言取(`textOf`),不存成模块级常量。 + */ +import { textOf } from "@/i18n"; +import { whenMinute } from "@/format"; +import type { ItemRef } from "@/types"; +import { syncLabelsText } from "./labels.i18n"; + +/** 「上游「公司中转」」「故障转移」「路由顺序」 */ +export function itemLabel(r: ItemRef): string { + const t = textOf(syncLabelsText); + switch (r.kind) { + case "global": + return t.global[r.name] ?? t.named(t.kind.global, r.name); + case "provider_order": + case "route_order": + case "plugin_order": + case "client_prefs": + return t.kind[r.kind]; + case "security_rule": { + // `<分项>/<规则名>`:分项在安全页上是一个标签,这里只写规则名 + const slash = r.name.indexOf("/"); + return t.named(t.kind.security_rule, slash >= 0 ? r.name.slice(slash + 1) : r.name); + } + default: + return t.named(t.kind[r.kind], r.name); + } +} + +/** 合并单位写成人话。认不得的(全局设置按字段,单位名是字段路径)原样 */ +export function unitLabel(unit: string): string { + return textOf(syncLabelsText).unit[unit] ?? unit; +} + +/** 「上游「公司中转」· 连接」。整个对象一个单位(`all`)的不写后半 */ +export function itemWithUnits(r: ItemRef, units: readonly string[]): string { + const t = textOf(syncLabelsText); + const named = units.filter((u) => u !== "all"); + const what = itemLabel(r); + return named.length ? t.withUnits(what, named.map(unitLabel).join(t.unitSep)) : what; +} + +/** + * 多久之前:一分钟内「刚刚」,一小时内「N 分钟前」,今天之内(超过一小时)「今天 09:31」, + * 更早的「10-16 11:02」(和别处写日期的样子一样) + */ +export function ago(atMs: number, now = Date.now()): string { + const t = textOf(syncLabelsText); + const d = Math.max(0, now - atMs); + if (d < 60_000) return t.justNow; + if (d < 3_600_000) return t.minutesAgo(Math.floor(d / 60_000)); + const at = new Date(atMs); + const today = new Date(now); + if (at.toDateString() === today.toDateString()) return t.today(whenMinute(atMs, now)); + return whenMinute(atMs, now); +} diff --git a/src/sync/sync.test.ts b/src/sync/sync.test.ts new file mode 100644 index 00000000..5705bfc7 --- /dev/null +++ b/src/sync/sync.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ImportRow, SyncConflict, SyncView } from "@/types"; +import { + countRows, + initialConflictPicks, + initialPicks, + keepBothProblem, + mergeDecisions, + pickAll, + refKey, + remoteDevice, + renameProblem, + resolveDecisions, + unresolved, +} from "./decide"; +import { ago, itemLabel, itemWithUnits } from "./labels"; +import { sentence, stateMark } from "./Notices"; +import { draftOf, EMPTY_TARGET, passphraseProblem, sameTarget, targetOf, targetProblem } from "./target"; +import { syncOn } from "./useSync"; + +const row = (kind: ImportRow["item"]["kind"], name: string, status: ImportRow["status"], extra: Partial = {}): ImportRow => ({ + item: { kind, name }, + status, + global: kind === "global", + flags: [], + differs: [], + suggested_name: status === "conflict" ? `${name} (2)` : null, + error: null, + refs: [], + rename_flags: [], + ...extra, +}); + +describe("同步位置的表单", () => { + it("三种位置各自写成 core 要的形状,地址去空白、密码原样,文件夹不经代理", () => { + expect(targetOf({ ...EMPTY_TARGET, type: "folder", path: " /Volumes/NAS/ThinkWatch ", proxy: "clash" })).toEqual({ + type: "folder", + path: "/Volumes/NAS/ThinkWatch", + proxy: "direct", + }); + expect( + targetOf({ ...EMPTY_TARGET, type: "webdav", url: " https://dav.example.com/tw ", user: " me ", pass: " p w ", proxy: "clash" }), + ).toEqual({ type: "webdav", url: "https://dav.example.com/tw", user: "me", pass: " p w ", proxy: "clash" }); + const s3 = { + type: "s3" as const, + endpoint: "https://x.r2.cloudflarestorage.com", + region: "auto", + bucket: "tw", + prefix: "lite/", + access_key_id: "AKIA", + secret_access_key: "s", + proxy: "direct", + }; + expect(targetOf(draftOf(s3))).toEqual(s3); + }); + + it("换了类型再换回来,填过的还在;别的类型的格子不算改过", () => { + const d = draftOf({ type: "webdav", url: "https://a", user: "u", pass: "p", proxy: "direct" }); + const moved = { ...d, type: "s3" as const, bucket: "b" }; + expect({ ...moved, type: "webdav" as const }.url).toBe("https://a"); + expect(sameTarget(d, { ...d, bucket: "anything" })).toBe(true); + expect(sameTarget(d, { ...d, pass: "q" })).toBe(false); + }); + + it("还差哪一格", () => { + expect(targetProblem({ ...EMPTY_TARGET })).toBe("path"); + expect(targetProblem({ ...EMPTY_TARGET, type: "webdav" })).toBe("url"); + expect(targetProblem({ ...EMPTY_TARGET, type: "webdav", url: "dav.example.com" })).toBe("url_scheme"); + expect(targetProblem({ ...EMPTY_TARGET, type: "webdav", url: "http://192.168.1.2/dav" })).toBeNull(); + expect(targetProblem({ ...EMPTY_TARGET, type: "s3", endpoint: "https://s3" })).toBe("bucket"); + expect(targetProblem({ ...EMPTY_TARGET, type: "s3", endpoint: "https://s3", bucket: "b", access_key_id: "k" })).toBe( + "secret_access_key", + ); + }); + + it("同步口令至少 12 个字符(按字符数,不按字节),两次一样", () => { + expect(passphraseProblem("短口令", "短口令")).toBe("short"); + expect(passphraseProblem("一二三四五六七八九十甲乙", "一二三四五六七八九十甲乙")).toBeNull(); + expect(passphraseProblem("correct horse", "correct house")).toBe("mismatch"); + }); +}); + +describe("加入同步时的合并", () => { + const rows = [ + row("provider", "openrouter", "new"), + row("provider", "公司中转", "conflict", { differs: ["conn", "models"] }), + row("provider", "bedrock", "error"), + row("route", "default", "same"), + row("global", "failover", "conflict"), + ]; + + it("冲突没有缺省值;全部改名导入跳过全局设置(它只能覆盖或跳过)", () => { + const picks = initialPicks(rows); + expect(unresolved(rows, picks)).toBe(2); + const all = pickAll(rows, picks, "rename"); + expect(all[refKey(rows[1]!.item)]).toEqual({ choice: "rename", name: "公司中转 (2)" }); + expect(all[refKey(rows[4]!.item)]?.choice).toBeNull(); + expect(unresolved(rows, all)).toBe(1); + expect(unresolved(rows, pickAll(rows, all, "skip"))).toBe(0); + }); + + it("新增的导入、冲突照选的、相同和错误的不交", () => { + const picks = pickAll(rows, initialPicks(rows), "overwrite"); + picks[refKey(rows[1]!.item)] = { choice: "rename", name: " 公司中转 (MBA) " }; + expect(mergeDecisions(rows, picks)).toEqual([ + { item: { kind: "provider", name: "openrouter" }, action: "import" }, + { item: { kind: "provider", name: "公司中转" }, action: "rename", new_name: "公司中转 (MBA)" }, + { item: { kind: "global", name: "failover" }, action: "overwrite" }, + ]); + expect(countRows(rows)).toEqual({ new: 1, conflict: 2, same: 1, error: 1 }); + }); + + it("改名改得不对的不算选好:空的、和原名一样、和同类的另一项撞名", () => { + const r = rows[1]!; + const k = refKey(r.item); + const base = initialPicks(rows); + expect(renameProblem(rows, { ...base, [k]: { choice: "rename", name: " " } }, r)).toBe("empty"); + expect(renameProblem(rows, { ...base, [k]: { choice: "rename", name: "公司中转" } }, r)).toBe("same"); + expect(renameProblem(rows, { ...base, [k]: { choice: "rename", name: "openrouter" } }, r)).toBe("taken"); + // 别的种类里有同名的不算撞 + expect(renameProblem(rows, { ...base, [k]: { choice: "rename", name: "default" } }, r)).toBeNull(); + }); +}); + +describe("同步冲突", () => { + const c = (id: string, name: string, device: string, both = true): SyncConflict => ({ + id, + item: { kind: "provider", name }, + units: ["conn"], + local: { device: "Mac mini", at_ms: 1, deleted: false }, + remote: { device, at_ms: 2, deleted: false }, + diff: [], + both_allowed: both, + options: both ? ["local", "remote", "both"] : ["local", "remote"], + reason: null, + }); + + it("只交选了的;两者都保留带新名字,缺省「名称 (设备)」", () => { + const list = [c("a", "公司中转", "MacBook Air"), c("b", "长上下文", "MacBook Air")]; + const picks = initialConflictPicks(list); + expect(picks.a).toEqual({ take: null, name: "公司中转 (MacBook Air)" }); + expect(resolveDecisions(list, picks)).toEqual([]); + picks.a = { ...picks.a!, take: "both" }; + picks.b = { ...picks.b!, take: "remote" }; + expect(resolveDecisions(list, picks)).toEqual([ + { id: "a", take: "both", new_name: "公司中转 (MacBook Air)" }, + { id: "b", take: "remote" }, + ]); + // 名字和原名一样的不交 + picks.a = { take: "both", name: "公司中转" }; + expect(keepBothProblem(list[0]!, picks.a)).toBe("same"); + expect(resolveDecisions(list, picks).map((d) => d.id)).toEqual(["b"]); + }); + + it("冲突都来自同一台设备时表头写它的名字", () => { + expect(remoteDevice([c("a", "x", "MacBook Air"), c("b", "y", "MacBook Air")])).toBe("MacBook Air"); + expect(remoteDevice([c("a", "x", "MacBook Air"), c("b", "y", "build-server")])).toBeNull(); + }); +}); + +describe("同步里的写法", () => { + it("对象和合并单位", () => { + setLang("zh"); + expect(itemLabel({ kind: "provider", name: "公司中转" })).toBe("上游「公司中转」"); + expect(itemLabel({ kind: "global", name: "failover" })).toBe("故障转移"); + expect(itemLabel({ kind: "security_rule", name: "content/项目代号" })).toBe("安全规则「项目代号」"); + expect(itemLabel({ kind: "route_order", name: "" })).toBe("路由顺序"); + expect(itemWithUnits({ kind: "provider", name: "公司中转" }, ["conn", "billing"])).toBe("上游「公司中转」· 连接、计费"); + expect(itemWithUnits({ kind: "route", name: "长上下文" }, ["all"])).toBe("路由「长上下文」"); + setLang("en"); + expect(itemWithUnits({ kind: "provider", name: "relay" }, ["conn"])).toBe("Upstream “relay” · connection"); + setLang("zh"); + }); + + it("多久之前:刚刚、几分钟前、今天几点、更早写日期", () => { + setLang("zh"); + const now = new Date(2026, 9, 18, 16, 42).getTime(); + expect(ago(now - 20_000, now)).toBe("刚刚"); + expect(ago(now - 2 * 60_000, now)).toBe("2 分钟前"); + expect(ago(new Date(2026, 9, 18, 9, 31).getTime(), now)).toBe("今天 09:31"); + expect(ago(new Date(2026, 9, 16, 11, 2).getTime(), now)).toBe("10-16 11:02"); + }); +}); + +describe("同步状态", () => { + const view: SyncView = { + state: "unreachable", + target: null, + encrypted: true, + device: null, + last_synced_ms: 100, + failing_since_ms: 50, + error: null, + devices: [], + conflicts: 0, + skipped: 0, + notices: [], + excluded: [], + excluded_cascade: [], + }; + + it("关着才算没开;忽略的印记随下一次出事换掉", () => { + expect(syncOn(view)).toBe(true); + expect(syncOn({ ...view, state: "off" })).toBe(false); + expect(syncOn(undefined)).toBe(false); + expect(stateMark(view)).not.toBe(stateMark({ ...view, failing_since_ms: 900 })); + expect(stateMark(view)).not.toBe(stateMark({ ...view, state: "auth_failed" })); + }); +}); + +describe("同步原因的写法", () => { + it("core 的半句单独成行时写成整句;中文整句不动", () => { + expect(sentence("the sync location could not be reached: timeout")).toBe("The sync location could not be reached: timeout."); + expect(sentence("无法连接同步位置:超时。")).toBe("无法连接同步位置:超时。"); + expect(sentence("")).toBe(""); + }); +}); + +describe("加入同步时 core 定了的项", () => { + it("默认网关密钥采用同步数据:不给选、不算没选的冲突、不交处理", () => { + const rows: ImportRow[] = [ + { item: { kind: "key", name: "default" }, status: "conflict", global: false, flags: [], differs: ["all"], suggested_name: null, error: null, refs: [], rename_flags: [] }, + { item: { kind: "provider", name: "relay" }, status: "conflict", global: false, flags: [], differs: ["conn"], suggested_name: "relay (2)", error: null, refs: [], rename_flags: [] }, + ]; + const auto = [{ item: { kind: "key" as const, name: "default" }, action: "overwrite" as const, reason: "default_key" as const }]; + const picks = initialPicks(rows, auto); + expect(Object.keys(picks)).toHaveLength(1); + expect(unresolved(rows, picks, auto)).toBe(1); + expect(countRows(rows, auto).conflict).toBe(1); + const all = pickAll(rows, picks, "skip", auto); + expect(unresolved(rows, all, auto)).toBe(0); + expect(mergeDecisions(rows, all, auto).map((d) => d.item.name)).toEqual(["relay"]); + }); +}); diff --git a/src/sync/target.ts b/src/sync/target.ts new file mode 100644 index 00000000..093dd788 --- /dev/null +++ b/src/sync/target.ts @@ -0,0 +1,130 @@ +/** + * 同步位置的表单:三种位置各几格,写成 `SyncTarget`、查填得对不对。设置里那一节和开启 + * 同步的第 1 步用的是同一份。纯函数,导出给测试。 + */ +import type { SyncTarget } from "@/types"; + +export type TargetKind = SyncTarget["type"]; + +export const TARGET_KINDS: readonly TargetKind[] = ["folder", "webdav", "s3"]; + +/** 三种位置的格子都在:换了类型再换回来,填过的还在 */ +export interface TargetDraft { + type: TargetKind; + path: string; + url: string; + user: string; + pass: string; + endpoint: string; + region: string; + bucket: string; + prefix: string; + access_key_id: string; + secret_access_key: string; + /** `direct` 或代理的名字。只对 WebDAV、S3 有意义 */ + proxy: string; +} + +export const EMPTY_TARGET: TargetDraft = { + type: "folder", + path: "", + url: "", + user: "", + pass: "", + endpoint: "", + region: "", + bucket: "", + prefix: "", + access_key_id: "", + secret_access_key: "", + proxy: "direct", +}; + +export function draftOf(t: SyncTarget | null | undefined): TargetDraft { + if (!t) return { ...EMPTY_TARGET }; + const d: TargetDraft = { ...EMPTY_TARGET, type: t.type, proxy: t.proxy || "direct" }; + switch (t.type) { + case "folder": + return { ...d, path: t.path }; + case "webdav": + return { ...d, url: t.url, user: t.user, pass: t.pass }; + case "s3": + return { + ...d, + endpoint: t.endpoint, + region: t.region, + bucket: t.bucket, + prefix: t.prefix, + access_key_id: t.access_key_id, + secret_access_key: t.secret_access_key, + }; + } +} + +/** + * 交给 core 的那一份。地址、路径、名字去掉两头的空白;**密码、密钥原样**(空格可能是它的 + * 一部分)。文件夹不经代理,代理一律写 `direct` + */ +export function targetOf(d: TargetDraft): SyncTarget { + switch (d.type) { + case "folder": + return { type: "folder", path: d.path.trim(), proxy: "direct" }; + case "webdav": + return { type: "webdav", url: d.url.trim(), user: d.user.trim(), pass: d.pass, proxy: d.proxy }; + case "s3": + return { + type: "s3", + endpoint: d.endpoint.trim(), + region: d.region.trim(), + bucket: d.bucket.trim(), + prefix: d.prefix.trim(), + access_key_id: d.access_key_id.trim(), + secret_access_key: d.secret_access_key, + proxy: d.proxy, + }; + } +} + +/** 写成 `SyncTarget` 之后一样吗(别的类型的格子不算) */ +export function sameTarget(a: TargetDraft, b: TargetDraft): boolean { + return JSON.stringify(targetOf(a)) === JSON.stringify(targetOf(b)); +} + +/** 还差哪一格(按格子从上到下的第一个)。都填好了是 null */ +export type TargetProblem = + | "path" + | "url" + | "url_scheme" + | "endpoint" + | "endpoint_scheme" + | "bucket" + | "access_key_id" + | "secret_access_key"; + +const SCHEME = /^https?:\/\/\S+$/i; + +export function targetProblem(d: TargetDraft): TargetProblem | null { + switch (d.type) { + case "folder": + return d.path.trim() ? null : "path"; + case "webdav": + if (!d.url.trim()) return "url"; + return SCHEME.test(d.url.trim()) ? null : "url_scheme"; + case "s3": + if (!d.endpoint.trim()) return "endpoint"; + if (!SCHEME.test(d.endpoint.trim())) return "endpoint_scheme"; + if (!d.bucket.trim()) return "bucket"; + if (!d.access_key_id.trim()) return "access_key_id"; + if (!d.secret_access_key) return "secret_access_key"; + return null; + } +} + +/** 同步口令至少几个字符(方案 6.8) */ +export const PASSPHRASE_MIN = 12; + +/** 新口令填得对不对:够长、两次一样。对了是 null */ +export function passphraseProblem(a: string, b: string): "short" | "mismatch" | null { + if ([...a].length < PASSPHRASE_MIN) return "short"; + return a === b ? null : "mismatch"; +} diff --git a/src/sync/useSync.ts b/src/sync/useSync.ts new file mode 100644 index 00000000..4a482476 --- /dev/null +++ b/src/sync/useSync.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useResource, type Resource } from "@/lib/resource"; +import { subscribe } from "@/lib/tauriEvent"; +import type { CoreEvent, SyncView } from "@/types"; +import { SYNC_KEYS, syncApi } from "./api"; + +/** + * 同步此刻的样子(`GET /sync`)。**core 一变就推整份过来**(`sync_status`),这里直接换上, + * 不再问一遍;配置变了(存了同步设置、别处改了不同步的上游)也重读。 + * + * 设置里的「同步」、上游页顶上的冲突提示、上游行上的「不同步」标签用的是同一份。 + */ +export function useSyncView(): Resource { + const r = useResource(SYNC_KEYS.status, syncApi.status, { events: ["config_reloaded"] }); + const { mutate } = r; + useEffect( + () => + subscribe("core-event", (e) => { + const ev = e.payload; + if (ev.kind === "sync_status") mutate(ev.view); + }), + [mutate], + ); + return r; +} + +/** 开着同步吗(`off` 以外都算开着:连不上、冲突、暂停都还是开着的) */ +export function syncOn(v: SyncView | undefined): v is SyncView { + return !!v && v.state !== "off"; +} diff --git a/src/types.ts b/src/types.ts index c321cfae..64fde3e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -294,6 +294,10 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { case "auth_changed": case "credential_expired": case "events_dropped": + // 同步的状态、网关密钥换了值、通用偏好变了(协议 50) + case "sync_status": + case "gateway_keys_changed": + case "client_prefs_changed": // 都不进请求列表。配置事件、熔断、额度、余额、凭据、代理说的都是「现在什么情况」, // 而这张表装的是「刚才发生过什么」。App 单独接。 break; diff --git a/src/ui/README.md b/src/ui/README.md index 99c84903..6bf0f3d3 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -100,7 +100,7 @@ export default function KeysPage() { - **`PageSection`**: a titled block inside the page (tw-head title, optional one-line description and actions). Sections are 32px apart; the first one after the header is 4px below it. -- Toolbar-level page actions (Config file / Version history) are the shell's; pages do +- Toolbar-level page actions (Config file / Backups) are the shell's; pages do not repeat them. ### Spacing scale diff --git a/src/upstreams/PriceSheetDialog.i18n.ts b/src/upstreams/PriceSheetDialog.i18n.ts index bcf501b5..ab614fe4 100644 --- a/src/upstreams/PriceSheetDialog.i18n.ts +++ b/src/upstreams/PriceSheetDialog.i18n.ts @@ -57,7 +57,7 @@ export const priceSheetDialogText = messages( nameTaken: (name: string) => `价目表名称「${name}」已被使用`, done: "完成", deleteTitle: (name: string) => `删除价目表「${name}」`, - deleteDesc: "删除后此价目表的倍率与模型覆盖将从配置文件中移除,可在版本历史中恢复。", + deleteDesc: "删除后此价目表的倍率与模型覆盖将从配置文件中移除,可从备份中恢复。", }, { defaultSheet: "Default price sheet", @@ -116,6 +116,6 @@ export const priceSheetDialogText = messages( nameTaken: (name: string) => `The price sheet name “${name}” is already in use`, done: "Done", deleteTitle: (name: string) => `Delete price sheet “${name}”`, - deleteDesc: "The price sheet's multiplier and model overrides will be removed from the config file. It can be restored from version history.", + deleteDesc: "The price sheet's multiplier and model overrides will be removed from the config file. It can be restored from the backups.", }, ); diff --git a/src/upstreams/ShareDialog.i18n.ts b/src/upstreams/ShareDialog.i18n.ts new file mode 100644 index 00000000..476e4980 --- /dev/null +++ b/src/upstreams/ShareDialog.i18n.ts @@ -0,0 +1,132 @@ +import { messages } from "@/i18n"; + +const s = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n} ${many}`); + +/** 分享上游的对话框(`ShareDialog.tsx`) */ +export const shareDialogText = messages( + { + title: (name: string) => `分享上游「${name}」`, + secrets: "包含密钥", + secretsWhat: "上游密钥", + secretsNote: "链接包含密钥。持有链接即可使用该密钥,请仅通过可信渠道发送。", + link: "链接", + copyWeb: "复制网页链接", + copyApp: "复制 thinkwatch:// 链接", + webHint: "网页链接在未安装 Lite 的设备上显示安装说明;# 之后的内容不发送至网站服务器。", + tooLong: (n: number) => `链接长 ${n} 个字符,超过 8192 个字符的上限,无法以链接形式分享。可改用配置包。`, + invalid: "此上游无法写成导入链接。可改用配置包。", + failed: "无法生成链接", + exportPack: "导出为配置包…", + done: "完成", + /** 底下那一句:包含什么、不包含什么 */ + includes: (items: string) => `包含:${items}。`, + excludes: (items: string) => `不包含:${items}。`, + sep: "、", + carried: { + address: "地址", + protocol: "协议", + key: "密钥", + headers: (n: number) => `${n} 个请求头`, + oauth: "OAuth 凭据", + aws: "AWS 访问密钥", + models: (n: number) => `${n} 个模型`, + scope: "只用这些模型", + specs: (n: number) => `${n} 个模型的规格`, + prices: (n: number) => `${n} 个模型的价格`, + multiplier: "价格倍率", + billing: "计费方式", + balance: "余额来源", + concurrency: "并发上限", + identity: "转发客户端身份", + login: "登录方式(接收方须登录)", + }, + left: { + secrets: "密钥", + env: "以 ${变量名} 书写的值(链接不接受该写法)", + login: "登录凭据", + awsProfile: "AWS profile", + proxy: "出站代理", + routes: "路由", + disabled: "停用状态", + tiers: "200K 以上的分档价格", + rejected: (what: string) => `${what}(链接不接受其中的字符)`, + }, + /** 链接写不进去、去掉了的那一项 */ + params: { + name: "名称", + key: "密钥", + headers: "请求头", + models: "模型列表", + models_only: "只用这些模型", + specs: "模型规格", + prices: "价目", + price_multiplier: "价格倍率", + oauth: "OAuth 凭据", + aws: "AWS 访问密钥", + other: "部分设置", + }, + }, + { + title: (name: string) => `Share upstream “${name}”`, + secrets: "Include keys", + secretsWhat: "Upstream key", + secretsNote: + "The link contains the key. Anyone holding the link can use the key; send it only through trusted channels.", + link: "Link", + copyWeb: "Copy web link", + copyApp: "Copy thinkwatch:// link", + webHint: + "On a device without Lite, the web link shows how to install it. The part after # is not sent to the website's server.", + tooLong: (n: number) => + `The link is ${n} characters long, over the 8192-character limit, so it cannot be shared as a link. Use a config pack instead.`, + invalid: "This upstream cannot be written as an import link. Use a config pack instead.", + failed: "Could not create the link", + exportPack: "Export as config pack…", + done: "Done", + includes: (items: string) => `Includes: ${items}.`, + excludes: (items: string) => `Leaves out: ${items}.`, + sep: ", ", + carried: { + address: "address", + protocol: "protocol", + key: "key", + headers: (n: number) => s(n, "header", "headers"), + oauth: "OAuth credentials", + aws: "AWS access keys", + models: (n: number) => s(n, "model", "models"), + scope: "only these models", + specs: (n: number) => `specs of ${s(n, "model", "models")}`, + prices: (n: number) => `prices of ${s(n, "model", "models")}`, + multiplier: "price multiplier", + billing: "billing", + balance: "balance source", + concurrency: "concurrency limit", + identity: "forwarded client identity", + login: "sign-in method (the receiver signs in)", + }, + left: { + secrets: "keys", + env: "values written as ${NAME} (links do not accept them)", + login: "sign-in credentials", + awsProfile: "AWS profile", + proxy: "outbound proxy", + routes: "routes", + disabled: "disabled state", + tiers: "tiered prices above 200K", + rejected: (what: string) => `${what} (characters links do not accept)`, + }, + params: { + name: "name", + key: "key", + headers: "headers", + models: "model list", + models_only: "only these models", + specs: "model specs", + prices: "prices", + price_multiplier: "price multiplier", + oauth: "OAuth credentials", + aws: "AWS access keys", + other: "some settings", + }, + }, +); diff --git a/src/upstreams/ShareDialog.tsx b/src/upstreams/ShareDialog.tsx new file mode 100644 index 00000000..67465dbb --- /dev/null +++ b/src/upstreams/ShareDialog.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useResource } from "@/lib/resource"; +import { FormRow, FormRows } from "@/settings/form"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { CopyButton } from "@/ui/copy-button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Skeleton } from "@/ui/skeleton"; +import { Switch } from "@/ui/switch"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { call } from "@/control"; +import type { ImportProposal, ProviderView, ShareLink } from "@/types"; +import { api } from "./api"; +import { modelsKey } from "./data"; +import { carried, dropParam, shareProposal, type Carried, type Left } from "./share"; +import { shareDialogText } from "./ShareDialog.i18n"; + +/** 一次分享最多去掉几项写不进链接的(再多就是别处出了错,不再试) */ +const MAX_DROPS = 8; + +/** 写成的链接,和为了写进去去掉的那几项(Rust 说的参数名) */ +interface Built { + link: ShareLink; + dropped: string[]; +} + +/** 交给 Rust 写成链接;写不进的那一项去掉再写(`dropParam`) */ +async function build(start: ImportProposal): Promise { + let proposal = start; + const dropped: string[] = []; + for (let i = 0; i <= MAX_DROPS; i++) { + const link = await invoke("share_import_link", { proposal }); + if (link.kind !== "invalid" || !link.param) return { link, dropped }; + const next = dropParam(proposal, link.param); + if (!next) return { link, dropped }; + dropped.push(link.param); + proposal = next; + } + return { link: { kind: "invalid", param: null }, dropped }; +} + +/** + * 分享上游(规格 8.3):生成导入链接(`thinkwatch://import?…` 与官网的网页形式),接收方点开后 + * 打开预填好的新建上游对话框。「包含密钥」缺省开,开着时写明持有链接即可使用该密钥。底下一句说 + * 包含什么、不包含什么。链接写不下(价目、规格太多)就只给配置包。 + * + * 「导出为配置包…」打开导出对话框,预先勾上这个上游(`onExportPack`)。 + */ +export function ShareDialog({ + provider: p, + onClose, + onExportPack, +}: { + provider: ProviderView; + onClose: () => void; + onExportPack: () => void; +}) { + const t = useText(shareDialogText); + const [secrets, setSecrets] = useState(true); + /** 这个上游有没有能写进链接的密钥一类(账号登录的上游没有):没有的话「包含密钥」不起作用,置灰 */ + const holdsSecrets = useMemo(() => { + const q = shareProposal(p, { models: null, sheet: null }, true).proposal; + return q.key != null || q.oauth != null || q.aws != null || q.headers.length > 0; + }, [p]); + const models = useResource(modelsKey(p.name), () => api.providerModels(p.name)); + const sheet = useResource(p.pricing ? `price-sheet:${p.pricing}` : null, () => call("PriceSheet", null, p.pricing!)); + /** 规格和价目表读完了(读不到就不带,链接照样生成) */ + const settled = + (models.data !== undefined || models.error !== undefined) && + (!p.pricing || sheet.data !== undefined || sheet.error !== undefined); + const [built, setBuilt] = useState<{ shared: ReturnType; out: Built } | null>(null); + const [error, setError] = useState(undefined); + + useEffect(() => { + if (!settled) return; + let alive = true; + const shared = shareProposal(p, { models: models.data?.models ?? null, sheet: sheet.data ?? null }, secrets); + setError(undefined); + build(shared.proposal).then( + (out) => alive && setBuilt({ shared, out }), + (e: unknown) => alive && setError(e), + ); + return () => { + alive = false; + }; + }, [p, secrets, settled, models.data, sheet.data]); + + const ready = built?.out.link.kind === "ready" ? built.out.link : null; + const keyCarried = ready ? ready.proposal.key != null || ready.proposal.aws != null || ready.proposal.oauth != null : false; + + return ( + !o && onClose()}> + + + {t.title(p.name)} + + + + {t.secretsNote} : undefined} + > + + + + {error !== undefined ? ( + + {errorText(error)} + + ) : !built ? ( + + ) : ready ? ( + <> +

+ {ready.web} +

+
+ navigator.clipboard.writeText(ready.web)} /> + navigator.clipboard.writeText(ready.app)} /> +
+ + ) : ( + + {built.out.link.kind === "too_long" ? t.tooLong(built.out.link.length) : t.invalid} + + )} +
+
+ + + {built && ready && ( + + + + )} + + + + +
+ ); +} + +/** 底下那一句:「包含:地址、协议、密钥、2 个模型。不包含:出站代理、路由。」 */ +function Summary({ carried: items, left, dropped }: { carried: Carried[]; left: Left[]; dropped: string[] }) { + const t = useText(shareDialogText); + const c = t.carried; + const say = (x: Carried): string => { + switch (x.kind) { + case "headers": + return c.headers(x.n); + case "models": + return c.models(x.n); + case "specs": + return c.specs(x.n); + case "prices": + return c.prices(x.n); + default: + return c[x.kind]; + } + }; + const params = t.params as Record; + const droppedNames = [ + ...new Set( + dropped.map((d) => + d.startsWith("oauth_") ? params.oauth : d.startsWith("aws_") ? params.aws : (params[d] ?? params.other), + ), + ), + ]; + const out = [ + ...left.map((l) => t.left[l]), + ...(droppedNames.length > 0 ? [t.left.rejected(droppedNames.join(t.sep))] : []), + ]; + return ( + <> + {items.length > 0 && t.includes(items.map(say).join(t.sep))} + {items.length > 0 && out.length > 0 && " "} + {out.length > 0 && t.excludes(out.join(t.sep))} + + ); +} diff --git a/src/upstreams/UpstreamTable.i18n.ts b/src/upstreams/UpstreamTable.i18n.ts index 2b8eab25..5ed556c1 100644 --- a/src/upstreams/UpstreamTable.i18n.ts +++ b/src/upstreams/UpstreamTable.i18n.ts @@ -54,6 +54,10 @@ export const upstreamTableText = messages( refreshBalance: "刷新余额", account: "额度与重置卡…", traffic: "查看流量", + share: "分享…", + /** 多选那一列 */ + select: (name: string) => `选择 ${name}`, + selectAll: "选择全部上游", enable: "启用", disable: "停用", locate: "在配置文件中定位", @@ -121,6 +125,9 @@ export const upstreamTableText = messages( refreshBalance: "Refresh balance", account: "Usage limits and reset credits…", traffic: "View traffic", + share: "Share…", + select: (name: string) => `Select ${name}`, + selectAll: "Select all upstreams", enable: "Enable", disable: "Disable", locate: "Show in config file", diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index 562bb2bd..efceb9a8 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -6,6 +6,7 @@ import { Badge } from "@/ui/badge"; import { AnimatedNumber, rowMotion, usePresentList } from "@/ui/motion"; import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; +import { Checkbox } from "@/ui/checkbox"; import { Skeleton } from "@/ui/skeleton"; import { Sparkline, sparklineWidth } from "@/ui/sparkline"; import { Spinner } from "@/ui/spinner"; @@ -43,6 +44,7 @@ import { balanceFace, balanceTip, billingLine, sourceLabel, type BalanceFace } f import { balanceText } from "./balance.i18n"; import { overviewText } from "@/overview/overview.i18n"; import { rankCost } from "@/overview/series"; +import { SyncBadges } from "@/sync/UpstreamBadges"; export interface UpstreamActions { edit: (name: string) => void; @@ -63,6 +65,8 @@ export interface UpstreamActions { toggle: (p: ProviderView) => void; locate: (name: string) => void; remove: (name: string) => void; + /** 分享这个上游:导入链接或配置包(`ShareDialog`)。不给就没有这一项 */ + share?: (name: string) => void; } /** @@ -88,6 +92,8 @@ export function UpstreamTable({ configVersion, onChanged, actions, + selected, + onSelect, }: { providers: ProviderView[]; stats: Resource; @@ -106,8 +112,24 @@ export function UpstreamTable({ /** 在这张表里写了配置(手写模型规格):外面重读概览 */ onChanged: () => void; actions: UpstreamActions; + /** + * 多选(工具条上的「导出所选…」):选中的上游名。给了 `onSelect` 才有最前面那一列勾选框; + * 勾选框不打开编辑对话框 + */ + selected?: ReadonlySet; + onSelect?: (next: ReadonlySet) => void; }) { const t = useText(upstreamTableText); + const picked = selected ?? new Set(); + const pickedHere = providers.filter((p) => picked.has(p.name)).length; + const toggle = (names: string[], on: boolean) => { + const next = new Set(picked); + for (const n of names) { + if (on) next.add(n); + else next.delete(n); + } + onSelect?.(next); + }; // 额度的「多久后重置」随时间走:重画就行,不用再问 core const now = useNow(); const shown = usePresentList(providers, (p) => p.name); @@ -133,6 +155,15 @@ export function UpstreamTable({ + {onSelect && ( + + toggle(providers.map((p) => p.name), v === true)} + /> + + )} {t.upstream} {t.models} {/* 订阅额度和按量计费是同一个问题的两种答案:还能用多少 */} @@ -153,6 +184,15 @@ export function UpstreamTable({ data-row={p.name} {...openRow(() => actions.edit(p.name), rowMotion(flash === p.name ? "enter" : presence))} > + {onSelect && ( + + toggle([p.name], v === true)} + /> + + )} a.account(p.name) }] as MenuItems) : []), { kind: "item", label: t.traffic, onSelect: () => a.traffic(p.name) }, + ...(a.share ? ([{ kind: "item", label: t.share, onSelect: () => a.share?.(p.name) }] as MenuItems) : []), { kind: "sep" }, { kind: "item", label: p.disabled ? t.enable : t.disable, onSelect: () => a.toggle(p) }, { kind: "item", label: t.locate, onSelect: () => a.locate(p.name) }, @@ -345,6 +386,8 @@ function NameCell({ {plan} )} + {/* 同步:不同步的上游、地址经同步变更(提示一次) */} + {problem && ( `导出所选 ${n} 个上游…`, + /** 空状态里「新建上游」旁边的两个(新安装时) */ + importConfig: "导入配置…", + joinSync: "加入同步…", checkAll: "检测全部", newProxy: "新建代理", autoUpdate: "自动更新", @@ -55,9 +60,9 @@ export const upstreamsPageText = messages( saved: (name: string) => `已保存 ${name}`, /** 交给删除对话框、嵌在句子中间的名词 */ what: { upstream: "上游", proxy: "代理", sheet: "价目表" }, - upstreamGone: "删除后,此上游的地址、凭据与设置将从配置文件中移除,可在版本历史中恢复。", - proxyGone: "删除后,此代理的地址与认证信息将从配置文件中移除,可在版本历史中恢复。", - sheetGone: "删除后,此价目表的倍率与模型覆盖将从配置文件中移除,可在版本历史中恢复。", + upstreamGone: "删除后,此上游的地址、凭据与设置将从配置文件中移除,可从备份中恢复。", + proxyGone: "删除后,此代理的地址与认证信息将从配置文件中移除,可从备份中恢复。", + sheetGone: "删除后,此价目表的倍率与模型覆盖将从配置文件中移除,可从备份中恢复。", }, { hero: { @@ -83,6 +88,9 @@ export const upstreamsPageText = messages( linkTest: "Connection test", speedTest: "Inference test", newUpstream: "New upstream", + exportSelected: (n: number) => (n === 1 ? "Export 1 selected upstream…" : `Export ${n} selected upstreams…`), + importConfig: "Import config…", + joinSync: "Join sync…", checkAll: "Check all", newProxy: "New proxy", autoUpdate: "Auto-update", @@ -112,10 +120,10 @@ export const upstreamsPageText = messages( saved: (name: string) => `${name} saved`, what: { upstream: "upstream", proxy: "proxy", sheet: "price sheet" }, upstreamGone: - "Deleting removes this upstream's address, credentials and settings from the config file. They can be restored from version history.", + "Deleting removes this upstream's address, credentials and settings from the config file. They can be restored from the backups.", proxyGone: - "Deleting removes this proxy's address and authentication details from the config file. They can be restored from version history.", + "Deleting removes this proxy's address and authentication details from the config file. They can be restored from the backups.", sheetGone: - "Deleting removes this price sheet's multiplier and model overrides from the config file. They can be restored from version history.", + "Deleting removes this price sheet's multiplier and model overrides from the config file. They can be restored from the backups.", }, ); diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index d4fbb9b7..09775037 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -1,5 +1,8 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ActivityIcon, NetworkIcon, PlusIcon, RefreshCwIcon, ServerIcon, ZapIcon } from "lucide-react"; +import { ExportDialog } from "@/backup/ExportDialog"; +import { useImport } from "@/backup/useImport"; +import { ShareDialog } from "./ShareDialog"; import type { ConfigFocus } from "@/configLocate"; import { invalidate, type Resource } from "@/lib/resource"; import { useWriteQueue } from "@/lib/writeQueue"; @@ -45,11 +48,12 @@ import { upstreamsPageText } from "./UpstreamsPage.i18n"; import { CostFigure } from "@/CostFigure"; import { UpstreamTable, problemsOf } from "./UpstreamTable"; import { NextClientsHint } from "@/guide/PageHints"; +import { SyncConflictBanner } from "@/sync/Notices"; import { releaseImportLink } from "@/import/link"; import { AliasesTab, AliasTabLabel, type AliasDialogMode } from "@/aliases/AliasesTab"; import { AliasDialog } from "@/aliases/AliasDialog"; import { aliasesText } from "@/aliases/aliases.i18n"; -import type { AliasInput } from "@/types"; +import type { AliasInput, ItemRef } from "@/types"; export type UpstreamTab = "upstreams" | "aliases" | "proxies" | "pricing"; @@ -73,7 +77,11 @@ type DialogState = | { kind: "sheet"; mode: PriceSheetDialogMode } | { kind: "delete-sheet"; name: string } /** 新建、编辑别名(`AliasDialog`) */ - | { kind: "alias"; mode: AliasDialogMode }; + | { kind: "alias"; mode: AliasDialogMode } + /** 分享一个上游:导入链接(`ShareDialog`) */ + | { kind: "share"; name: string } + /** 导出所选的上游,或者分享里的「导出为配置包…」(`ExportDialog`,预先勾上这几个) */ + | { kind: "export"; preselect: ItemRef[] }; /** * 上游页:上游、别名、代理、价目表四个标签。 @@ -111,6 +119,9 @@ export default function UpstreamsPage({ setTabState(next); }; const [dialog, setDialog] = useState(null); + /** 上游表里勾选的(「导出所选…」)。删掉了的上游不算 */ + const [selected, setSelected] = useState>(() => new Set()); + const importer = useImport(() => onChanged()); const { stats, since } = useUpstreamStats(); const health = useUpstreamHealth(); const pricing = usePricingStatus(configVersion); @@ -198,6 +209,10 @@ export default function UpstreamsPage({ void stats.reload(); }; + const picked = ov.providers.filter((p) => selected.has(p.name)).map((p) => p.name); + const exportProviders = (names: string[]) => + setDialog({ kind: "export", preselect: names.map((name) => ({ kind: "provider", name })) }); + async function toggle(p: ProviderView) { const saved = ov.providers.find((x) => x.name === p.name); if (!saved) return; @@ -341,6 +356,11 @@ export default function UpstreamsPage({ /> )} + {picked.length > 0 && ( + + )} + // 新安装(还没有上游):也可以把别处导出的配置导进来,或者加入已有的同步 +
+ + + +
} /> ) : ( @@ -460,6 +495,8 @@ export default function UpstreamsPage({ focus={focus} configVersion={configVersion} onChanged={changed} + selected={selected} + onSelect={setSelected} actions={{ edit: (name) => setDialog({ kind: "upstream", mode: { kind: "edit", name } }), test: (name) => setDialog({ kind: "test", name }), @@ -486,6 +523,7 @@ export default function UpstreamsPage({ toggle: (p) => void toggle(p), locate: (name) => onOpenConfigFile({ section: "providers", name }), remove: (name) => setDialog({ kind: "delete-upstream", name }), + share: (name) => setDialog({ kind: "share", name }), }} /> )} @@ -664,6 +702,21 @@ export default function UpstreamsPage({ }} /> )} + {dialog?.kind === "share" && + (() => { + const p = ov.providers.find((x) => x.name === dialog.name); + return p ? ( + setDialog(null)} + onExportPack={() => exportProviders([p.name])} + /> + ) : null; + })()} + {dialog?.kind === "export" && ( + setDialog(null)} /> + )} + {importer.dialog} {dialog?.kind === "delete-sheet" && ( setLang("zh")); + +function view(patch: Partial = {}): ProviderView { + return { + name: "relay", + base_url: "https://relay.example/v1", + key: "sk-relay-Q3xA", + auth_header: "x-api-key", + headers: [{ name: "X-Team", value: "core" }], + oauth: null, + protocol: "anthropic", + protocol_explicit: true, + forward_client_identity: false, + proxy: "direct", + on_proxy_fail: "fail", + models: [], + models_only: null, + model_source: "discovered", + model_status: "listed", + model_fetching: false, + model_count: 3, + disabled: false, + health: "ok", + billing: "per-token", + references: [], + pricing: null, + balance_setting: "auto", + balance: null, + signed_in: null, + sync_excluded: false, + ...patch, + }; +} + +const NONE = { models: null, sheet: null }; + +/** + * 接收方的新建对话框里看到的,和分享方这个上游的编辑对话框里写的,是同一份:服务类型、地址、 + * 实际的协议、认证方式与凭据、请求头、高级设置、模型、计费。代理、停用状态这些链接本来就不带 + */ +function shape(f: UpstreamForm) { + const preset = presetById(f.preset); + return { + preset: f.preset, + url: f.baseUrl.trim().replace(/\/+$/, ""), + protocol: f.protocol || preset.protocol, + auth: authModeOf(f), + key: f.key, + headers: f.headers.map((h) => [h.name, h.value]), + oauth: authModeOf(f) === "oauth" ? [f.oauthEndpoint, f.oauthRefresh, f.oauthClientId, f.oauthClientSecret] : null, + aws: authModeOf(f) === "aws-keys" ? [f.awsKeyId, f.awsSecret, f.awsToken, f.awsRegion] : null, + identity: f.forwardClientIdentity, + concurrency: f.maxConcurrent, + manual: f.manualModels, + scope: f.scope, + scopeList: f.scopeList, + billing: f.billing, + balance: f.balance ?? preset.balance ?? "auto", + name: f.name, + }; +} + +/** 分享出去、按链接新建,表单和原来的一样 */ +function roundTrips(p: ProviderView) { + const { proposal } = shareProposal(p, NONE, true); + expect(shape(formFromImport(proposal, []))).toEqual(shape(formFromView(p))); +} + +describe("分享上游:链接里的提议", () => { + it("各种服务分享出去再按链接新建,表单和原来的一样", () => { + roundTrips(view({ balance_setting: "sub2api", base_url: "https://relay.example", protocol: null, protocol_explicit: false, forward_client_identity: true, max_concurrent: 8, models: ["m-1"], models_only: ["claude-*"] })); + roundTrips(view({ name: "anthropic", base_url: "https://api.anthropic.com", headers: [], protocol: "anthropic", protocol_explicit: false })); + roundTrips(view({ name: "openai", base_url: "https://api.openai.com", headers: [], protocol: "openai-responses", protocol_explicit: true })); + roundTrips(view({ name: "zai", base_url: "https://api.z.ai/api/anthropic", headers: [], protocol: null, protocol_explicit: false })); + roundTrips(view({ name: "ollama", base_url: "http://127.0.0.1:11434", key: null, headers: [], protocol: null, protocol_explicit: false, billing: "free" })); + roundTrips(view({ name: "lan-ollama", base_url: "http://192.168.1.20:11434", key: null, headers: [], protocol: null, protocol_explicit: false, billing: "per-token" })); + roundTrips(view({ balance_setting: "newapi", protocol: "openai-chat" })); + roundTrips(view({ balance_setting: "openrouter", protocol: null, protocol_explicit: false })); + roundTrips( + view({ + key: null, + headers: [], + oauth: { endpoint: "https://auth.example/token", refresh: "rt-1", client_id: "cid", client_secret: "cs" }, + }), + ); + roundTrips( + view({ + name: "bedrock", + base_url: "https://bedrock-runtime.ap-northeast-1.amazonaws.com", + key: null, + headers: [], + protocol: "bedrock", + protocol_explicit: false, + aws: { access_key_id: "AKIAIOSFODNN7EXAMPLE", secret_access_key: "secret", session_token: null, region: null }, + }), + ); + roundTrips( + view({ + name: "bedrock-vpc", + base_url: "https://vpce-1.bedrock-runtime.us-west-2.vpce.amazonaws.com", + key: null, + headers: [], + protocol: "bedrock", + protocol_explicit: true, + aws: { access_key_id: "AKIAIOSFODNN7EXAMPLE", secret_access_key: "secret", session_token: "tok", region: "us-west-2" }, + }), + ); + }); + + it("地址不是那种服务的标准写法:按「自定义」,地址和协议照写", () => { + const p = view({ name: "ds", base_url: "https://api.deepseek.com", protocol: "openai-chat", headers: [] }); + const { proposal } = shareProposal(p, NONE, true); + expect(proposal.service).toBe("custom"); + expect(proposal.base_url).toBe("https://api.deepseek.com"); + expect(proposal.protocol).toBe("openai-chat"); + }); + + it("服务自己定的不写:官方地址、固定的协议、缺省的计费方式", () => { + const { proposal } = shareProposal( + view({ name: "anthropic", base_url: "https://api.anthropic.com/", headers: [], protocol: "anthropic" }), + NONE, + true, + ); + expect(proposal).toMatchObject({ service: "anthropic", base_url: null, protocol: null, billing: null, key: "sk-relay-Q3xA" }); + }); + + it("不含密钥时密钥、请求头、OAuth 都不写,说明不包含密钥", () => { + const { proposal, left } = shareProposal(view(), NONE, false); + expect(proposal.key).toBeNull(); + expect(proposal.headers).toEqual([]); + expect(left).toContain("secrets"); + const o = shareProposal( + view({ key: null, headers: [], oauth: { endpoint: "https://a.example/t", refresh: "rt", client_id: null, client_secret: null } }), + NONE, + false, + ); + expect(o.proposal.auth).toBe("key"); + expect(o.proposal.oauth).toBeNull(); + }); + + it("`${变量名}` 写的值不进链接,说明原因", () => { + const { proposal, left } = shareProposal(view({ key: "${RELAY_KEY}" }), NONE, true); + expect(proposal.key).toBeNull(); + expect(left).toContain("env"); + }); + + it("账号登录的上游只写服务和登录方式;出站代理、路由、停用状态写明不包含", () => { + const chatgpt = shareProposal( + view({ name: "chatgpt", base_url: "https://chatgpt.com/backend-api/codex", protocol: "chatgpt", key: null, headers: [{ name: "ChatGPT-Account-Id", value: "acc" }], proxy: "clash", disabled: true, references: [{ kind: "group", group: "main" }] }), + NONE, + true, + ); + expect(chatgpt.proposal).toMatchObject({ service: "openai", auth: "account", base_url: null, key: null, headers: [] }); + expect(chatgpt.left).toEqual(expect.arrayContaining(["login", "proxy", "routes", "disabled"])); + const zai = shareProposal(view({ name: "zai", base_url: "https://api.z.ai/api/anthropic", signed_in: "zai", headers: [] }), NONE, true); + expect(zai.proposal).toMatchObject({ service: "zai", auth: "account", site: "zai", key: null }); + expect(carried(zai.proposal)).toEqual([{ kind: "login" }]); + }); + + it("手写的规格、自定义价目表跟着走;分档价写不进,说明不包含", () => { + const models = [ + { id: "m-1", context_window: 200000, context_window_source: "manual", max_output_tokens: 8192, max_output_tokens_source: "price_table" }, + { id: "m-2", context_window: 128000, context_window_source: "price_table" }, + ] as ModelRow[]; + const sheet: PriceSheetInput = { + name: "relay", + multiplier: 1.2, + models: { "m-1": { input: 3, output: 15, cache_read: 0.3, cache_write_5m: 3.75, cache_write_1h: 6, input_above_200k: 6 } }, + }; + const { proposal, left } = shareProposal(view({ pricing: "relay" }), { models, sheet }, true); + expect(proposal.specs).toEqual([ + { model: "m-1", context_window: 200000, max_output_tokens: null, reasoning: null, image_input: null }, + ]); + expect(proposal.price_sheet).toEqual({ + multiplier: 1.2, + prices: [{ model: "m-1", input: 3, output: 15, cache_read: 0.3, cache_write_5m: 3.75, cache_write_1h: 6 }], + }); + expect(left).toContain("tiers"); + expect(carried(proposal).map((c) => c.kind)).toEqual( + expect.arrayContaining(["address", "key", "headers", "specs", "prices", "multiplier"]), + ); + }); + + it("写不进链接的那一项去掉;地址、服务类型去不掉", () => { + const { proposal } = shareProposal(view({ models: ["m 1"] }), NONE, true); + expect(dropParam(proposal, "models")?.models).toEqual([]); + expect(dropParam(proposal, "oauth_refresh")?.auth).toBe("key"); + expect(dropParam(proposal, "prices")?.price_sheet).toBeNull(); + expect(dropParam(proposal, "url")).toBeNull(); + expect(dropParam(proposal, "service")).toBeNull(); + }); +}); diff --git a/src/upstreams/share.ts b/src/upstreams/share.ts new file mode 100644 index 00000000..3913aefe --- /dev/null +++ b/src/upstreams/share.ts @@ -0,0 +1,320 @@ +/** + * 分享一个上游(规格 8.2):按它的配置拼一份导入链接的提议(`ImportProposal`),交给 Rust 写成 + * 链接(`share_import_link`,生成与解析同在 `import_link.rs`)。 + * + * **拼的是接收方的新建对话框要的那一份**:服务类型、认证方式、站点照这个上游认出来的填(认不出、 + * 或者它的地址、协议不是那种服务的写法,就按「自定义」),和那种服务新建时缺省一样的不写。 + * 密钥、请求头、AWS 凭据、OAuth 凭据由「包含密钥」决定;`${变量名}`、出站代理、别名、路由、 + * 停用状态、AWS profile、账号登录的凭据一律不写,写明不包含什么。 + * + * 写不进链接的值(链接不收的字符)由 Rust 指出是哪一项,这里去掉那一项再写一次(`dropParam`)。 + */ +import type { + ImportAuth, + ImportProposal, + ImportService, + ImportSheet, + ImportSpec, + ModelRow, + PriceSheetInput, + Protocol, + ProviderView, +} from "@/types"; +import { bedrockRegionOf } from "./labels"; +import { presetById, zaiSiteOf, type Preset } from "./presets"; +import { formFromView, type UpstreamForm } from "./upstreamForm"; + +/** 链接里的四种协议(`import_link::LINK_PROTOCOLS`) */ +const LINK_PROTOCOLS: readonly Protocol[] = ["anthropic", "openai-chat", "openai-responses", "gemini"]; + +/** 地址由这种服务自己定、链接给的得一模一样的那几种(`Address::Own`) */ +const OWN_ADDRESS = new Set(["anthropic", "openai", "gemini", "deepseek", "openrouter"]); + +/** 分享时要从别处取的两样:这家的模型(手写的规格)、它用的自定义价目表 */ +export interface ShareExtra { + models: readonly ModelRow[] | null; + sheet: PriceSheetInput | null; +} + +/** 有、却没写进链接的东西(对话框底下「不包含」那一句) */ +export type Left = + | "secrets" + | "env" + | "login" + | "awsProfile" + | "proxy" + | "routes" + | "disabled" + | "tiers"; + +export interface Shared { + proposal: ImportProposal; + left: Left[]; +} + +const trimUrl = (u: string) => u.trim().replace(/\/+$/, ""); +const isEnv = (v: string | null | undefined) => !!v && v.includes("${"); + +/** 这种服务收不收这个上游的地址、协议、认证方式;不收就按「自定义」 */ +function fits(preset: Preset, f: UpstreamForm, auth: ImportAuth): boolean { + if (!(preset.auth as string[]).includes(auth)) return false; + const url = trimUrl(f.baseUrl); + if (OWN_ADDRESS.has(preset.id) && url !== preset.baseUrl) return false; + if (preset.id === "zai" && auth !== "account" && zaiSiteOf(url) === null) return false; + if (preset.key === "none" && f.key.trim() !== "") return false; + const proto = f.protocol; + if (proto === "") return true; + const choices = preset.protocols; + if (choices === "all") return LINK_PROTOCOLS.includes(proto); + if (Array.isArray(choices)) return choices.includes(proto); + return proto === preset.protocol; +} + +/** 这家的模型里手写过的规格(来源是 `manual` 的那几格) */ +function manualSpecs(rows: readonly ModelRow[] | null): ImportSpec[] { + const out: ImportSpec[] = []; + for (const r of rows ?? []) { + const spec: ImportSpec = { + model: r.id, + context_window: r.context_window_source === "manual" ? (r.context_window ?? null) : null, + max_output_tokens: r.max_output_tokens_source === "manual" ? (r.max_output_tokens ?? null) : null, + reasoning: r.reasoning_source === "manual" ? (r.reasoning ?? null) : null, + image_input: r.image_input_source === "manual" ? (r.image_input ?? null) : null, + }; + if (spec.context_window != null || spec.max_output_tokens != null || spec.reasoning != null || spec.image_input != null) { + out.push(spec); + } + } + return out; +} + +/** 自定义价目表写成链接里的新价目表。分档价(200K 以上)链接写不了 */ +function sheetOf(s: PriceSheetInput | null): { sheet: ImportSheet | null; tiers: boolean } { + if (!s) return { sheet: null, tiers: false }; + const entries = Object.entries(s.models); + const tiers = entries.some(([, p]) => p.input_above_200k != null || p.output_above_200k != null); + const prices = entries.map(([model, p]) => ({ + model, + input: p.input, + output: p.output, + cache_read: p.cache_read, + cache_write_5m: p.cache_write_5m, + cache_write_1h: p.cache_write_1h, + })); + if (s.multiplier === 1 && prices.length === 0) return { sheet: null, tiers }; + return { sheet: { multiplier: s.multiplier, prices }, tiers }; +} + +/** 按一个上游的配置拼导入链接的提议。`secrets`:「包含密钥」 */ +export function shareProposal(p: ProviderView, extra: ShareExtra, secrets: boolean): Shared { + const f = formFromView(p); + const left = new Set(); + const chatgpt = p.protocol === "chatgpt"; + let auth: ImportAuth; + if (chatgpt || f.authMode === "account") { + auth = "account"; + left.add("login"); + } else if (f.authMode === "aws-profile") { + auth = "key"; + left.add("awsProfile"); + } else { + auth = f.authMode; + } + let service = (chatgpt ? "openai" : f.preset) as ImportService; + if (!chatgpt && auth !== "account" && !fits(presetById(service), f, auth)) service = "custom"; + if (service === "custom" && (auth === "aws-keys" || auth === "account")) auth = "key"; + const preset = presetById(service); + const url = trimUrl(f.baseUrl); + + // 凭据。不含密钥、写的是 `${变量名}`:不写,认证方式退回 API 密钥(接收方自己填) + let key: string | null = null; + let oauth: ImportProposal["oauth"] = null; + let aws: ImportProposal["aws"] = null; + if (auth === "key" && preset.key !== "none" && f.key.trim() !== "") { + if (!secrets) left.add("secrets"); + else if (isEnv(f.key)) left.add("env"); + else key = f.key.trim(); + } else if (auth === "oauth") { + const fields = [f.oauthEndpoint, f.oauthRefresh, f.oauthClientId, f.oauthClientSecret]; + if (!secrets) left.add("secrets"); + else if (fields.some(isEnv)) left.add("env"); + else { + oauth = { + endpoint: f.oauthEndpoint.trim(), + refresh: f.oauthRefresh.trim(), + client_id: f.oauthClientId.trim() || null, + client_secret: f.oauthClientSecret.trim() || null, + }; + } + if (!oauth) auth = "key"; + } else if (auth === "aws-keys") { + const fields = [f.awsKeyId, f.awsSecret, f.awsToken]; + if (!secrets) left.add("secrets"); + else if (fields.some(isEnv)) left.add("env"); + else { + aws = { + access_key_id: f.awsKeyId.trim(), + secret_access_key: f.awsSecret.trim(), + session_token: f.awsToken.trim() || null, + }; + } + if (!aws) auth = "key"; + } + + // 地址:服务自己定的不写(Z.ai / BigModel 写站点,标准 Bedrock 地址写区域,本机缺省的 Ollama 不写) + const standardRegion = service === "bedrock" ? bedrockRegionOf(url) : null; + let base_url: string | null = null; + if (auth !== "account" && !OWN_ADDRESS.has(service) && service !== "zai") { + if (service === "bedrock") base_url = standardRegion ? null : url; + else if (service === "ollama") base_url = url === preset.baseUrl ? null : url; + else base_url = url; + } + const site = + service !== "zai" ? null : auth === "account" ? (p.signed_in ?? f.zaiFamily) : (zaiSiteOf(url) ?? f.zaiFamily); + const region = + service !== "bedrock" + ? null + : (standardRegion ?? (auth === "aws-keys" ? f.awsRegion.trim() || null : null)); + + // 协议:这种服务定了的不写,和它缺省的一样不写 + const protocol: Protocol | null = + auth === "account" || + f.protocol === "" || + f.protocol === preset.protocol || + !preset.protocols || + !LINK_PROTOCOLS.includes(f.protocol) + ? null + : f.protocol; + + const headers = f.headers + .map((h) => ({ name: h.name.trim(), value: h.value.trim() })) + .filter((h) => h.name !== "" && h.value !== ""); + if (headers.length > 0 && !secrets) left.add("secrets"); + if (headers.length > 0 && secrets && headers.some((h) => isEnv(h.value))) left.add("env"); + const shareHeaders = secrets && auth !== "account" && !headers.some((h) => isEnv(h.value)) ? headers : []; + + const billing = p.billing === (preset.billing ?? "per-token") ? null : p.billing; + const perToken = (billing ?? preset.billing ?? "per-token") === "per-token"; + const { sheet, tiers } = perToken ? sheetOf(extra.sheet) : { sheet: null, tiers: false }; + if (tiers) left.add("tiers"); + + if (p.proxy !== "direct") left.add("proxy"); + if (p.references.length > 0) left.add("routes"); + if (p.disabled) left.add("disabled"); + + return { + proposal: { + service, + auth, + site, + name: p.name, + base_url, + protocol, + key, + oauth, + region, + aws, + headers: shareHeaders, + client_identity: auth !== "account" && f.forwardClientIdentity && preset.clientIdentity === true, + concurrency: auth === "account" ? null : (p.max_concurrent ?? null), + balance: service === "custom" && p.balance_setting !== "auto" ? p.balance_setting : null, + models: p.models, + models_only: p.models_only && p.models_only.length > 0 ? p.models_only : null, + specs: manualSpecs(extra.models), + billing, + price_sheet: sheet, + }, + left: [...left], + }; +} + +/** + * 去掉链接写不进的那一项(Rust 说的参数名),再试一次。地址、服务类型去不掉(去掉就不是这个 + * 上游了):交回 null + */ +export function dropParam(p: ImportProposal, param: string): ImportProposal | null { + switch (param) { + case "name": + return { ...p, name: null }; + case "protocol": + return { ...p, protocol: null }; + case "site": + return { ...p, site: null }; + case "key": + return { ...p, key: null }; + case "auth": + case "oauth_endpoint": + case "oauth_refresh": + case "oauth_client_id": + case "oauth_client_secret": + case "aws_access_key_id": + case "aws_secret_access_key": + case "aws_session_token": + return p.auth === "account" ? null : { ...p, auth: "key", oauth: null, aws: null }; + case "region": + return { ...p, region: null }; + case "headers": + return { ...p, headers: [] }; + case "client_identity": + return { ...p, client_identity: false }; + case "concurrency": + return { ...p, concurrency: null }; + case "balance": + return { ...p, balance: null }; + case "models": + return { ...p, models: [] }; + case "models_only": + return { ...p, models_only: null }; + case "specs": + return { ...p, specs: [] }; + case "billing": + return { ...p, billing: null }; + case "price_multiplier": + case "prices": + return { ...p, price_sheet: null }; + default: + return null; + } +} + +/** 链接里写了什么(对话框底下「包含」那一句的几项),按接收方看到的那一份算 */ +export type Carried = + | { kind: "address" } + | { kind: "protocol" } + | { kind: "key" } + | { kind: "headers"; n: number } + | { kind: "oauth" } + | { kind: "aws" } + | { kind: "models"; n: number } + | { kind: "scope" } + | { kind: "specs"; n: number } + | { kind: "prices"; n: number } + | { kind: "multiplier" } + | { kind: "billing" } + | { kind: "balance" } + | { kind: "concurrency" } + | { kind: "identity" } + | { kind: "login" }; + +export function carried(p: ImportProposal): Carried[] { + const out: Carried[] = []; + const preset = presetById(p.service); + if (p.auth === "account") out.push({ kind: "login" }); + // 地址:写在链接里的,或者这种服务自己的(接收方的对话框照样填上) + if (p.auth !== "account") out.push({ kind: "address" }); + if (p.protocol) out.push({ kind: "protocol" }); + if (p.key) out.push({ kind: "key" }); + if (p.headers.length > 0) out.push({ kind: "headers", n: p.headers.length }); + if (p.oauth) out.push({ kind: "oauth" }); + if (p.aws) out.push({ kind: "aws" }); + if (p.models.length > 0) out.push({ kind: "models", n: p.models.length }); + if (p.models_only) out.push({ kind: "scope" }); + if (p.specs.length > 0) out.push({ kind: "specs", n: p.specs.length }); + if (p.price_sheet && p.price_sheet.prices.length > 0) out.push({ kind: "prices", n: p.price_sheet.prices.length }); + if (p.price_sheet && p.price_sheet.multiplier !== 1) out.push({ kind: "multiplier" }); + if (p.billing) out.push({ kind: "billing" }); + if (p.balance || preset.balance) out.push({ kind: "balance" }); + if (p.concurrency != null) out.push({ kind: "concurrency" }); + if (p.client_identity) out.push({ kind: "identity" }); + return out; +} diff --git a/src/upstreams/upstreamForm.i18n.ts b/src/upstreams/upstreamForm.i18n.ts index a1740dd3..33ec76b4 100644 --- a/src/upstreams/upstreamForm.i18n.ts +++ b/src/upstreams/upstreamForm.i18n.ts @@ -5,6 +5,8 @@ export const upstreamFormText = messages( name: "填写名称", nameTaken: (name: string) => `名称「${name}」已被其他上游使用`, baseUrl: "填写接口地址", + baseUrlBackslash: "接口地址中不能含反斜杠,请使用正斜杠 /", + baseUrlUserinfo: "接口地址的主机前不能带用户名或密码,凭据请填在密钥或请求头中", key: "填写 API 密钥", signIn: "登录后继续", oauth: "填写 Refresh Token 与 Token 端点", @@ -25,6 +27,8 @@ export const upstreamFormText = messages( name: "Enter a name", nameTaken: (name: string) => `The name “${name}” is already used by another upstream`, baseUrl: "Enter the base URL", + baseUrlBackslash: "The base URL cannot contain a backslash; use forward slashes", + baseUrlUserinfo: "The base URL cannot have a user name or password before the host; put credentials in the key or headers", key: "Enter the API key", signIn: "Sign in to continue", oauth: "Enter the refresh token and token endpoint", diff --git a/src/upstreams/upstreamForm.test.ts b/src/upstreams/upstreamForm.test.ts index 961f7f0f..bf1f7cde 100644 --- a/src/upstreams/upstreamForm.test.ts +++ b/src/upstreams/upstreamForm.test.ts @@ -62,6 +62,7 @@ function view(patch: Partial = {}): ProviderView { balance_setting: "auto", balance: null, signed_in: null, + sync_excluded: false, ...patch, }; } @@ -129,6 +130,20 @@ describe("编辑时回填原样", () => { } }); + it("地址里有反斜杠、主机前带用户名或密码的,和 core 一样不收", () => { + const f = formFromView(view()); + expect(connectionMissing({ ...f, baseUrl: "https://relay.example\\v1" }, "relay", ["relay"])).toBe( + "接口地址中不能含反斜杠,请使用正斜杠 /", + ); + for (const bad of ["https://user:pw@relay.example/v1", "https://user@relay.example"]) { + expect(connectionMissing({ ...f, baseUrl: bad }, "relay", ["relay"])).toBe( + "接口地址的主机前不能带用户名或密码,凭据请填在密钥或请求头中", + ); + } + // 路径、查询里的 @ 不算 + expect(connectionMissing({ ...f, baseUrl: "https://relay.example/v1/@team" }, "relay", ["relay"])).toBeNull(); + }); + it("清空密钥就是不要密钥;清空请求头的值要补上", () => { const f = formFromView(view()); expect(toInput({ ...f, key: " " }).key).toBeUndefined(); diff --git a/src/upstreams/upstreamForm.ts b/src/upstreams/upstreamForm.ts index 91e27800..12d2dd02 100644 --- a/src/upstreams/upstreamForm.ts +++ b/src/upstreams/upstreamForm.ts @@ -813,6 +813,9 @@ export function connectionMissing( if (name === "") return t.name; if (name !== original && taken.includes(name)) return t.nameTaken(name); if (f.baseUrl.trim() === "") return t.baseUrl; + // core 不收的两种写法(`config.base_url_backslash`、`config.base_url_userinfo`):在这里就说 + if (f.baseUrl.includes("\\")) return t.baseUrlBackslash; + if (/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(f.baseUrl.trim())) return t.baseUrlUserinfo; // 新建时才要求:已保存的上游可能用自己写的请求头鉴权,编辑它不该被一个空的密钥栏拦住 if (!f.saved && mode === "key" && presetById(f.preset).key === "required" && f.key.trim() === "") return t.key; if ( From f209a532704739331518afc99e30ca50c09f46b2 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:13:09 +0800 Subject: [PATCH 2/4] 2026.10.25: version and release notes Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.25.md | 27 +++++++++++++++++++++++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 release-notes/2026.10.25.md diff --git a/package.json b/package.json index 0e58846e..d549d23e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.24", + "version": "2026.10.25", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.25.md b/release-notes/2026.10.25.md new file mode 100644 index 00000000..1343fde1 --- /dev/null +++ b/release-notes/2026.10.25.md @@ -0,0 +1,27 @@ +**Upgrade notes:** +- The bundled core is now 0.78.0 and the control-plane protocol is 50. A remote server running an older core must be upgraded to 0.78.0 to stay connected. +- Nothing is cleared; the request history, stored payloads and settings carry over. +- Backups replace the version history. The old history entries are removed when the first backup is kept. +- An upstream address that contains a backslash, or a user name or password before the host, is no longer accepted. A configuration with one opens in safe mode; put the credential in the key or a header instead. + +**Sync and backup (Settings > Sync and backup):** +- Export the whole configuration, or chosen items, to a `.twconfig` file. Items they depend on come along. Keys are included by default and can be left out; the file can be encrypted with a passphrase. +- Import adds what is new. An item with the same name and different content is a conflict, and each conflict is overwritten, imported under a new name, or skipped. Imported plugins start disabled, and ChatGPT and other OAuth logins are signed in again on each device. Packs from older releases are repaired, and the preview lists every fix. +- Every configuration change keeps a backup, plugins included, and the last 10 are kept. Restore a whole version or only the items you need, such as one deleted upstream. A restore can be undone. Backups can also be restored from safe mode. +- Sync keeps one configuration across your Macs, PCs and remote cores through a folder (iCloud Drive, Dropbox, OneDrive, Syncthing, a network share), a WebDAV server or S3-compatible storage. Changes made on different devices merge item by item; only the same setting changed two ways is a conflict, and it is resolved once on any device. Encryption with a sync passphrase is on by default. +- Upstreams can be kept on this device only, together with the routes and aliases that use them. Gateway keys used by clients this device has taken over are never removed by sync, and clients are updated when a key changes. +- The sync location also keeps the latest and the previous version as an off-site backup. + +**Upstream sharing:** +- Share... in an upstream's menu creates an import link, with the key included by default and a warning that anyone with the link can use it. Select upstreams to export them as a file. +- A new installation offers Import config... and Join sync... on the empty Upstreams page. + +**Security log:** +- Filter the log by type from the Type column header. Paging, per-day counts and export follow the filter. + +**Menu bar:** +- The speed line shows 0 t/s instead of a dash when nothing is generating. +- The sweeping glow runs on the TW mark only. In the numbers-only style, a dot to the left of the numbers breathes while requests run. + +**Tool-call checks:** +- Streamed tool calls are no longer judged on arguments that are still arriving, so a host cut off mid-stream is not mistaken for an external one. Hits that are only recorded are logged with the whole call. `-T` counts as uploading a file only inside a curl command. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c4f76dce..1a56ec49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4833,7 +4833,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.24" +version = "2026.10.25" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5a19242e..6964105d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.24" +version = "2026.10.25" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9dc57b28..6c623a67 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.24", + "version": "2026.10.25", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", From cf590dd11a444193f454fc4ca2b3dc5df0f238a7 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:20:43 +0800 Subject: [PATCH 3/4] Sync and backup: consistent labels for sign-in, repair and the settings section Co-Authored-By: Claude Opus 5.5 --- src/backup/backup.i18n.ts | 6 +++--- src/sync/MergeRows.i18n.ts | 4 ++-- src/sync/UpstreamBadges.i18n.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/backup/backup.i18n.ts b/src/backup/backup.i18n.ts index b7693836..487ffb21 100644 --- a/src/backup/backup.i18n.ts +++ b/src/backup/backup.i18n.ts @@ -262,7 +262,7 @@ export const backupText = messages( syncFrom: (device: string) => `Synced from ${device}`, import: "Import", restore: "Restore", - repair: "One-click repair", + repair: "Repair", export: "Export", share: "Share", rotation: "Credential rotation", @@ -403,8 +403,8 @@ export const backupText = messages( }, repaired: (n: number) => n === 1 - ? "1 entry written by an older version was fixed the way one-click repair fixes it:" - : `${n} entries written by an older version were fixed the way one-click repair fixes them:`, + ? "1 entry written by an older version was fixed by the rules Repair uses:" + : `${n} entries written by an older version were fixed by the rules Repair uses:`, stale: "The config changed after the preview. Preview again.", previewAgain: "Preview again", diff --git a/src/sync/MergeRows.i18n.ts b/src/sync/MergeRows.i18n.ts index 0e9a11ba..d16139a6 100644 --- a/src/sync/MergeRows.i18n.ts +++ b/src/sync/MergeRows.i18n.ts @@ -10,7 +10,7 @@ export const mergeRowsText = messages( action: "处理", statusOf: { new: "新增", same: "相同", conflict: "冲突", error: "错误" }, differs: (units: string) => `${units}不同`, - flag: { missing_key: "缺少密钥", needs_login: "需要登录", plugin_disabled: "导入后为停用状态" }, + flag: { missing_key: "缺少密钥", needs_login: "需要登录", plugin_disabled: "插件停用" }, choice: { overwrite: "覆盖", rename: "改名导入", skip: "跳过" }, importIt: "导入", cannot: "不可导入", @@ -35,7 +35,7 @@ export const mergeRowsText = messages( action: "Action", statusOf: { new: "New", same: "Same", conflict: "Conflict", error: "Error" }, differs: (units: string) => `${units} differ`, - flag: { missing_key: "Key missing", needs_login: "Sign-in needed", plugin_disabled: "Off after import" }, + flag: { missing_key: "Key missing", needs_login: "Sign-in required", plugin_disabled: "Plugin disabled" }, choice: { overwrite: "Overwrite", rename: "Rename", skip: "Skip" }, importIt: "Import", cannot: "Cannot import", diff --git a/src/sync/UpstreamBadges.i18n.ts b/src/sync/UpstreamBadges.i18n.ts index 4567f03d..5209063a 100644 --- a/src/sync/UpstreamBadges.i18n.ts +++ b/src/sync/UpstreamBadges.i18n.ts @@ -15,7 +15,7 @@ export const upstreamBadgesText = messages( { notSynced: "Not synced", notSyncedTip: - "This upstream is left out of sync: it is not uploaded and not replaced by an upstream of the same name from another device. Change this in Settings › Sync & backup.", + "This upstream is left out of sync: it is not uploaded and not replaced by an upstream of the same name from another device. Change this in Settings › Sync and backup.", urlChanged: "URL changed by sync", urlChangedTitle: "URL changed by sync", urlChangedBody: (from: string, to: string) => `The URL changed from ${from} to ${to}.`, From 504c212bffdd95574740bd2081aa722a683e7acc Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:47:32 +0800 Subject: [PATCH 4/4] Pin core v0.78.0 Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 30 +++++++++++++++--------------- src-tauri/Cargo.toml | 12 ++++++------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a56ec49..fa77ad61 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -985,7 +985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2661,7 +2661,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3626,7 +3626,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3682,7 +3682,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4810,7 +4810,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5339,7 +5339,7 @@ dependencies = [ [[package]] name = "tw-api" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "serde", "serde_json", @@ -5351,7 +5351,7 @@ dependencies = [ [[package]] name = "tw-dialect" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "memchr", "serde", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "tw-guard" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5380,7 +5380,7 @@ dependencies = [ [[package]] name = "tw-link" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "serde", "serde_json", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "tw-types" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "serde", "ts-rs", @@ -5416,7 +5416,7 @@ dependencies = [ [[package]] name = "tw-watch" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "libc", "notify", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "tw-yaml" version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=bcc90042d6edc63df4370074a62e4ce854d7334a#bcc90042d6edc63df4370074a62e4ce854d7334a" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5891,7 +5891,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6964105d..713ee2b2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "bcc90042d6edc63df4370074a62e4ce854d7334a" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } [lib] name = "thinkwatch_lite_lib"