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
}>
+
+ {t.notSynced}
+
+
+ )}
+ {showChange && change && (
+
+
+
+
+ {t.urlChanged}
+
+
+ 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}
+
+
+ {t.markRead}
+
+
+
+
+
+ )}
+ >
+ );
+}
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 (
+
+ );
+}
+
+/** 底下那一句:「包含:地址、协议、密钥、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 && (
+ exportProviders(picked)}>
+ {t.exportSelected(picked.length)}
+
+ )}
{t.newUpstream}
@@ -436,6 +456,8 @@ export default function UpstreamsPage({
>
{stats.error !== undefined ? errorText(stats.error) : null}
+ {/* 同步有冲突:和设置里同一条提示,可以忽略 */}
+
{providers.length === 0 ? (
-
- {t.newUpstream}
-
+ // 新安装(还没有上游):也可以把别处导出的配置导进来,或者加入已有的同步
+
+
+
+ {t.newUpstream}
+
+
+ {t.importConfig}
+
+
nav.open("settings", { section: "sync-backup", action: "join-sync" })}
+ >
+ {t.joinSync}
+
+
}
/>
) : (
@@ -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"