From 44166e5458c2a6e88bb72b71075d4cd509adf8b8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:37:39 +0800 Subject: [PATCH 1/2] Connections: switch between the local core and remote cores (remote transport stubbed) The connection-management shell from the remote-core plan (P5, part 1): - Connection list persisted by the app itself (connections.json in the data dir): built-in, undeletable "This Mac" plus remote entries. Keys live in the OS keychain (keyring crate), never in the file and never sent back to the UI. - One connector seam (connection::connector): test/connect returns ServerInfo or a ConnectError (unreachable / closed / wrong key / version mismatch / timeout). The remote handshake is a single stub, connector::handshake, that P2 fills in with the Noise handshake; until then it answers "not available yet" after the TCP connect. - ControlClient is retargetable; every clone follows the current connection. In remote mode the local core stops once in-flight requests finish and the watchdog does not restart it; switching back starts it. Never falls back to local on its own. - Launch screen waits at most ~8 s, then the window opens in a "not connected" state (data pages disabled, Settings usable, retry with backoff, edit, switch to this Mac). Version-mismatch page. Mid-session disconnect shows a banner, makes the content read-only, raises one notification and reconciles through the existing core-state/events_dropped path. - Launch guard: two launches in a row that never got ready, or holding Option/Alt at launch, show a small connection picker first. - Sidebar switcher, Settings > Connection section, add/edit dialog with test / save / save and switch, switch confirmation with the "also point these clients at the server" option (clients::retarget_adopted_clients is a TODO hook until adoption moves into lite). - Menubar/tray "Connection" submenu on all platforms. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 477 +++++++++++++- src-tauri/Cargo.toml | 7 + src-tauri/build.rs | 29 + src-tauri/capabilities/picker.json | 13 + src-tauri/src/clients.rs | 47 ++ src-tauri/src/connection/connector.rs | 301 +++++++++ src-tauri/src/connection/keychain.rs | 50 ++ src-tauri/src/connection/launch.rs | 101 +++ src-tauri/src/connection/mod.rs | 889 ++++++++++++++++++++++++++ src-tauri/src/connection/store.rs | 222 +++++++ src-tauri/src/control.rs | 79 ++- src-tauri/src/gateway.rs | 197 ++++-- src-tauri/src/lib.rs | 66 +- src-tauri/src/menubar/macos.rs | 3 + src-tauri/src/menubar/mod.rs | 39 ++ src-tauri/src/menubar/model.rs | 78 ++- src-tauri/src/menubar/tray.rs | 12 + src-tauri/src/notices/rules.rs | 30 +- src-tauri/src/supervisor/mod.rs | 6 + src-tauri/src/updater.rs | 60 +- src-tauri/src/window.rs | 40 +- src/App.tsx | 200 ++++-- src/Config.tsx | 4 + src/connection/ConnectionProvider.tsx | 121 ++++ src/connection/ConnectionsSection.tsx | 181 ++++++ src/connection/PickerWindow.tsx | 150 +++++ src/connection/ProfileDialog.tsx | 292 +++++++++ src/connection/SwitchDialog.tsx | 187 ++++++ src/connection/Switcher.tsx | 156 +++++ src/connection/Unlinked.tsx | 164 +++++ src/connection/api.ts | 116 ++++ src/connection/connection.i18n.tsx | 298 +++++++++ src/connection/describe.test.tsx | 44 ++ src/connection/describe.tsx | 57 ++ src/connection/useConnection.ts | 31 + src/launch/LaunchScreen.tsx | 14 +- src/main.tsx | 4 +- src/ui/icons.tsx | 2 + 38 files changed, 4553 insertions(+), 214 deletions(-) create mode 100644 src-tauri/capabilities/picker.json create mode 100644 src-tauri/src/connection/connector.rs create mode 100644 src-tauri/src/connection/keychain.rs create mode 100644 src-tauri/src/connection/launch.rs create mode 100644 src-tauri/src/connection/mod.rs create mode 100644 src-tauri/src/connection/store.rs create mode 100644 src/connection/ConnectionProvider.tsx create mode 100644 src/connection/ConnectionsSection.tsx create mode 100644 src/connection/PickerWindow.tsx create mode 100644 src/connection/ProfileDialog.tsx create mode 100644 src/connection/SwitchDialog.tsx create mode 100644 src/connection/Switcher.tsx create mode 100644 src/connection/Unlinked.tsx create mode 100644 src/connection/api.ts create mode 100644 src/connection/connection.i18n.tsx create mode 100644 src/connection/describe.test.tsx create mode 100644 src/connection/describe.tsx create mode 100644 src/connection/useConnection.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c7cef611..ca69a8f7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -115,6 +126,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -311,6 +333,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -463,6 +494,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -506,6 +546,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -529,6 +575,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -768,6 +824,35 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -848,6 +933,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -888,7 +974,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1089,7 +1175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1684,6 +1770,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1981,6 +2085,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2207,6 +2321,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zbus 4.4.0", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2243,6 +2374,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2409,7 +2549,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2442,6 +2582,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "8.0.0" @@ -2462,7 +2615,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -2471,7 +2624,40 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", ] [[package]] @@ -2480,6 +2666,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2800,7 +3016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3175,13 +3391,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -3196,6 +3423,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3206,6 +3443,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -3395,7 +3641,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3421,7 +3667,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3448,10 +3694,10 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3552,6 +3798,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3763,6 +4041,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3846,7 +4135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3903,6 +4192,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4320,7 +4615,7 @@ dependencies = [ "thiserror 2.0.20", "url", "windows", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -4337,7 +4632,7 @@ dependencies = [ "tokio", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -4494,7 +4789,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4527,6 +4822,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "keyring", "libc", "objc2", "objc2-app-kit", @@ -4556,7 +4852,7 @@ dependencies = [ "tw-api", "windows", "windows-sys 0.61.2", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -4972,7 +5268,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5054,7 +5350,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5508,7 +5804,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.61.2", ] [[package]] @@ -6097,6 +6393,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.3" @@ -6120,6 +6426,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.13.2" @@ -6150,9 +6494,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.13.2", + "zbus_names 4.3.1", + "zvariant 5.9.2", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -6165,9 +6522,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -6178,7 +6546,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.15", - "zvariant", + "zvariant 5.9.2", ] [[package]] @@ -6227,6 +6595,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -6300,6 +6682,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.9.2" @@ -6310,8 +6705,21 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -6324,7 +6732,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42c90db6..1cde345a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,6 +62,11 @@ tauri-plugin-updater = "2" reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "system-proxy"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } semver = "1" +# 远程连接的控制面密钥存在系统的钥匙串里:macOS 钥匙串、Windows 凭据管理器、Linux 的 +# Secret Service(见 `connection/keychain.rs`)。**Linux 那一支走纯 Rust 的 zbus** +# (`async-secret-service` + `async-io`),不链 libdbus —— 那要 CI 和用户机器上多装一个 +# 开发包 +keyring = { version = "3.6", features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } # Homebrew 那一档的「复制命令」。**在 Rust 这边写剪贴板**,理由见 # `update_copy_command`。 tauri-plugin-clipboard-manager = "2" @@ -131,6 +136,8 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Environment", # explorer.exe 按 Windows 目录拼全路径,不按 PATH 找(`clients::explorer`) "Win32_System_SystemInformation", + # 启动时看 Alt 键有没有按着:按着就先显示连接选择(`connection::launch`) + "Win32_UI_Input_KeyboardAndMouse", ] } # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6..30c674da 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,32 @@ fn main() { + core_tag(); tauri_build::build() } + +/// 这一版应用配的是哪一版 core:`Cargo.lock` 里 tw-api 锁到的 tag,去掉开头的 `v`。 +/// +/// 连远程 core 时版本对不上,界面要说出「本应用需要 core X」。**从锁文件读,不另写 +/// 一份**:打包脚本取 twcore 看的也是这一行(`scripts/fetch-core.sh`),两处说的 +/// 必然是同一版。读不到(临时用 `[patch]` 指向本地 core 时就是这样)记成 `dev` +fn core_tag() { + println!("cargo:rerun-if-changed=Cargo.lock"); + let lock = std::fs::read_to_string("Cargo.lock").unwrap_or_default(); + let mut in_api = false; + let mut tag = None; + for line in lock.lines() { + if line.starts_with("name = ") { + in_api = line == "name = \"tw-api\""; + } else if in_api + && line.starts_with("source = ") + && let Some(i) = line.find("tag=v") + { + let rest = &line[i + "tag=v".len()..]; + let end = rest.find(['#', '&', '"']).unwrap_or(rest.len()); + tag = Some(rest[..end].to_string()); + } + } + println!( + "cargo:rustc-env=TW_CORE_TAG={}", + tag.unwrap_or_else(|| "dev".into()) + ); +} diff --git a/src-tauri/capabilities/picker.json b/src-tauri/capabilities/picker.json new file mode 100644 index 00000000..4391769e --- /dev/null +++ b/src-tauri/capabilities/picker.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "picker", + "description": "启动时的连接选择。按内容定好高度(picker_fit,走 Rust)之后自己亮出来、拿焦点;选好之后由 Rust 关掉它(pick_connection)。别的都不需要。", + "windows": [ + "picker" + ], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-set-focus" + ] +} diff --git a/src-tauri/src/clients.rs b/src-tauri/src/clients.rs index f03d715f..897b1bda 100644 --- a/src-tauri/src/clients.rs +++ b/src-tauri/src/clients.rs @@ -187,3 +187,50 @@ fn reveal(path: &str) -> Result<(), String> { ) .to_string()) } + +/// 这台机器上已接管、还指着本机网关的客户端。切到远程之前的确认里说(「已接管的 3 个 +/// 客户端仍指向本机网关 127.0.0.1:8788」) +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct Adopted { + pub count: usize, + /// 本机网关此刻的地址 + pub local_addr: Option, +} + +/// 数一数这台机器上已接管、还指着本机网关的客户端。**数不出来就当没有**:确认框里 +/// 少说一条提醒,不该挡住切换。 +/// +/// TODO(P3):客户端接管整块搬进 lite 之后,改成问 lite 自己的扫描;现在还是问本机 +/// core 的 `/clients`(`control` 必须是本机那一个,见 `Supervisor::control`)。 +pub async fn adopted_pointing_at_local(control: &crate::control::ControlClient) -> Adopted { + let (clients, status) = tokio::join!(control.call::(&[], &()), control.status()); + let local_addr = status.ok().and_then(|s| s.gateway_addr); + let count = match (&clients, &local_addr) { + (Ok(list), Some(addr)) => list + .clients + .iter() + .filter(|c| c.adopted_at_ms.is_some()) + .filter(|c| { + c.endpoint + .as_deref() + .is_none_or(|e| e.contains(addr.as_str())) + }) + .count(), + _ => 0, + }; + Adopted { count, local_addr } +} + +/// 把这台机器上已接管的客户端改为指向 `gateway_addr`(远程服务器的网关)。返回改了 +/// 几个。切换确认里勾了「同时将这些客户端改为指向…」时调。 +/// +/// TODO(P3):接管搬进 lite 之后在这里接上 —— 对每个已接管、指着本机网关的客户端, +/// 用 lite 侧的接管代码把端点换成 `http://{gateway_addr}`,密钥换成服务器上为它发放的 +/// 专用密钥。在那之前如实说做不到,不假装改过了。 +pub async fn retarget_adopted_clients( + app: &tauri::AppHandle, + gateway_addr: &str, +) -> anyhow::Result { + let _ = app; + anyhow::bail!("retargeting adopted clients to {gateway_addr} is not implemented yet (P3)") +} diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs new file mode 100644 index 00000000..aa487143 --- /dev/null +++ b/src-tauri/src/connection/connector.rs @@ -0,0 +1,301 @@ +//! 连上一个 core:本机的,或者另一台机器上的。 +//! +//! **切换之前先试连**,试连和真正连上走的是同一个函数([`test`]),所以「试的时候 +//! 通了、切过去却连不上」只可能是这中间对面变了,不会是两段代码各说各的。 +//! +//! 失败分五种说([`ConnectError`]),每一种界面上都给出下一步:地址不通、被对面 +//! 关掉、密钥不对、版本不一致、超时。 +//! +//! # 远程那一档还没接上(P2) +//! +//! 远程控制面的握手(Noise,密钥来自服务器的 `listen.control.key`)由 core 的 +//! `tw-link` 提供,还没发版。这里先把 TCP 连上 —— 地址不通和超时现在就能如实说 —— +//! 握手那一步集中在 [`handshake`] 一个函数里,眼下一律回 [`ConnectError::NotYetAvailable`]。 +//! 接上 P2 时只改它(和删掉那一种错误)。 + +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::control::{ControlClient, Stream, Target}; + +/// 远程的 core 在哪、拿什么进门。**密钥只在内存里**:存盘在钥匙串,不进设置文件, +/// 也不交给界面 +#[derive(Clone)] +pub struct RemoteTarget { + pub host: String, + pub port: u16, + /// 服务器上 `twcore control-key` 给的那 64 位十六进制 + pub key: String, +} + +impl RemoteTarget { + /// `host:port`。IPv6 字面量加方括号 + pub fn addr(&self) -> String { + display_addr(&self.host, self.port) + } +} + +impl std::fmt::Debug for RemoteTarget { + /// **不打印密钥**:日志里出现的只有地址 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RemoteTarget({})", self.addr()) + } +} + +pub fn display_addr(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// 连上之后知道的:对面是哪一版 core,客户端该连它的哪个网关地址 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ServerInfo { + pub core_version: String, + /// 服务器的网关地址(安全模式下没有)。远程时侧栏上显示的就是它 —— 客户端要连的那个 + pub gateway_addr: Option, +} + +/// 连不上的原因。**界面按种类说话**(文案在前端的 `connection.i18n.tsx`),这里只 +/// 带事实:地址、两个版本号 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConnectError { + /// 地址不通:解析不出来、对面拒绝连接、网络不可达 + Unreachable { addr: String }, + /// TCP 连上了,对面一个字节不回就关掉。**多半是不在允许列表里**:core 对 + /// `allow_from` 之外的地址 accept 之后直接关,不向扫描者暴露自己是什么 + Closed { addr: String }, + /// 握手解不开:密钥和服务器的不一样 + WrongKey, + /// 握手通过了,两边的控制面版本不一样。`ours` 是这一版应用配的 core + VersionMismatch { ours: String, theirs: String }, + /// 在限定时间内没连上,或者握手没走完 + Timeout { addr: String }, + /// 这一版应用还不会远程握手。**P2 接上握手时删掉这一种** + NotYetAvailable, +} + +/// 一句话。**界面不用它**(界面按种类说,带下一步);它给的是经控制面客户端冒出来、 +/// 落进日志和个别错误提示里的那一句 +impl std::fmt::Display for ConnectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ConnectError::Unreachable { addr } => { + tr!( + format!("无法连接到 {addr}"), + format!("Cannot connect to {addr}") + ) + } + ConnectError::Closed { addr } => tr!( + format!("{addr} 关闭了连接"), + format!("{addr} closed the connection") + ), + ConnectError::WrongKey => tr!("密钥不正确", "The key is not correct").into(), + ConnectError::VersionMismatch { ours, theirs } => tr!( + format!("版本不一致:服务器 core {theirs},本应用需要 {ours}"), + format!("Version mismatch: the server runs core {theirs}; this app needs {ours}") + ), + ConnectError::Timeout { addr } => tr!( + format!("连接 {addr} 超时"), + format!("Connecting to {addr} timed out") + ), + ConnectError::NotYetAvailable => tr!( + "此版本尚不支持连接远程 core", + "This version cannot connect to a remote core yet" + ) + .into(), + }; + f.write_str(&s) + } +} + +impl std::error::Error for ConnectError {} + +/// 这一版应用配的是哪一版 core。**来自 `Cargo.lock` 里 tw-api 锁的 tag**,见 build.rs +pub const REQUIRED_CORE: &str = env!("TW_CORE_TAG"); + +/// 连一次 TCP、握手,最多等这么久 +const CONNECT_WITHIN: Duration = Duration::from_secs(5); +/// 本机那一档问一次 `/status` 最多等多久 +const LOCAL_WITHIN: Duration = Duration::from_secs(3); + +/// 握手的结果:对面说自己是哪一版 +#[derive(Debug, Clone)] +pub struct Handshake { + pub core_version: String, +} + +/// 试连:连上、握手、问一次 `/status`。**切换之前、启动时、断线重连都走它。** +pub async fn test(target: &Target) -> Result { + match target { + Target::Local { .. } => { + let c = ControlClient::to(target.clone()); + let s = tokio::time::timeout(LOCAL_WITHIN, c.status()) + .await + .map_err(|_| ConnectError::Timeout { + addr: tr!("本机", "This Mac").into(), + })? + .map_err(|_| ConnectError::Unreachable { + addr: tr!("本机", "This Mac").into(), + })?; + Ok(ServerInfo { + core_version: s.version, + gateway_addr: s.gateway_addr, + }) + } + Target::Remote(r) => { + let (_, hello) = open(r).await?; + // 握手过了,剩下的是一次普通的控制面请求。**失败按「连接被关闭」说**: + // 握手刚通过就断,是对面在这一瞬间没了 + let s = ControlClient::to(target.clone()) + .status() + .await + .map_err(|_| ConnectError::Closed { addr: r.addr() })?; + Ok(ServerInfo { + core_version: if s.version.is_empty() { + hello.core_version + } else { + s.version + }, + gateway_addr: s.gateway_addr, + }) + } + } +} + +/// 打开一条到远程控制面的流:TCP 连上,再握手。控制面客户端每个请求都走它 +pub(crate) async fn open(r: &RemoteTarget) -> Result<(Box, Handshake), ConnectError> { + let addr = r.addr(); + let tcp = tokio::time::timeout( + CONNECT_WITHIN, + tokio::net::TcpStream::connect((r.host.trim_matches(['[', ']']), r.port)), + ) + .await + .map_err(|_| ConnectError::Timeout { addr: addr.clone() })? + .map_err(|e| { + tracing::debug!("连不上 {addr}:{e}"); + match e.kind() { + std::io::ErrorKind::TimedOut => ConnectError::Timeout { addr: addr.clone() }, + _ => ConnectError::Unreachable { addr: addr.clone() }, + } + })?; + let _ = tcp.set_nodelay(true); + tokio::time::timeout(CONNECT_WITHIN, handshake(tcp, &r.key, &addr)) + .await + .map_err(|_| ConnectError::Timeout { addr })? +} + +/// **P2 在这里接上握手。** +/// +/// 要做的:用 `tw-link` 的 Noise 握手(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`,PSK 是 +/// `key` 的 32 字节)把 `io` 包成加密的流,第一条消息带上应用的控制面版本和应用版本; +/// 按对面第二条消息的结果返回: +/// +/// - 解不开、收到明文拒绝字节 → [`ConnectError::WrongKey`] +/// - `accept=false`(版本不一致)→ [`ConnectError::VersionMismatch`],`ours` 用 +/// [`REQUIRED_CORE`],`theirs` 用对面报的 core 版本 +/// - 对面一个字节不回就关掉 → [`ConnectError::Closed`] +/// +/// 返回的流交给 hyper 跑 HTTP/1.1,事件流(SSE)也走它。 +async fn handshake( + io: S, + key: &str, + addr: &str, +) -> Result<(Box, Handshake), ConnectError> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let _ = (io, key); + tracing::debug!("{addr}:TCP 已连上,这一版还不会远程握手"); + Err(ConnectError::NotYetAvailable) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipv6_addresses_get_brackets() { + assert_eq!(display_addr("192.168.1.20", 8789), "192.168.1.20:8789"); + assert_eq!(display_addr("::1", 8789), "[::1]:8789"); + assert_eq!(display_addr("[::1]", 8789), "[::1]:8789"); + assert_eq!(display_addr("nas.local", 1), "nas.local:1"); + } + + /// 日志里不能出现密钥 + #[test] + fn debug_output_leaves_the_key_out() { + let r = RemoteTarget { + host: "h".into(), + port: 1, + key: "secret-secret".into(), + }; + assert!(!format!("{r:?}").contains("secret")); + } + + #[test] + fn the_required_core_is_the_locked_tag() { + assert!(!REQUIRED_CORE.is_empty()); + assert!(!REQUIRED_CORE.starts_with('v'), "{REQUIRED_CORE}"); + } + + /// 错误按种类交给界面,字段是事实不是句子 + #[test] + fn errors_go_out_tagged_by_kind() { + let v = serde_json::to_value(ConnectError::VersionMismatch { + ours: "0.48.0".into(), + theirs: "0.47.2".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "version_mismatch"); + assert_eq!(v["theirs"], "0.47.2"); + assert_eq!( + serde_json::to_value(ConnectError::WrongKey).unwrap()["kind"], + "wrong_key" + ); + } + + /// 没人听的端口:说地址不通(或者超时),**不会卡住** + #[tokio::test] + async fn a_port_nobody_listens_on_is_unreachable() { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + let r = RemoteTarget { + host: "127.0.0.1".into(), + port, + key: "k".into(), + }; + let e = test(&Target::Remote(r)).await.unwrap_err(); + assert!( + matches!( + e, + ConnectError::Unreachable { .. } | ConnectError::Timeout { .. } + ), + "{e:?}" + ); + } + + /// 有人听、握手还没接上:说「这一版还不支持」,不是别的 + #[tokio::test] + async fn a_listening_port_reaches_the_handshake_seam() { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + let _ = l.accept().await; + }); + let r = RemoteTarget { + host: "127.0.0.1".into(), + port, + key: "k".into(), + }; + assert_eq!( + test(&Target::Remote(r)).await.unwrap_err(), + ConnectError::NotYetAvailable + ); + } +} diff --git a/src-tauri/src/connection/keychain.rs b/src-tauri/src/connection/keychain.rs new file mode 100644 index 00000000..1999cc26 --- /dev/null +++ b/src-tauri/src/connection/keychain.rs @@ -0,0 +1,50 @@ +//! 远程连接的控制面密钥:放进系统的钥匙串。 +//! +//! macOS 是钥匙串,Windows 是凭据管理器,Linux 是 Secret Service(GNOME 钥匙串、 +//! KWallet)。**不进设置文件,也不交给界面**:界面只在添加、更换时把用户粘进来的那一串 +//! 递过来一次,之后再也拿不回去 —— 编辑对话框里显示的是「已保存在钥匙串中」。 + +/// 钥匙串里这一项归谁 +const SERVICE: &str = "ThinkWatch Lite"; + +fn entry(id: &str) -> anyhow::Result { + Ok(keyring::Entry::new(SERVICE, &format!("control-key:{id}"))?) +} + +/// 用户粘进来的那一串整理成密钥:去掉首尾空白、统一成小写。**不是 64 位十六进制就不收** +/// —— 服务器那边也只认这种,收下一把短的只会在连接时换来一句「密钥不正确」 +pub fn normalize(raw: &str) -> Option { + let k = raw.trim().to_ascii_lowercase(); + (k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit())).then_some(k) +} + +pub fn store(id: &str, key: &str) -> anyhow::Result<()> { + entry(id)?.set_password(key)?; + Ok(()) +} + +pub fn load(id: &str) -> anyhow::Result { + Ok(entry(id)?.get_password()?) +} + +/// 删掉这一项。**本来就没有不算错**:连接删了,钥匙串那边早被用户清过也无妨 +pub fn remove(id: &str) -> anyhow::Result<()> { + match entry(id)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_64_hex_characters_are_a_key() { + let k = "9F2C".repeat(16); + assert_eq!(normalize(&format!(" {k}\n")), Some(k.to_ascii_lowercase())); + assert_eq!(normalize("abc"), None); + assert_eq!(normalize(&"g".repeat(64)), None); + assert_eq!(normalize(&"a".repeat(65)), None); + } +} diff --git a/src-tauri/src/connection/launch.rs b/src-tauri/src/connection/launch.rs new file mode 100644 index 00000000..f0b73f87 --- /dev/null +++ b/src-tauri/src/connection/launch.rs @@ -0,0 +1,101 @@ +//! 启动时先不连、先问连哪个的两种情形:按住 ⌥ 启动,和上两次启动都没走到就绪。 +//! +//! **后一条是失败保护。**远程连不上时界面照样能开,但要是某个连接让应用一启动就出事 +//! (卡在启动画面、崩掉),用户每打开一次都会再撞一次。和 core 连续启动失败就进安全 +//! 模式是同一个思路:连着两次没走到就绪,第三次先让人选。 + +use std::path::Path; + +/// 记着「这次启动还没走到就绪」的文件,里面是连着几次了 +const FILE: &str = "launch-attempts"; + +/// 连着几次没走到就绪,下一次就先让人选 +pub const PICK_AFTER: u32 = 2; + +/// 为什么先显示连接选择 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Why { + /// 按住了 ⌥(Windows、Linux 上是 Alt) + Option, + /// 上次启动没能走到就绪 + Unfinished, +} + +/// 这次启动开始了:读出之前连着几次没走到就绪,再把这一次记上。 +pub fn begin(dir: &Path) -> u32 { + let before = std::fs::read_to_string(dir.join(FILE)) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::write(dir.join(FILE), (before + 1).to_string()); + before +} + +/// 走到就绪了(连上了):清掉记号 +pub fn ready(dir: &Path) { + let _ = std::fs::remove_file(dir.join(FILE)); +} + +/// 这次要不要先让人选,为什么 +pub fn why(before: u32, option_held: bool) -> Option { + if option_held { + Some(Why::Option) + } else if before >= PICK_AFTER { + Some(Why::Unfinished) + } else { + None + } +} + +/// 启动这一刻 ⌥ 是不是按着。 +/// +/// macOS 问 `NSEvent.modifierFlags`,Windows 问 Alt 键的状态。**Linux 上说不出来**: +/// Wayland 不让一个还没有窗口的进程读键盘,那边只有失败保护这一条 +pub fn option_held() -> bool { + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSEvent, NSEventModifierFlags}; + NSEvent::modifierFlags_class().contains(NSEventModifierFlags::Option) + } + #[cfg(windows)] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_MENU}; + // SAFETY: 只读一个键的状态。最高位是「此刻按着」 + (unsafe { GetAsyncKeyState(VK_MENU as i32) } as u16 & 0x8000) != 0 + } + #[cfg(not(any(target_os = "macos", windows)))] + { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("tw-launch-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// 连着两次没走到就绪,第三次先让人选;走到过一次就从头数 + #[test] + fn two_unfinished_launches_make_the_third_one_ask() { + let dir = tmp("guard"); + assert_eq!(why(begin(&dir), false), None); + assert_eq!(why(begin(&dir), false), None); + assert_eq!(why(begin(&dir), false), Some(Why::Unfinished)); + ready(&dir); + assert_eq!(why(begin(&dir), false), None); + } + + #[test] + fn holding_option_always_asks() { + assert_eq!(why(0, true), Some(Why::Option)); + assert_eq!(why(5, true), Some(Why::Option)); + } +} diff --git a/src-tauri/src/connection/mod.rs b/src-tauri/src/connection/mod.rs new file mode 100644 index 00000000..33a366b4 --- /dev/null +++ b/src-tauri/src/connection/mod.rs @@ -0,0 +1,889 @@ +//! 连哪个 core:本机的,或者另一台机器上的。 +//! +//! 应用同一时间只连一个 core。**几条原则,都来自同一种要避开的失败**:远程连不上, +//! 界面就初始化不了,只能重装或者把远程修好才能切回本机。 +//! +//! - 界面外壳不依赖 core。连接列表归应用自己存(`store`),密钥进钥匙串(`keychain`)。 +//! - 「本机」内置、删不掉,任何状态下一步就能切回来。 +//! - 先试连,再切换(`connector::test`)。失败就留在原来的连接上,说明原因。 +//! - **绝不悄悄退回本机。**连不上远程时不自动拉起本机的 core:两边配置不同,客户端又 +//! 指着服务器,用户会分不清请求走的是哪边。 +//! - 远程模式下本机 core 停掉(等在途请求结束),本机数据原样保留,切回本机时再拉起。 +//! - 断线只报一次:一条系统通知,界面上一条横幅;连上后按现有的对账机制补齐。 + +pub mod connector; +pub mod keychain; +pub mod launch; +pub mod store; + +use std::sync::Arc; +use std::time::Duration; + +use tauri::{Emitter, Manager}; +use tokio::sync::{Notify, watch}; + +use crate::control::Target; +use crate::error::{CmdError, Out}; +use crate::supervisor::CoreState; +use crate::{AppState, data_dir, notices}; +use connector::{ConnectError, RemoteTarget, ServerInfo}; +use store::{LOCAL, Remote}; + +/// 此刻的连接走到了哪一步。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LinkState { + /// 连的是本机:状态看守护(`core-state`),这一层不另说 + Local, + /// 还没开始连:连接选择开着 + Waiting, + /// 正在连,第几次。`ever`:这次运行里连上过它 + Connecting { + attempt: u32, + ever: bool, + }, + Connected { + info: ServerInfo, + }, + /// 连不上,隔一会儿再试。`retry_in_ms` 从 `at_ms` 算起 + Down { + error: ConnectError, + attempt: u32, + at_ms: u64, + retry_in_ms: u64, + ever: bool, + }, +} + +/// 当前连接是哪一条 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Current { + Local, + Remote(Remote), +} + +impl Current { + fn id(&self) -> &str { + match self { + Current::Local => LOCAL, + Current::Remote(r) => &r.id, + } + } +} + +pub struct Link { + app: tauri::AppHandle, + /// 本机 core 的控制面在哪、凭据是什么。切回本机时控制面客户端指回这里 + local: Target, + current: std::sync::Mutex, + state: watch::Sender, + /// 「立即重试」 + retry: Arc, + /// 事件流断了(连着远程时) + lost: Arc, + /// 连远程的那条循环 + task: std::sync::Mutex>>, + /// 切换一次做完再做下一次。**停本机 core 的那一步也拿它**:切走之后又马上切 + /// 回来时,不能出现「刚拉起来的 core 被上一次切换停掉」 + switching: tokio::sync::Mutex<()>, +} + +/// 远程断线重连的间隔:第几次失败之后等多久 +fn backoff(attempt: u32) -> Duration { + const STEPS: [u64; 6] = [2, 4, 8, 15, 30, 60]; + Duration::from_secs(STEPS[(attempt.saturating_sub(1) as usize).min(STEPS.len() - 1)]) +} + +/// 连着远程时多久问一次它还在不在,连着几次不答算断线 +const PING_EVERY: Duration = Duration::from_secs(5); +const PING_FAILS: u32 = 3; + +impl Link { + pub fn new(app: tauri::AppHandle, local: Target, current: Current) -> Self { + Self { + app, + local, + current: std::sync::Mutex::new(current), + state: watch::channel(LinkState::Local).0, + retry: Arc::new(Notify::new()), + lost: Arc::new(Notify::new()), + task: std::sync::Mutex::new(None), + switching: tokio::sync::Mutex::new(()), + } + } + + pub fn state(&self) -> LinkState { + self.state.borrow().clone() + } + + pub fn watch(&self) -> watch::Receiver { + self.state.subscribe() + } + + pub fn current(&self) -> Current { + self.current.lock().expect("锁未中毒").clone() + } + + pub fn is_remote(&self) -> bool { + matches!(self.current(), Current::Remote(_)) + } + + fn set(&self, s: LinkState) { + self.state.send_replace(s); + announce(&self.app); + } + + /// 「立即重试」:连着远程时马上再连一次,不等退避 + pub fn retry_now(&self) { + self.retry.notify_one(); + } + + /// 事件流断了。连着远程时它就是断线的信号 + pub fn stream_lost(&self) { + if matches!(self.state(), LinkState::Connected { .. }) && self.is_remote() { + self.lost.notify_one(); + } + } + + fn stop_task(&self) { + if let Some(h) = self.task.lock().expect("锁未中毒").take() { + h.abort(); + } + } + + /// 启动时先让人选:什么都不连,等选好 + pub fn wait_for_pick(&self) { + self.set(LinkState::Waiting); + } +} + +/// 状态变了:界面、菜单栏都说一声 +fn announce(app: &tauri::AppHandle) { + let _ = app.emit("connection", view(app)); + emit_core_state(app); + if let Some(st) = app.try_state::() { + st.menubar_now.notify_one(); + } +} + +/// 界面认得的那个 core 状态字符串,**按当前连接说**。 +/// +/// 连本机时就是守护的状态。连远程时只有两种:连上了是 `running:0`(各处见到 +/// `running:` 就去取数、对账,和本机 core 重启回来走同一条路),没连上是 `unlinked`, +/// 细节在 `connection` 事件里 +pub fn describe_active(link: &LinkState, sup: &CoreState) -> String { + match link { + LinkState::Local => crate::gateway::describe_state(sup), + LinkState::Connected { .. } => "running:0".into(), + _ => "unlinked".into(), + } +} + +pub(crate) fn emit_core_state(app: &tauri::AppHandle) { + if let Some(st) = app.try_state::() { + let _ = app.emit( + "core-state", + describe_active(&st.link.state(), &st.supervisor.state()), + ); + } +} + +/// 守护换了状态。**连本机时本机 core 起来了就算走到了就绪**,见 `launch` +pub(crate) fn on_supervisor(app: &tauri::AppHandle, s: &CoreState) { + if let Some(st) = app.try_state::() + && !st.link.is_remote() + && matches!(s, CoreState::Running { .. }) + { + launch::ready(&data_dir()); + } +} + +// ------------------------------------------------------------------ 界面看到的 + +/// 连接列表里的一条。**没有密钥** +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProfileView { + pub id: String, + pub name: String, + pub local: bool, + pub host: Option, + pub port: Option, + /// `host:port`,本机没有 + pub addr: Option, + pub last_connected_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ConnView { + /// 本机在最前,其余按添加的顺序 + pub profiles: Vec, + pub current: String, + /// 上次用的那一条。启动时连接选择里标「上次使用」 + pub last_used: String, + pub startup: store::Startup, + pub link: LinkState, + /// 这一版应用配的 core 版本 + pub required_core: &'static str, + /// 数据目录,「本机」那一行的说明 + pub data_dir: String, +} + +fn profile(r: &Remote) -> ProfileView { + ProfileView { + id: r.id.clone(), + name: r.name.clone(), + local: false, + host: Some(r.host.clone()), + port: Some(r.port), + addr: Some(connector::display_addr(&r.host, r.port)), + last_connected_at: r.last_connected_at, + } +} + +/// 数据目录写成用户认得的样子:家目录下的写成 `~/…` +fn home_relative(p: &std::path::Path) -> String { + #[cfg(unix)] + if let Some(home) = std::env::var_os("HOME") + && let Ok(rest) = p.strip_prefix(&home) + { + return format!("~/{}", rest.display()); + } + p.display().to_string() +} + +pub fn view(app: &tauri::AppHandle) -> ConnView { + let c = store::load(&data_dir()); + let mut profiles = vec![ProfileView { + id: LOCAL.into(), + name: tr!("本机", "This Mac").into(), + local: true, + host: None, + port: None, + addr: None, + last_connected_at: None, + }]; + profiles.extend(c.remotes.iter().map(profile)); + let (current, link) = match app.try_state::() { + Some(st) => (st.link.current().id().to_string(), st.link.state()), + None => (LOCAL.to_string(), LinkState::Local), + }; + ConnView { + profiles, + current, + last_used: c.last_used, + startup: c.startup, + link, + required_core: connector::REQUIRED_CORE, + data_dir: home_relative(&data_dir()), + } +} + +// ------------------------------------------------------------------ 启动与切换 + +/// 还在等连接选择(启动时先让人选、还没选) +pub fn waiting(app: &tauri::AppHandle) -> bool { + app.try_state::() + .is_some_and(|st| st.link.state() == LinkState::Waiting) +} + +/// 启动时连上选定的那一个。**不在这里等**:守护、远程连接都在后台,窗口照常打开 +pub fn start(app: &tauri::AppHandle, id: &str) { + let Some(st) = app.try_state::() else { + return; + }; + let c = store::load(&data_dir()); + match c.remote(id).cloned() { + Some(r) => begin_remote(app, &st.link, r, None), + None => begin_local(app, &st.link), + } +} + +/// 选定了连哪个:启动时的连接选择要是还开着,就收起来 —— 别处(菜单栏的「连接」 +/// 子菜单)已经替它选了 +fn close_picker(app: &tauri::AppHandle) { + if let Some(w) = app.get_webview_window(PICKER_WINDOW) { + let _ = w.destroy(); + } +} + +fn begin_local(app: &tauri::AppHandle, link: &Link) { + close_picker(app); + link.stop_task(); + *link.current.lock().expect("锁未中毒") = Current::Local; + if let Some(st) = app.try_state::() { + st.control.retarget(link.local.clone()); + } + link.set(LinkState::Local); + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_back(), notices::now_ms()); + } + // 守护没在跑就接回来(远程模式下它停了)。在跑的话什么都不做 + crate::gateway::ensure_supervising(app); +} + +/// 开始连一个远程。`first`:切换时刚试连成功的结果,不再连第二遍 +fn begin_remote(app: &tauri::AppHandle, link: &Link, r: Remote, first: Option) { + close_picker(app); + link.stop_task(); + *link.current.lock().expect("锁未中毒") = Current::Remote(r.clone()); + let key = keychain::load(&r.id).unwrap_or_default(); + let target = RemoteTarget { + host: r.host.clone(), + port: r.port, + key, + }; + if let Some(st) = app.try_state::() { + st.control.retarget(Target::Remote(target.clone())); + } + link.set(LinkState::Connecting { + attempt: 1, + ever: false, + }); + let app2 = app.clone(); + let h = tauri::async_runtime::spawn(async move { remote_loop(app2, r, target, first).await }); + *link.task.lock().expect("锁未中毒") = Some(h); +} + +/// 连着一个远程:连上、看着、断了再连。**不会自己退回本机** +async fn remote_loop( + app: tauri::AppHandle, + remote: Remote, + target: RemoteTarget, + mut first: Option, +) { + let Some(st) = app.try_state::() else { + return; + }; + let link = &st.link; + let mut attempt = 0u32; + let mut ever = false; + loop { + let tried = match first.take() { + Some(info) => Ok(info), + None => { + attempt += 1; + link.set(LinkState::Connecting { attempt, ever }); + connector::test(&Target::Remote(target.clone())).await + } + }; + match tried { + Ok(info) => { + attempt = 0; + ever = true; + connected(&app, &remote.id); + link.set(LinkState::Connected { info }); + watch_until_lost(link, &st.control).await; + tracing::warn!("与 {} 的连接断开", remote.name); + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_lost(&remote.name), notices::now_ms()); + } + } + Err(error) => { + let wait = backoff(attempt); + tracing::info!(?error, attempt, ?wait, "连不上 {}", remote.name); + link.set(LinkState::Down { + error, + attempt, + at_ms: notices::now_ms(), + retry_in_ms: wait.as_millis() as u64, + ever, + }); + tokio::select! { + _ = tokio::time::sleep(wait) => {} + _ = link.retry.notified() => {} + } + } + } + } +} + +/// 连着的时候看着它:事件流断了,或者连着几次问不到状态,就算断线 +async fn watch_until_lost(link: &Link, control: &crate::control::ControlClient) { + let mut fails = 0; + loop { + tokio::select! { + _ = link.lost.notified() => return, + _ = tokio::time::sleep(PING_EVERY) => { + match control.ping(Duration::from_secs(3)).await { + Ok(()) => fails = 0, + Err(_) => { + fails += 1; + if fails >= PING_FAILS { + return; + } + } + } + } + } + } +} + +/// 连上了:记下时刻、清掉断线那条提醒、这次启动算走到了就绪 +fn connected(app: &tauri::AppHandle, id: &str) { + let dir = data_dir(); + launch::ready(&dir); + let now = notices::now_ms(); + let id2 = id.to_string(); + if let Err(e) = store::update(&dir, |c| { + if let Some(r) = c.remotes.iter_mut().find(|r| r.id == id2) { + r.last_connected_at = Some(now); + } + }) { + tracing::warn!("连接列表写不进去:{e:#}"); + } + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_back(), now); + } +} + +/// 切到另一个连接。 +/// +/// 切到本机:直接切,拉起本机 core。切到远程:**先试连**,通过了才提交 —— 没通过就 +/// 留在原来的连接上,把原因交给界面。提交之后本机 core 在手上的请求结束后停掉。 +pub async fn switch( + app: &tauri::AppHandle, + id: &str, + retarget_clients: bool, +) -> Result<(), SwitchError> { + let st = app.state::(); + let dir = data_dir(); + let c = store::load(&dir); + if id == LOCAL { + let _g = st.link.switching.lock().await; + begin_local(app, &st.link); + remember(&dir, LOCAL); + return Ok(()); + } + let r = c.remote(id).cloned().ok_or(SwitchError::Unknown)?; + let key = keychain::load(&r.id).map_err(|e| SwitchError::Keychain { + detail: format!("{e:#}"), + })?; + let target = RemoteTarget { + host: r.host.clone(), + port: r.port, + key, + }; + let info = connector::test(&Target::Remote(target)) + .await + .map_err(|error| SwitchError::Connect { error })?; + // 要改指向的话,趁本机 core 还在跑先把名单拿到 + let retarget = if retarget_clients { + info.gateway_addr.clone() + } else { + None + }; + { + let _g = st.link.switching.lock().await; + begin_remote(app, &st.link, r.clone(), Some(info)); + remember(&dir, &r.id); + } + if let Some(addr) = retarget { + match crate::clients::retarget_adopted_clients(app, &addr).await { + Ok(n) => tracing::info!(n, "已接管的客户端改为指向 {addr}"), + Err(e) => tracing::warn!("改指向没做成:{e:#}"), + } + } + stop_local_when_quiet(app.clone()); + Ok(()) +} + +fn remember(dir: &std::path::Path, id: &str) { + let id = id.to_string(); + if let Err(e) = store::update(dir, |c| c.last_used = id) { + tracing::warn!("连接列表写不进去:{e:#}"); + } +} + +/// 本机 core 在手上的请求结束后停掉,**并且守护不再拉它**。等的这段时间里又切回了 +/// 本机,就不停了 +fn stop_local_when_quiet(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let Some(st) = app.try_state::() else { + return; + }; + if !matches!(st.supervisor.state(), CoreState::Running { .. }) { + // 没在跑(启动中、安全模式、起不来):直接停,不必等请求 + let _g = st.link.switching.lock().await; + if st.link.is_remote() { + st.supervisor.stop_and_wait(Duration::from_secs(5)).await; + } + return; + } + crate::updater::wait_for_quiet(st.supervisor.control(), |_| {}).await; + let _g = st.link.switching.lock().await; + if st.link.is_remote() { + tracing::info!("已切到远程,停掉本机 core"); + st.supervisor.stop_and_wait(Duration::from_secs(5)).await; + } + }); +} + +/// 切换没做成的原因 +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SwitchError { + /// 列表里没有这一条(别处刚删掉) + Unknown, + /// 钥匙串里取不出密钥 + Keychain { detail: String }, + /// 试连没通过 + Connect { error: ConnectError }, +} + +// ------------------------------------------------------------------ 命令 + +#[tauri::command] +pub fn connections(app: tauri::AppHandle) -> ConnView { + view(&app) +} + +#[tauri::command] +pub fn set_connection_startup(app: tauri::AppHandle, startup: store::Startup) -> Out { + store::update(&data_dir(), |c| c.startup = startup).map_err(saving)?; + announce(&app); + Ok(view(&app)) +} + +/// 对话框里填的 +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ProfileInput { + /// 编辑时是那一条的 id,新建时没有 + pub id: Option, + pub name: String, + pub host: String, + pub port: u16, + /// 新建时必填;编辑时不填就是沿用钥匙串里的那一把 + pub key: Option, +} + +/// 对话框里的一项不对。`field` 是哪一格 +#[derive(Debug, Clone, serde::Serialize)] +pub struct Invalid { + pub field: &'static str, + pub reason: &'static str, +} + +fn check(c: &store::Connections, p: &ProfileInput) -> Result<(), Invalid> { + let bad = |field, reason| Err(Invalid { field, reason }); + if p.name.trim().is_empty() { + return bad("name", "empty"); + } + if c.name_taken(&p.name, p.id.as_deref()) { + return bad("name", "taken"); + } + let host = p.host.trim(); + if host.is_empty() || host.contains(char::is_whitespace) || host.contains('/') { + return bad("host", "invalid"); + } + if p.port == 0 { + return bad("port", "invalid"); + } + match &p.key { + Some(k) if keychain::normalize(k).is_none() => bad("key", "invalid"), + None if p.id.is_none() => bad("key", "empty"), + _ => Ok(()), + } +} + +/// 试连对话框里填的这一条。**还没存**:编辑时没换密钥,就用钥匙串里那一把 +#[tauri::command] +pub async fn test_connection(input: ProfileInput) -> Out { + let key = match (&input.key, &input.id) { + (Some(k), _) => keychain::normalize(k).unwrap_or_default(), + (None, Some(id)) => keychain::load(id).map_err(|e| keychain_error(&e))?, + (None, None) => String::new(), + }; + let target = RemoteTarget { + host: input.host.trim().to_string(), + port: input.port, + key, + }; + Ok(match connector::test(&Target::Remote(target)).await { + Ok(info) => Tested::Ok { info }, + Err(error) => Tested::Failed { error }, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum Tested { + Ok { info: ServerInfo }, + Failed { error: ConnectError }, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum Saved { + Ok { profile: ProfileView }, + Invalid { invalid: Invalid }, +} + +/// 存一条连接:密钥先进钥匙串,名字和地址再进列表。**当前连着的那一条改了地址或 +/// 密钥,要重新连一次才生效** —— 这里顺手重连 +#[tauri::command] +pub async fn save_connection(app: tauri::AppHandle, input: ProfileInput) -> Out { + let dir = data_dir(); + let c = store::load(&dir); + if let Err(invalid) = check(&c, &input) { + return Ok(Saved::Invalid { invalid }); + } + let id = input.id.clone().unwrap_or_else(store::new_id); + if let Some(k) = input.key.as_deref().and_then(keychain::normalize) { + keychain::store(&id, &k).map_err(|e| keychain_error(&e))?; + } + let remote = Remote { + id: id.clone(), + name: input.name.trim().to_string(), + host: input.host.trim().to_string(), + port: input.port, + last_connected_at: c.remote(&id).and_then(|r| r.last_connected_at), + }; + let r2 = remote.clone(); + store::update(&dir, |c| { + match c.remotes.iter_mut().find(|r| r.id == r2.id) { + Some(old) => *old = r2, + None => c.remotes.push(r2), + } + }) + .map_err(saving)?; + let st = app.state::(); + if st.link.current().id() == id { + let _g = st.link.switching.lock().await; + begin_remote(&app, &st.link, remote.clone(), None); + } else { + announce(&app); + } + Ok(Saved::Ok { + profile: profile(&remote), + }) +} + +/// 删一条连接。**当前连着的删不掉**,要先切走;本机本来就删不掉 +#[tauri::command] +pub fn delete_connection(app: tauri::AppHandle, id: String) -> Out { + let st = app.state::(); + if id == LOCAL || st.link.current().id() == id { + return Err(CmdError::plain(tr!( + "当前连接不能删除,请先切换到其他连接。", + "The current connection cannot be deleted. Switch to another connection first." + ))); + } + store::update(&data_dir(), |c| { + c.remotes.retain(|r| r.id != id); + if c.last_used == id { + c.last_used = LOCAL.into(); + } + }) + .map_err(saving)?; + if let Err(e) = keychain::remove(&id) { + tracing::warn!("钥匙串里那一项没删掉:{e:#}"); + } + announce(&app); + Ok(view(&app)) +} + +/// 切过去之前要告诉用户的:这台机器上已接管、还指着本机网关的客户端 +#[tauri::command] +pub async fn switch_preflight(state: tauri::State<'_, AppState>) -> Out { + // 问本机的 core:已接管的客户端指着的是它。连着远程时它停着,数出来是 0 + Ok(crate::clients::adopted_pointing_at_local(state.supervisor.control()).await) +} + +#[tauri::command] +pub async fn switch_connection( + app: tauri::AppHandle, + id: String, + retarget_clients: bool, +) -> Result { + switch(&app, &id, retarget_clients).await?; + Ok(view(&app)) +} + +#[tauri::command] +pub fn retry_connection(state: tauri::State<'_, AppState>) { + state.link.retry_now(); +} + +fn saving(e: anyhow::Error) -> CmdError { + CmdError::plain(tr!( + format!("无法保存连接列表:{e:#}"), + format!("The connection list could not be saved: {e:#}") + )) +} + +fn keychain_error(e: &anyhow::Error) -> CmdError { + CmdError::plain(tr!( + format!("无法访问钥匙串:{e:#}"), + format!("The keychain could not be accessed: {e:#}") + )) +} + +// ------------------------------------------------------------------ 连接选择 + +/// 连接选择的小窗口 +pub const PICKER_WINDOW: &str = "picker"; + +/// 启动时先显示连接选择。**不依赖 core 的任何数据**:列表来自应用自己存的那一份 +pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<()> { + let reason = match why { + launch::Why::Option => "option", + launch::Why::Unfinished => "unfinished", + }; + tauri::WebviewWindowBuilder::new(app, PICKER_WINDOW, tauri::WebviewUrl::default()) + .title(tr!("选择连接", "Choose a Connection")) + .initialization_script(format!( + "{} window.__TW_PICK__ = {:?};", + crate::i18n::init_script(), + reason + )) + .inner_size(PICKER_WIDTH, 300.0) + .resizable(false) + .minimizable(false) + .maximizable(false) + .center() + .visible(false) + .build()?; + Ok(()) +} + +pub(crate) const PICKER_WIDTH: f64 = 420.0; + +/// 连接选择里选好了:连它,打开主界面(`settings`:落到设置页的「连接」一节) +#[tauri::command] +pub fn pick_connection(app: tauri::AppHandle, id: String, then: Option) -> Out<()> { + remember(&data_dir(), &id); + start(&app, &id); + match then { + Some(view) => notices::open_view(&app, view), + None => { + crate::window::show_main_window(&app).map_err(|e| CmdError::plain(e.to_string()))? + } + } + Ok(()) +} + +#[tauri::command] +pub fn picker_fit(window: tauri::Window, height: f64) -> tauri::Result<()> { + crate::window::fit(&window, PICKER_WIDTH, height) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 交给界面的几种结果**序列化得出来**。内部标签的枚举里装一个字符串、或者装另一个 + /// 同样用 `kind` 当标签的枚举,serde 在运行时才报错 —— 界面收到的是一句「序列化失败」 + #[test] + fn results_for_the_interface_serialize() { + let e = ConnectError::Timeout { addr: "h:1".into() }; + let v = serde_json::to_value(SwitchError::Connect { error: e.clone() }).unwrap(); + assert_eq!(v["kind"], "connect"); + assert_eq!(v["error"]["kind"], "timeout"); + let v = serde_json::to_value(SwitchError::Keychain { detail: "x".into() }).unwrap(); + assert_eq!(v["detail"], "x"); + let v = serde_json::to_value(Tested::Failed { error: e }).unwrap(); + assert_eq!(v["result"], "failed"); + let v = serde_json::to_value(LinkState::Down { + error: ConnectError::WrongKey, + attempt: 2, + at_ms: 1, + retry_in_ms: 4000, + ever: true, + }) + .unwrap(); + assert_eq!(v["kind"], "down"); + assert_eq!(v["error"]["kind"], "wrong_key"); + let v = serde_json::to_value(Saved::Invalid { + invalid: Invalid { + field: "name", + reason: "taken", + }, + }) + .unwrap(); + assert_eq!(v["invalid"]["field"], "name"); + } + + #[test] + fn retries_back_off_and_stop_growing() { + assert_eq!(backoff(1), Duration::from_secs(2)); + assert_eq!(backoff(2), Duration::from_secs(4)); + assert_eq!(backoff(6), Duration::from_secs(60)); + assert_eq!(backoff(40), Duration::from_secs(60)); + assert_eq!(backoff(0), Duration::from_secs(2)); + } + + /// 连远程时界面只见到两种:连上了照「运行中」取数、对账;没连上是 `unlinked`, + /// **不会是守护的「已停止」** —— 本机 core 在远程模式下本来就停着 + #[test] + fn the_active_state_follows_the_connection() { + let stopped = CoreState::Stopped; + assert_eq!(describe_active(&LinkState::Local, &stopped), "stopped"); + let info = ServerInfo { + core_version: "0.48.0".into(), + gateway_addr: None, + }; + assert!(describe_active(&LinkState::Connected { info }, &stopped).starts_with("running:")); + let down = LinkState::Down { + error: ConnectError::WrongKey, + attempt: 1, + at_ms: 0, + retry_in_ms: 2000, + ever: false, + }; + assert_eq!(describe_active(&down, &stopped), "unlinked"); + assert_eq!(describe_active(&LinkState::Waiting, &stopped), "unlinked"); + } + + fn input( + id: Option<&str>, + name: &str, + host: &str, + port: u16, + key: Option<&str>, + ) -> ProfileInput { + ProfileInput { + id: id.map(Into::into), + name: name.into(), + host: host.into(), + port, + key: key.map(Into::into), + } + } + + #[test] + fn the_dialog_is_checked_field_by_field() { + let c = store::Connections::default(); + let key = "a".repeat(64); + let field = |p: ProfileInput| check(&c, &p).err().map(|i| (i.field, i.reason)); + assert_eq!( + field(input(None, "nas", "10.0.3.7", 8789, Some(&key))), + None + ); + assert_eq!( + field(input(None, " ", "h", 1, Some(&key))), + Some(("name", "empty")) + ); + assert_eq!( + field(input(None, "本机", "h", 1, Some(&key))), + Some(("name", "taken")) + ); + assert_eq!( + field(input(None, "n", "a b", 1, Some(&key))), + Some(("host", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 0, Some(&key))), + Some(("port", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 1, Some("short"))), + Some(("key", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 1, None)), + Some(("key", "empty")) + ); + // 编辑时不填密钥就是沿用 + assert_eq!(field(input(Some("x"), "n", "h", 1, None)), None); + } +} diff --git a/src-tauri/src/connection/store.rs b/src-tauri/src/connection/store.rs new file mode 100644 index 00000000..d9407994 --- /dev/null +++ b/src-tauri/src/connection/store.rs @@ -0,0 +1,222 @@ +//! 连接列表:存在应用自己的数据目录里,**不是网关的配置**。 +//! +//! 界面外壳不能依赖 core —— 远程连不上时,连接列表、切换入口照样要能显示、能操作, +//! 所以它归应用自己保存(和 `prefs.rs` 同一个目录,另一个文件)。**密钥不在这里**: +//! 进系统钥匙串(`keychain.rs`),这个文件里只有名字和地址。 +//! +//! 「本机」不存:它是内置的、删不掉,列表里永远有它。 + +use std::path::{Path, PathBuf}; + +/// 设置文件,放在数据目录里 +const FILE: &str = "connections.json"; + +/// 「本机」的 id。**远程连接的 id 不会是它**:那些是随机生成的 +pub const LOCAL: &str = "local"; + +/// 一条远程连接 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Remote { + /// 随机生成,不随改名变。钥匙串里的那一项按它找 + pub id: String, + pub name: String, + /// 主机名或 IP + pub host: String, + /// 服务器配置里 `listen.control.remote.port` 的值 + pub port: u16, + /// 上一次连上是什么时候(毫秒)。没连上过是 None + pub last_connected_at: Option, +} + +/// 启动时连哪个 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Startup { + /// 上次用的那个。**出厂是它**:以服务器为主的人不用每次再切一遍 + #[default] + Last, + /// 永远先连本机 + Local, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Connections { + pub remotes: Vec, + /// 最近一次用的连接。切换成功才改它 + pub last_used: String, + pub startup: Startup, +} + +impl Default for Connections { + fn default() -> Self { + Self { + remotes: Vec::new(), + last_used: LOCAL.to_string(), + startup: Startup::Last, + } + } +} + +impl Connections { + pub fn remote(&self, id: &str) -> Option<&Remote> { + self.remotes.iter().find(|r| r.id == id) + } + + /// 这次启动该连哪个。上次用的那一条已经被删了,就是本机 + pub fn startup_target(&self) -> String { + match self.startup { + Startup::Local => LOCAL.to_string(), + Startup::Last if self.remote(&self.last_used).is_some() => self.last_used.clone(), + Startup::Last => LOCAL.to_string(), + } + } + + /// 名字有没有被别的连接用掉(大小写不分)。「本机」也算一个名字 + pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool { + let n = name.trim().to_lowercase(); + let local_names = ["本机", "this mac", "this computer", "local"]; + local_names.contains(&n.as_str()) + || self + .remotes + .iter() + .any(|r| Some(r.id.as_str()) != except && r.name.trim().to_lowercase() == n) + } +} + +fn path(dir: &Path) -> PathBuf { + dir.join(FILE) +} + +/// 读列表。**读不出来就是只有本机**:文件不在(第一次运行)和文件坏了,对用户的意义 +/// 一样 —— 为一份列表让应用起不来,代价不对。密钥在钥匙串里,不会跟着丢 +pub fn load(dir: &Path) -> Connections { + std::fs::read(path(dir)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +pub fn save(dir: &Path, c: &Connections) -> anyhow::Result<()> { + std::fs::create_dir_all(dir)?; + let mut text = serde_json::to_vec_pretty(c)?; + text.push(b'\n'); + // 先写临时文件再改名:写到一半断电,留下的是旧的那份而不是半份 + let tmp = dir.join(format!("{FILE}.tmp")); + std::fs::write(&tmp, text)?; + std::fs::rename(tmp, path(dir))?; + Ok(()) +} + +/// 改一处,其余照旧 +pub fn update(dir: &Path, f: impl FnOnce(&mut Connections) -> T) -> anyhow::Result { + let mut c = load(dir); + let out = f(&mut c); + save(dir, &c)?; + Ok(out) +} + +/// 新连接的 id:16 个十六进制字符 +pub fn new_id() -> String { + let mut b = [0u8; 8]; + rand::fill(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tw-conns-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn remote(id: &str, name: &str) -> Remote { + Remote { + id: id.into(), + name: name.into(), + host: "192.168.1.20".into(), + port: 8789, + last_connected_at: None, + } + } + + #[test] + fn with_no_file_there_is_only_this_mac() { + let dir = tmp("none"); + let c = load(&dir); + assert!(c.remotes.is_empty()); + assert_eq!(c.startup_target(), LOCAL); + } + + #[test] + fn a_corrupt_file_falls_back_to_this_mac() { + let dir = tmp("corrupt"); + std::fs::write(path(&dir), b"{ nope").unwrap(); + assert_eq!(load(&dir), Connections::default()); + } + + #[test] + fn the_list_survives_a_round_trip() { + let dir = tmp("trip"); + let want = Connections { + remotes: vec![remote("a1", "home-server")], + last_used: "a1".into(), + startup: Startup::Last, + }; + save(&dir, &want).unwrap(); + assert_eq!(load(&dir), want); + } + + /// 出厂是「上次使用的连接」;上次那一条删掉了,就回到本机 + #[test] + fn startup_follows_the_last_used_connection_while_it_exists() { + let mut c = Connections { + remotes: vec![remote("a1", "home-server")], + last_used: "a1".into(), + startup: Startup::default(), + }; + assert_eq!(c.startup_target(), "a1"); + c.startup = Startup::Local; + assert_eq!(c.startup_target(), LOCAL); + c.startup = Startup::Last; + c.remotes.clear(); + assert_eq!(c.startup_target(), LOCAL); + } + + #[test] + fn names_are_unique_and_this_mac_is_taken() { + let c = Connections { + remotes: vec![remote("a1", "home-server")], + ..Default::default() + }; + assert!(c.name_taken("Home-Server ", None)); + assert!( + !c.name_taken("home-server", Some("a1")), + "改自己的名字不算重名" + ); + assert!(c.name_taken("本机", None)); + assert!(!c.name_taken("office-nas", None)); + } + + /// 密钥永远不进这个文件 + #[test] + fn the_file_has_no_key_field() { + let text = serde_json::to_string(&Connections { + remotes: vec![remote("a1", "x")], + ..Default::default() + }) + .unwrap(); + assert!(!text.contains("key"), "{text}"); + } + + #[test] + fn ids_are_random_and_never_local() { + let (a, b) = (new_id(), new_id()); + assert_eq!(a.len(), 16); + assert_ne!(a, b); + assert_ne!(a, LOCAL); + } +} diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 14e72ff8..b779f56a 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -24,7 +24,10 @@ use tw_api::{Endpoint, Format, ep}; /// 两种传输各给一种,而**两种在所有平台上都编译**(只有 unix socket 那一支 /// 本身是 unix 专有的)。装箱是为了让上面那些函数只写一遍 —— 拿到流之后的 /// 每一件事,两边都该一模一样。 -trait Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {} +pub(crate) trait Stream: + tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send +{ +} impl Stream for T {} /// 控制面绑到了哪个端口。 @@ -65,15 +68,62 @@ fn refused(status: hyper::StatusCode, body: &[u8]) -> anyhow::Error { } } +/// 控制面在哪、凭什么进去。 +#[derive(Clone)] +pub enum Target { + /// 本机的 core:数据目录里的 socket(Windows 上是回环端口),凭据是这一次启动生成的 + Local { + at: tw_api::control::Address, + /// 这一次启动的凭据,见 `crate::token`。 + token: String, + }, + /// 另一台机器上的 core,见 `crate::connection` + Remote(crate::connection::connector::RemoteTarget), +} + +/// 控制面客户端。 +/// +/// **克隆出来的几份指着同一个地方。**应用同一时间只连一个 core,切换连接时 +/// [`ControlClient::retarget`] 换掉的是大家共用的那一份 —— 各页的命令、菜单栏、 +/// 事件桥手里拿的都是 `AppState.control` 的克隆,不用一个个去通知。守护和心跳 +/// 另外各建一份([`ControlClient::new`]),它们只管本机那个 core,切到远程也不跟着走。 +#[derive(Clone)] pub struct ControlClient { - at: tw_api::control::Address, - /// 这一次启动的凭据,见 `crate::token`。 - token: String, + target: std::sync::Arc>, + /// 换过几次地方。事件流靠它知道手上那条订阅已经连错了对象 + moved: std::sync::Arc>, } impl ControlClient { pub fn new(at: tw_api::control::Address, token: String) -> Self { - Self { at, token } + Self::to(Target::Local { at, token }) + } + + pub fn to(target: Target) -> Self { + Self { + target: std::sync::Arc::new(std::sync::RwLock::new(target)), + moved: std::sync::Arc::new(tokio::sync::watch::channel(0).0), + } + } + + /// 换一个 core 连。**所有克隆一起换**,正在订阅的事件流会被叫醒重连 + pub fn retarget(&self, target: Target) { + *self.target.write().expect("锁未中毒") = target; + self.moved.send_modify(|n| *n += 1); + } + + /// 连的是不是别的机器 + pub fn is_remote(&self) -> bool { + matches!(*self.target.read().expect("锁未中毒"), Target::Remote(_)) + } + + /// 换地方的通知 + pub fn moved(&self) -> tokio::sync::watch::Receiver { + self.moved.subscribe() + } + + fn target(&self) -> Target { + self.target.read().expect("锁未中毒").clone() } /// 连上控制面。 @@ -90,7 +140,17 @@ impl ControlClient { "core is not running, or has not finished starting" )) }; - match &self.at { + let at = match self.target() { + Target::Local { at, .. } => at, + // **远程那一档的门由握手把守**,见 `connection::connector` + Target::Remote(r) => { + return crate::connection::connector::open(&r) + .await + .map(|(stream, _)| stream) + .map_err(anyhow::Error::new); + } + }; + match &at { #[cfg(unix)] Address::Socket(path) => Ok(Box::new( tokio::net::UnixStream::connect(path) @@ -110,9 +170,12 @@ impl ControlClient { } } - /// 每个请求都带上的那个头。 + /// 每个请求都带上的那个头。**远程连接不带**:那边进门靠的是握手,不是这个头 fn auth(&self) -> String { - format!("Bearer {}", self.token) + match self.target() { + Target::Local { token, .. } => format!("Bearer {token}"), + Target::Remote(_) => String::new(), + } } /// 调一个端点。 diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index ba2b44d8..427b203a 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -185,11 +185,17 @@ pub async fn core_status(state: tauri::State<'_, AppState>) -> Out) -> Out { - // 连 core 都没找到时,守护状态说什么都没意义 —— 那句话才是答案 - if let Some(why) = &state.core_missing { + // 连 core 都没找到时,守护状态说什么都没意义 —— 那句话才是答案。**连着远程时 + // 不说这个**:用不着本机的 core + if let Some(why) = &state.core_missing + && !state.link.is_remote() + { return Ok(format!("missing:{why}")); } - Ok(describe_state(&state.supervisor.state())) + Ok(crate::connection::describe_active( + &state.link.state(), + &state.supervisor.state(), + )) } /// 把 core 拉起来。 @@ -206,6 +212,16 @@ pub async fn restart_core(app: tauri::AppHandle) -> Out<()> { pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { use std::sync::atomic::Ordering; let state = app.state::(); + // 启动时的连接选择还没选,就点了「重新启动网关」:那就是选了本机 + if crate::connection::waiting(app) { + crate::connection::start(app, crate::connection::store::LOCAL); + return Ok(()); + } + // **连着远程时不碰本机的 core**:「重新启动」在那时的意思是马上再连一次 + if state.link.is_remote() { + state.link.retry_now(); + return Ok(()); + } if let Some(why) = &state.core_missing { return Err(why.clone().into()); } @@ -222,6 +238,22 @@ pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { Ok(()) } +/// 守护没在跑就接回来;在跑就什么都不做。切回本机时用:远程模式下它停了 +pub(crate) fn ensure_supervising(app: &tauri::AppHandle) { + use std::sync::atomic::Ordering; + let state = app.state::(); + if state.core_missing.is_some() || state.supervising.swap(true, Ordering::SeqCst) { + return; + } + let sup = state.supervisor.clone(); + let flag = state.supervising.clone(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + supervise(sup, app).await; + flag.store(false, Ordering::SeqCst); + }); +} + /// 守护状态说成界面认得的那个字符串。 /// /// **只有这一处。**开窗时问一次、之后每次转换推一条,两条路必须说出 @@ -298,77 +330,110 @@ pub(crate) async fn heartbeat_loop( /// 断开的那一段里发生的事,事件流不会再说一遍 —— 界面要对一次账,否则那段时间 /// 里结束的请求,会一直显示成进行中。菜单栏的实时数是问 core 要的(`/live`), /// 叫醒它重收一次就对了。 -pub(crate) async fn bridge_events( - at: tw_api::control::Address, - token: String, - app: tauri::AppHandle, -) { +/// +/// **跟着当前连接走。**用的是 `AppState.control`:切换连接时它换了地方,这里手上那条 +/// 订阅随即作废、连新的那个。连着远程时事件流断了就是断线,告诉连接那一层 +pub(crate) async fn bridge_events(app: tauri::AppHandle) { + let Some(st) = app.try_state::() else { + return; + }; + let client = st.control.clone(); + let mut moved = client.moved(); + let mut link = st.link.watch(); let mut connected_before = false; loop { - let client = ControlClient::new(at.clone(), token.clone()); + moved.mark_unchanged(); + // **连着远程、还没连上时不去订阅**:连接那一层自己在按退避重连,这里再每秒 + // 连一次,就是往服务器上多打一份不带退避的连接。等它连上(或者换了连接) + while !matches!( + *link.borrow_and_update(), + crate::connection::LinkState::Local | crate::connection::LinkState::Connected { .. } + ) { + if link.changed().await.is_err() { + return; + } + } let opened = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (a, b, o) = (app.clone(), app.clone(), opened.clone()); let resumed = connected_before; - let r = client - .subscribe_events( - move || { - o.store(true, std::sync::atomic::Ordering::SeqCst); - // **每次接上都按现状对一次账**:接上之前 core 报过的(启动时的凭据 - // 失效、断线那段里被拒的配置),事件流不会再说一遍 - reconcile_notices(&b); - if resumed { - let lost = tw_api::Event::EventsDropped { - id: 0, - count: 0, - at_ms: notices::now_ms(), - }; - if let Some(st) = b.try_state::() { - st.menubar.notify_one(); - } - let _ = b.emit("core-event", &lost); - } - }, - move |ev| { - // **在客户端弹批准提示的同一瞬间弹一条通知**。 - // 这是网关位置独有的能力:只有我们同时知道「这个调用长 - // 什么样」和「它来自哪个上游」。用户看到批准提示的同时 - // 看到这条,判断质量完全不一样。 - if let Some(n) = a.try_state::>() { - n.on_event(&ev); - } - // 菜单栏上那几个数只跟这几种事件有关:花了多少(请求 - // 落地之后存储层才算得出来)、额度还剩多少、进行中几个。 - // 别的事件叫醒它只是让它白跑一趟。**事件只是叫醒它,数 - // 由 core 给**(`/live`):掉过队的话(`EventsDropped`) - // 重收一次也就对上了 - if let Some(st) = a.try_state::() - && matches!( - ev, - tw_api::Event::RequestStarted { .. } - | tw_api::Event::RequestFinished { .. } - | tw_api::Event::RequestFailed { .. } - | tw_api::Event::RequestCancelled { .. } - | tw_api::Event::QuotaSeen { .. } - | tw_api::Event::ConfigReloaded { .. } - | tw_api::Event::EventsDropped { .. } - ) - { + let sub = client.subscribe_events( + move || { + o.store(true, std::sync::atomic::Ordering::SeqCst); + // **每次接上都按现状对一次账**:接上之前 core 报过的(启动时的凭据 + // 失效、断线那段里被拒的配置),事件流不会再说一遍 + reconcile_notices(&b); + if resumed { + let lost = tw_api::Event::EventsDropped { + id: 0, + count: 0, + at_ms: notices::now_ms(), + }; + if let Some(st) = b.try_state::() { st.menubar.notify_one(); } - // core 说这个订阅者掉过队:掉的那几条里可能有该提醒的事 - if matches!(ev, tw_api::Event::EventsDropped { .. }) { - reconcile_notices(&a); - } - let _ = a.emit("core-event", &ev); - }, - ) - .await; - if let Err(e) = r { - tracing::debug!("事件流断开:{e:#}"); + let _ = b.emit("core-event", &lost); + } + }, + move |ev| { + // **在客户端弹批准提示的同一瞬间弹一条通知**。 + // 这是网关位置独有的能力:只有我们同时知道「这个调用长 + // 什么样」和「它来自哪个上游」。用户看到批准提示的同时 + // 看到这条,判断质量完全不一样。 + if let Some(n) = a.try_state::>() { + n.on_event(&ev); + } + // 菜单栏上那几个数只跟这几种事件有关:花了多少(请求 + // 落地之后存储层才算得出来)、额度还剩多少、进行中几个。 + // 别的事件叫醒它只是让它白跑一趟。**事件只是叫醒它,数 + // 由 core 给**(`/live`):掉过队的话(`EventsDropped`) + // 重收一次也就对上了 + if let Some(st) = a.try_state::() + && matches!( + ev, + tw_api::Event::RequestStarted { .. } + | tw_api::Event::RequestFinished { .. } + | tw_api::Event::RequestFailed { .. } + | tw_api::Event::RequestCancelled { .. } + | tw_api::Event::QuotaSeen { .. } + | tw_api::Event::ConfigReloaded { .. } + | tw_api::Event::EventsDropped { .. } + ) + { + st.menubar.notify_one(); + } + // core 说这个订阅者掉过队:掉的那几条里可能有该提醒的事 + if matches!(ev, tw_api::Event::EventsDropped { .. }) { + reconcile_notices(&a); + } + let _ = a.emit("core-event", &ev); + }, + ); + let switched = tokio::select! { + r = sub => { + if let Err(e) = r { + tracing::debug!("事件流断开:{e:#}"); + } + false + } + _ = moved.changed() => true, + }; + let was_open = opened.load(std::sync::atomic::Ordering::SeqCst); + if was_open && !switched { + st.link.stream_lost(); + } + // 接通过才算连上过:core 重启期间那几轮连不上的不算。**换了连接从头算**: + // 新连上的那个 core 界面会整个重新取一遍,不用补报丢事件 + connected_before = if switched { + false + } else { + connected_before | was_open + }; + if !switched { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} + _ = moved.changed() => {} + } } - // 接通过才算连上过:core 重启期间那几轮连不上的不算 - connected_before |= opened.load(std::sync::atomic::Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f535bf90..7d42915f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::Arc; -use tauri::{Emitter, Manager}; +use tauri::Manager; // 第一个声明:`tr!` 要在后面每个模块里都能用 #[macro_use] @@ -15,6 +15,7 @@ pub mod autostart; pub mod call; pub mod chatgpt; pub mod clients; +pub mod connection; pub mod control; pub mod core_text; pub mod dashboard; @@ -51,10 +52,7 @@ pub mod window; pub mod zai; use control::ControlClient; -use gateway::{ - CORE_EXE, bridge_events, control_address, describe_state, heartbeat_loop, locate_core, - supervise, -}; +use gateway::{CORE_EXE, bridge_events, control_address, heartbeat_loop, locate_core, supervise}; use settings::{check_autostart_path, maybe_notify_first_autostart}; use supervisor::Supervisor; use updater::{Updates, announce_update, update_loop}; @@ -63,7 +61,11 @@ use window::become_accessory; use window::{open_urls, show_main_window}; pub struct AppState { + /// 控制面客户端,**指着当前连接的那个 core**:本机的,或者远程的。切换连接时 + /// 由 `connection` 换掉它的目标,各处拿着的克隆一起跟着换 pub control: ControlClient, + /// 当前连哪个 core、连到了哪一步。见 `connection` + pub link: Arc, pub supervisor: Arc, /// 连 twcore 都没找到时,那句话。 /// @@ -155,6 +157,16 @@ pub fn run() { gateway::core_status, gateway::core_state, window::reveal_main_window, + connection::connections, + connection::set_connection_startup, + connection::test_connection, + connection::save_connection, + connection::delete_connection, + connection::switch_preflight, + connection::switch_connection, + connection::retry_connection, + connection::pick_connection, + connection::picker_fit, gateway::restart_core, dashboard::dashboard, upstreams::upstream_stats, @@ -280,8 +292,24 @@ pub fn run() { saved.notices, ); app.manage(notices.clone()); + // 这次连哪个。**按住 ⌥ 启动,或者上两次启动都没走到就绪**,先让人选 + let dir = data_dir(); + let attempts = connection::launch::begin(&dir); + let pick = connection::launch::why(attempts, connection::launch::option_held()); + let conns = connection::store::load(&dir); + let start_id = conns.startup_target(); + let local = control::Target::Local { + at: at.clone(), + token: token.clone(), + }; + let link = Arc::new(connection::Link::new( + handle.clone(), + local.clone(), + connection::Current::Local, + )); app.manage(AppState { - control: ControlClient::new(at.clone(), token.clone()), + control: ControlClient::to(local), + link: link.clone(), supervisor: sup.clone(), core_missing: located.as_ref().err().map(|e| format!("{e:#}")), supervising: supervising.clone(), @@ -303,14 +331,26 @@ pub fn run() { if let Some(n) = h.try_state::>() { n.on_core_state(&now); } - let _ = h.emit("core-state", describe_state(&now)); + connection::on_supervisor(&h, &now); + // 界面收到的是**当前连接**的状态:连着远程时本机 core 停下, + // 界面上不该出现「已停止」 + connection::emit_core_state(&h); } }); } // 守护循环。**它跑在后台任务里而不是阻塞 setup** —— core 起 // 不来的时候,界面必须还能打开,否则用户连错误都看不到。 - if located.is_ok() { + // + // **只在连本机时起。**连远程时本机的 core 不跑;要先让人选的话,选好了再起 + let remote_start = pick.is_some() || conns.remote(&start_id).is_some(); + if remote_start { + if pick.is_some() { + link.wait_for_pick(); + } else { + connection::start(&handle, &start_id); + } + } else if located.is_ok() { supervising.store(true, std::sync::atomic::Ordering::SeqCst); let h = handle.clone(); let sup_for_loop = sup.clone(); @@ -344,7 +384,12 @@ pub fn run() { // 静默启动:开机拉起来的时候屏幕上什么都不该出现, // 只有菜单栏多一个图标。**图标已经在上面建好了** —— 它不等 // core 就绪,否则用户开机后会有一段「到底启没启」的空白期。 - if autostart::launched_by_autostart(std::env::args()) { + if let Some(why) = pick { + // 连接选择先出来,主窗口等选好了再开。**开机自启时也出来**:只有 + // 连着两次没走到就绪才会走到这里,那时再悄悄撞一次不如问一句 + tracing::info!(?why, "启动时先显示连接选择"); + connection::show_picker(&handle, why)?; + } else if autostart::launched_by_autostart(std::env::args()) { tracing::info!("开机自启,不开窗口"); #[cfg(target_os = "macos")] become_accessory(&handle); @@ -389,9 +434,8 @@ pub fn run() { // 事件桥:控制面的 SSE → Tauri 事件 → 前端。 let h = handle.clone(); - let (at3, tok3) = (at.clone(), token.clone()); tauri::async_runtime::spawn(async move { - bridge_events(at3, tok3, h).await; + bridge_events(h).await; }); // 有没有新版本。**循环无条件起,开关在循环里读** —— 用户在 diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 0e2c9001..1eee9e6f 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -412,6 +412,9 @@ fn fill_item(mtm: MainThreadMarker, ui: &mut Ui, item: &NSMenuItem, i: &super::m } }; for m in &i.submenu { + if m.sep_before { + sub.addItem(&NSMenuItem::separatorItem(mtm)); + } let one = NSMenuItem::new(mtm); one.setTitle(&NSString::from_str(&m.title)); one.setState(if m.checked { diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 23d95a44..1a474238 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -249,7 +249,17 @@ struct Credits { async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits) -> Snapshot { let now_ms = notices::now_ms(); + let current = state.link.current(); + let remote = match ¤t { + crate::connection::Current::Remote(r) => Some(r.name.clone()), + crate::connection::Current::Local => None, + }; let gateway = match state.supervisor.state() { + // 连着远程:看的是那一条连接,本机的 core 本来就停着 + _ if remote.is_some() => match state.link.state() { + crate::connection::LinkState::Connected { .. } => Gateway::Running, + _ => Gateway::Unlinked, + }, _ if state.core_missing.is_some() => Gateway::Failed, CoreState::Running { .. } => Gateway::Running, CoreState::Starting | CoreState::Restarting { .. } => Gateway::Starting, @@ -264,6 +274,16 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits .is_some_and(|n| n.mode() != notices::Mode::Off), notices: notices.map(|n| unread(&n.list())).unwrap_or_default(), update: crate::updater::pending_update(app), + connections: crate::connection::view(app) + .profiles + .into_iter() + .map(|p| model::Connection { + current: p.id == current_id(¤t), + id: p.id, + name: p.name, + }) + .collect(), + remote, now_ms, gateway, ..Default::default() @@ -365,6 +385,13 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits snap } +fn current_id(c: &crate::connection::Current) -> &str { + match c { + crate::connection::Current::Local => crate::connection::store::LOCAL, + crate::connection::Current::Remote(r) => &r.id, + } +} + /// 未读的提醒,要紧的在前,同样要紧的新的在前 fn unread(list: &[notices::Notice]) -> Vec { let mut out: Vec<(model::NoticeLine, u64)> = list @@ -403,6 +430,11 @@ fn handle(app: &tauri::AppHandle, action: Action) { // 窗口可能是为这一下新建的,事件会错过:和落页一样存下来,界面挂上之后自己取 Action::AllNotices => notices::open_view(&app, "notices".to_string()), Action::InstallUpdate => crate::updater::show_pending_update(&app), + // 切到远程要先试连、再确认(确认框里要说哪些客户端还指着本机):交给主界面 + // 走和侧栏同一条路。切回本机不用确认,直接切 + Action::SwitchConnection(id) if id != crate::connection::store::LOCAL => { + notices::open_view(&app, format!("switch:{id}")) + } Action::Quit => quit(&app), other => { tauri::async_runtime::spawn(async move { background(&app, other).await }); @@ -432,6 +464,13 @@ async fn background(app: &tauri::AppHandle, action: Action) { Action::RestartGateway => crate::gateway::restart_gateway(app) .await .map_err(|e| e.to_string()), + Action::RetryConnection => { + st.link.retry_now(); + Ok(()) + } + Action::SwitchConnection(id) => crate::connection::switch(app, &id, false) + .await + .map_err(|e| format!("{e:?}")), Action::CheckUpdates => { check_updates(app).await; Ok(()) diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 1179477e..9804798f 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -27,6 +27,8 @@ pub enum Gateway { Failed, /// 停下了,没有在重启 Stopped, + /// 连着远程 core,眼下连不上(正在重连) + Unlinked, } impl Gateway { @@ -61,9 +63,20 @@ pub struct Snapshot { pub undo_at_ms: Option, /// 查到了、还没装的新版本 pub update: Option, + /// 连接列表,本机在最前。「连接」子菜单照它列 + pub connections: Vec, + /// 连着远程时是那台服务器的名字。状态头写它,不写应用名 + pub remote: Option, pub now_ms: u64, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Connection { + pub id: String, + pub name: String, + pub current: bool, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Today { pub requests: i64, @@ -145,6 +158,10 @@ pub enum Action { provider: String, }, RestartGateway, + /// 连着远程、连不上时:马上再连一次 + RetryConnection, + /// 切到这个连接。本机直接切;远程要先试连、确认,在主界面里做 + SwitchConnection(String), CheckUpdates, InstallUpdate, Quit, @@ -209,6 +226,8 @@ pub struct SubItem { pub title: String, pub checked: bool, pub action: Action, + /// 前面隔一条线 + pub sep_before: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -435,6 +454,7 @@ fn state_text(g: &Gateway) -> &'static str { Gateway::SafeMode => tr!("未在转发", "Not Forwarding"), Gateway::Failed => tr!("无法启动", "Cannot Start"), Gateway::Stopped => tr!("未运行", "Not Running"), + Gateway::Unlinked => tr!("未连接", "Not Connected"), } } @@ -452,7 +472,21 @@ fn live_count(n: usize) -> String { fn rows(s: &Snapshot) -> Vec { let mut out = vec![header(s), Row::Separator]; if !s.gateway.running() { - if !matches!(s.gateway, Gateway::Starting) { + if s.gateway == Gateway::Unlinked { + out.push(Row::Item(Item::new( + "retry", + "arrow.clockwise", + tr!("立即重试", "Retry Now"), + Action::RetryConnection, + ))); + out.push(Row::Item(Item::new( + "why", + "info.circle", + tr!("查看原因…", "Show Details…"), + Action::OpenMain, + ))); + out.push(Row::Separator); + } else if !matches!(s.gateway, Gateway::Starting) { out.push(Row::Item(Item::new( "restart", "arrow.clockwise", @@ -601,6 +635,7 @@ fn rows(s: &Snapshot) -> Vec { group: g.name.clone(), provider: m.clone(), }, + sep_before: false, }) .collect(); out.push(Row::Item(item)); @@ -675,9 +710,18 @@ fn header(s: &Snapshot) -> Row { tr!("转发已停止。", "Forwarding has stopped.").to_string(), true, ), + Gateway::Unlinked => ( + StateTone::Bad, + tr!("正在重新连接。", "Reconnecting.").to_string(), + true, + ), }; Row::Header { - title: "ThinkWatch Lite".to_string(), + // 连着远程时写服务器的名字:菜单里的数字、提醒都是那台的 + title: s + .remote + .clone() + .unwrap_or_else(|| "ThinkWatch Lite".to_string()), state: state_text(&s.gateway).to_string(), tone, line2, @@ -699,6 +743,7 @@ fn app_items(s: &Snapshot, out: &mut Vec) { tr!("打开主界面", "Open ThinkWatch Lite"), Action::OpenMain, ))); + out.push(Row::Item(connections(s))); out.push(Row::Item( Item::new( "settings", @@ -737,6 +782,35 @@ fn app_items(s: &Snapshot, out: &mut Vec) { )); } +/// 「连接」子菜单:主窗口关着也能切。**本机永远在第一个**,任何状态下一步就能切回来 +fn connections(s: &Snapshot) -> Item { + let mut item = Item::new( + "connections", + "network", + tr!("连接", "Connection"), + Action::OpenMain, + ); + item.action = None; + let mut subs: Vec = s + .connections + .iter() + .map(|c| SubItem { + title: c.name.clone(), + checked: c.current, + action: Action::SwitchConnection(c.id.clone()), + sep_before: false, + }) + .collect(); + subs.push(SubItem { + title: tr!("管理连接…", "Manage Connections…").to_string(), + checked: false, + action: Action::Settings, + sep_before: true, + }); + item.submenu = subs; + item +} + fn window_row(w: &Window, now_ms: u64) -> WindowRow { if !current(w, now_ms) { return WindowRow { diff --git a/src-tauri/src/menubar/tray.rs b/src-tauri/src/menubar/tray.rs index 4ab0b75c..607066eb 100644 --- a/src-tauri/src/menubar/tray.rs +++ b/src-tauri/src/menubar/tray.rs @@ -230,6 +230,18 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result } else { let mut subs: Vec>> = Vec::new(); for m in &i.submenu { + // 隔在线后面的是动作(「管理连接…」),不是几个里选一个:不画成勾选项 + if m.sep_before { + subs.push(Box::new(PredefinedMenuItem::separator(app)?)); + subs.push(Box::new(MenuItem::with_id( + app, + id(&m.action), + &m.title, + true, + None::<&str>, + )?)); + continue; + } subs.push(Box::new(CheckMenuItem::with_id( app, id(&m.action), diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index 20d25b86..3f8afeff 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -66,7 +66,8 @@ pub fn default_view(key: &str) -> &'static str { /// **网关不在服务时不必再说它下面每一家怎么了** —— 那时用户要做的只有一件事。 pub fn suppresses(key: &str) -> &'static [&'static str] { match key { - "gateway" => &[ + // 连着的远程 core 断了:和本机网关停了一样,它下面每一家怎么了都不必再说 + "gateway" | "remote" => &[ "upstream:", "quota:", "credential:", @@ -644,6 +645,33 @@ pub fn wedged() -> Signal { .now() } +/// 和远程 core 的连接断了。**只报一次**:总线按键去重,重连的每一次失败都不再说; +/// 连上之后由 [`remote_back`] 收起 +pub fn remote_lost(name: &str) -> Signal { + Signal::raised( + "remote", + Level::Critical, + tr!( + format!("与 {name} 的连接已断开"), + format!("Disconnected from “{name}”") + ), + ) + .body( + tr!( + "正在重新连接。连接恢复前,应用中的内容停留在断开时的状态。", + "Reconnecting. Until the connection is restored, the app shows the state at the time of the disconnect." + ) + .to_string(), + ) + .view(SETTINGS) + .now() + .suppressing(suppresses("remote")) +} + +pub fn remote_back() -> Signal { + Signal::cleared("remote") +} + /// `5h` / `weekly` → 「5 小时」「每周」(英文是 `5-hour`、`weekly`)。认不出来的原样用 fn window_label(w: &str) -> String { match w { diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index 92259a1e..f1b5db3e 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -356,6 +356,12 @@ impl Supervisor { args } + /// 本机 core 的控制面客户端。**永远指着本机**,切到远程也不跟着走:等本机手上的 + /// 请求结束、再停掉它,问的都是本机这一个 + pub fn control(&self) -> &crate::control::ControlClient { + &self.control + } + pub fn binary(&self) -> &PathBuf { &self.binary } diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index d1795d3e..8e797ac6 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -179,40 +179,10 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { Ok(()) } -/// 把更新窗口调成网页量出来的那么高。 -/// -/// **窗口有多高,不等于网页有多高。**Tauri 在 macOS 上建的是一扇 -/// `FullSizeContentView` 的窗:内容视图铺满整扇窗户,标题栏盖在它上面, -/// webview 只摆在标题栏底下那一块。而 `set_size` 说的是整扇窗户 -/// (`inner_size()` 和 `outer_size()` 在这里报的也是同一个数,所以标题栏 -/// 有多高,从它们之间也减不出来)—— 照着网页量出来的高度设下去,网页拿到 -/// 的就少了一条标题栏,内容的最后一截被窗口下沿切掉:底部留白没了,那排 -/// 按钮只剩上半截。 -/// -/// 标题栏多高不写死 —— 各版本不一样(Tahoe 上是 32 点),没有标题栏的窗 -/// 是 0。`contentLayoutRect` 给的正是没被标题栏盖住的那一块,和整扇窗户 -/// 一减就是要补上的数。 +/// 把更新窗口调成网页量出来的那么高。理由见 [`crate::window::fit`] #[tauri::command] pub fn update_fit(window: tauri::Window, height: f64) -> tauri::Result<()> { - #[cfg(target_os = "macos")] - { - let w = window.clone(); - // **走 Tauri 的主线程队列,不走 GCD。**紧跟在这之后网页会把窗口 - // 亮出来,那一步排在同一条队列上;换一条队列,用户就会先看见一扇 - // 大小还没调好的窗 - window.run_on_main_thread(move || { - let Ok(ptr) = w.ns_window() else { return }; - // SAFETY: `ns_window()` 给的是这扇窗的 NSWindow,这里在主线程上 - let ns = unsafe { &*ptr.cast::() }; - let titlebar = ns.frame().size.height - ns.contentLayoutRect().size.height; - ns.setContentSize(objc2_foundation::NSSize::new( - UPDATE_WIDTH, - height + titlebar, - )); - }) - } - #[cfg(not(target_os = "macos"))] - window.set_size(tauri::LogicalSize::new(UPDATE_WIDTH, height)) + crate::window::fit(&window, UPDATE_WIDTH, height) } /// 更新那条系统通知的键。点开它拉起的是更新窗口,不是主界面的某一页 @@ -347,43 +317,35 @@ pub(crate) enum Step { /// /// 问不到(core 不在跑、控制面没答应)就不等 —— 没有网关,也就没有要保护 /// 的请求。 -pub(crate) async fn wait_for_quiet(app: &tauri::AppHandle, control: &ControlClient) { +pub(crate) async fn wait_for_quiet(control: &ControlClient, on_wait: impl Fn(usize)) { let started = std::time::Instant::now(); let mut waited = false; loop { let s = match control.status().await { Ok(s) => s, Err(e) => { - tracing::info!("更新:问不到网关的状态,不等({e:#})"); + tracing::info!("等请求结束:问不到网关的状态,不等({e:#})"); return; } }; if s.in_flight == 0 { if waited { - tracing::info!("更新:请求都结束了,等了 {:?}", started.elapsed()); + tracing::info!("等请求结束:都结束了,等了 {:?}", started.elapsed()); } return; } if started.elapsed() >= DRAIN_LIMIT { tracing::warn!( - "更新:等了 {DRAIN_LIMIT:?} 还有 {} 个请求没结束,照常重启", + "等请求结束:等了 {DRAIN_LIMIT:?} 还有 {} 个请求没结束,不再等", s.in_flight ); return; } if !waited { - tracing::info!( - "更新:网关手上还有 {} 个请求,等它们结束再重启", - s.in_flight - ); + tracing::info!("等请求结束:网关手上还有 {} 个请求", s.in_flight); waited = true; } - let _ = app.emit( - "update-step", - Step::Waiting { - in_flight: s.in_flight, - }, - ); + on_wait(s.in_flight); tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } @@ -471,7 +433,11 @@ pub async fn update_install( ) })?; - wait_for_quiet(&app, &state.control).await; + // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 + wait_for_quiet(state.supervisor.control(), |in_flight| { + let _ = app.emit("update-step", Step::Waiting { in_flight }); + }) + .await; let _ = app.emit("update-step", Step::Installing); // 起来之后说一声换到了哪一版。写不进去不影响更新本身。 diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 2e660215..a8ba8ff3 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -101,9 +101,15 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { // 热启动还藏着的时候又点了一次,也是这里:不等了,直接出来 return reveal(app, &w); } + // 「core 已经在跑」按当前连接算:连着远程时本机的 core 本来就停着 let warm = app .try_state::() - .is_some_and(|s| matches!(s.supervisor.state(), CoreState::Running { .. })); + .is_some_and(|s| match s.link.state() { + crate::connection::LinkState::Local => { + matches!(s.supervisor.state(), CoreState::Running { .. }) + } + state => matches!(state, crate::connection::LinkState::Connected { .. }), + }); let b = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("ThinkWatch Lite") .initialization_script(format!( @@ -166,6 +172,38 @@ pub(crate) fn reveal(app: &tauri::AppHandle, w: &tauri::WebviewWindow) -> tauri: w.set_focus() } +/// 把一扇小窗调成网页量出来的那么大(更新窗口、连接选择)。 +/// +/// **窗口有多高,不等于网页有多高。**Tauri 在 macOS 上建的是一扇 +/// `FullSizeContentView` 的窗:内容视图铺满整扇窗户,标题栏盖在它上面, +/// webview 只摆在标题栏底下那一块。而 `set_size` 说的是整扇窗户 +/// (`inner_size()` 和 `outer_size()` 在这里报的也是同一个数,所以标题栏 +/// 有多高,从它们之间也减不出来)—— 照着网页量出来的高度设下去,网页拿到 +/// 的就少了一条标题栏,内容的最后一截被窗口下沿切掉:底部留白没了,那排 +/// 按钮只剩上半截。 +/// +/// 标题栏多高不写死 —— 各版本不一样(Tahoe 上是 32 点),没有标题栏的窗 +/// 是 0。`contentLayoutRect` 给的正是没被标题栏盖住的那一块,和整扇窗户 +/// 一减就是要补上的数。 +pub(crate) fn fit(window: &tauri::Window, width: f64, height: f64) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + let w = window.clone(); + // **走 Tauri 的主线程队列,不走 GCD。**紧跟在这之后网页会把窗口 + // 亮出来,那一步排在同一条队列上;换一条队列,用户就会先看见一扇 + // 大小还没调好的窗 + window.run_on_main_thread(move || { + let Ok(ptr) = w.ns_window() else { return }; + // SAFETY: `ns_window()` 给的是这扇窗的 NSWindow,这里在主线程上 + let ns = unsafe { &*ptr.cast::() }; + let titlebar = ns.frame().size.height - ns.contentLayoutRect().size.height; + ns.setContentSize(objc2_foundation::NSSize::new(width, height + titlebar)); + }) + } + #[cfg(not(target_os = "macos"))] + window.set_size(tauri::LogicalSize::new(width, height)) +} + /// 没有窗口时退回菜单栏应用:不占 Dock、不进 ⌘Tab。 #[cfg(target_os = "macos")] pub(crate) fn become_accessory(app: &tauri::AppHandle) { diff --git a/src/App.tsx b/src/App.tsx index 7208cfcf..71ba5fdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,11 @@ import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { trouble } from "./launch/trouble"; import { LaunchScreen } from "./launch/LaunchScreen"; import { warm } from "./launch/warm"; +import { ConnectionProvider, useConnections } from "./connection/ConnectionProvider"; +import { Switcher } from "./connection/Switcher"; +import { Unlinked } from "./connection/Unlinked"; +import { currentProfile } from "./connection/api"; +import { connText } from "./connection/connection.i18n"; import { Sidebar, SidebarContent, @@ -108,6 +113,13 @@ const MAX_TRIES = 8; /** 概览的几个重读理由挨着到时,等这么久再读:一串只读一次 */ const COALESCE_MS = 100; +/** + * 启动画面最多等多久。**过了就照常打开窗口**,内容区显示「未连接」那一页、后台接着 + * 重连 —— 而不是一直转圈。界面外壳不该依赖 core 连没连上:连接管理在设置里,那一页 + * 这时必须能用 + */ +const LAUNCH_CAP_MS = 8_000; + /** core 的状态字符串来自 Rust 侧的 CoreState,见 supervisor/mod.rs。 */ /** * 主窗口的几个面。 @@ -265,8 +277,40 @@ function describeCore(raw: string): { */ const drag = isMac ? { "data-tauri-drag-region": true } : {}; +/** + * 主窗口。 + * + * **换了连接,主界面整个重挂。**流量、会话、概览、各页的缓存都属于原来那个 core —— + * 两边的请求编号还会重叠 —— 一样样去清,漏一处就是把一台机器的数据安在另一台头上。 + * 连接的对话框挂在重挂的那一层外面(`ConnectionProvider`),切换那一下不跟着消失。 + */ export default function App() { + return ( + + + + ); +} + +function PerConnection() { + const { view } = useConnections(); + // 第一次读到的那个连接不算「换了」:那是启动时本来就要连的 + const seen = useRef<{ id: string | null; n: number }>({ id: null, n: 0 }); + if (view && seen.current.id !== view.current) { + if (seen.current.id !== null) seen.current.n += 1; + seen.current.id = view.current; + } + return ; +} + +function Shell({ first }: { first: boolean }) { const t = useText(appText); + const ct = useText(connText); + const conn = useConnections(); + const profile = conn.view ? currentProfile(conn.view) : undefined; + /** 连着的是远程 core */ + const remote = profile !== undefined && !profile.local; + const link = conn.view?.link ?? null; const common = useText(commonText); /** * 守护状态。**先当它在起**:第一次读到之前画「已停止」的话,启动画面会先 @@ -417,7 +461,18 @@ export default function App() { * 启动画面还在。**只在冷启动、这次开窗第一次交接之前**,见 `LaunchScreen`。 * 热启动没有这一面,窗口藏到交接那一刻才出现(见 `warm`) */ - const [launching, setLaunching] = useState(!warm); + const [launching, setLaunching] = useState(!warm && first); + /** 启动画面等够了(或者远程说了一个等多久都不会好的原因):不再等,交出主界面 */ + const [gaveUp, setGaveUp] = useState(false); + useEffect(() => { + const h = setTimeout(() => setGaveUp(true), LAUNCH_CAP_MS); + return () => clearTimeout(h); + }, []); + const lasting = + link?.kind === "down" && + (link.error.kind === "wrong_key" || + link.error.kind === "version_mismatch" || + link.error.kind === "not_yet_available"); /** 概览那一页的第一份数据到了。启动画面等它,交接时数字已经是对的 */ const [landed, setLanded] = useState(false); /** 连上之后首屏迟迟取不齐:不再等,交给那一页自己的骨架 */ @@ -574,6 +629,11 @@ export default function App() { } // `requests:42`:打开流量页并展开那一条(菜单栏里点了一个进行中的请求) const [page, id] = view.split(":"); + // `switch:`:菜单栏里选了一条远程连接。试连和确认在这里做,和侧栏同一条路 + if (page === "switch" && id) { + switchTo.current(id); + return; + } setTab(page as Surface); if (page === "requests" && id) setOpen(Number(id)); }; @@ -584,11 +644,19 @@ export default function App() { go(e.payload); void invoke("take_pending_view").catch(() => {}); }); + // 连接那一层(切换器里的「管理连接…」)要落页时发的 + const local = (e: Event) => go((e as CustomEvent).detail); + window.addEventListener("tw-open-view", local); return () => { un.then((f) => f()); + window.removeEventListener("tw-open-view", local); }; }, []); + /** 切换连接的入口。**用 ref**:落页那个 effect 只挂一次,而连接列表是后来才读到的 */ + const switchTo = useRef(conn.switchTo); + switchTo.current = conn.switchTo; + /** * 列表的键盘导航。 * @@ -827,8 +895,15 @@ export default function App() { }, [reloads, nudge, health, models, listening, upstreamState, core, setStatus, setOv]); const c = describeCore(core); - /** 连上过、又断了。**只在这时候挂那条带子** */ - const lost = linked && tries > 0 ? trouble(core, tries) : null; + /** 连上过、又断了。**只在这时候挂那条带子**。连着远程时断线另有一条(见下面) */ + const lost = linked && tries > 0 && !remote ? trouble(core, tries) : null; + /** + * 连着远程、连上过、现在断了(设计稿 ⑥ 右)。**不换成「未连接」整页**:已有的内容 + * 留着、置为只读,顶上一条横幅。连上后按现有的对账机制补齐(`core-state` 回到 + * `running:` 时各处自己对账) + */ + const remoteLost = remote && linked && link !== null && link.kind !== "connected"; + const remoteAttempt = link?.kind === "down" ? link.attempt : link?.kind === "connecting" ? link.attempt : 0; useEffect(() => { if (!linked) return; @@ -840,7 +915,10 @@ export default function App() { 被盖着;交接时概览(和落地页那一页的数据)已经在了,数字不会在眼前从零 跳成实际值。取不齐就不等了(`waited`),那一页有自己的骨架。 */ - const handover = linked && ((ov !== null && (tab !== "dashboard" || landed)) || waited); + const handover = + (linked && ((ov !== null && (tab !== "dashboard" || landed)) || waited)) || + gaveUp || + lasting; // 热启动:交接的那一刻就是窗口出现的那一刻。Rust 那边也有保底,这里只管早到 useEffect(() => { @@ -932,9 +1010,13 @@ export default function App() { const badge = it.id === "mcp" ? alerts.length : 0; const Icon = it.icon; const label = t.surfaces[it.id]; + // 没连上时需要 core 数据的几页置灰;设置始终可用,连接管理在那里 + const off = !linked && it.id !== "settings"; return ( setTab(it.id)} aria-current={on ? "page" : undefined} @@ -980,48 +1062,29 @@ export default function App() { className="border-t" style={{ borderColor: "var(--chrome-hair)" }} > - - {status?.gateway_addr - ? `${c.text} · ${status.gateway_addr}` - : c.text} - {/* 监听设置没换成:上面的地址是还在服务的旧地址,原因写在这里 */} - {status?.listen_error && ( -
{t.listenStale(coreText(status.listen_error))}
- )} - - } - > -
- - {/* 展开时写全,收起时 80px 也放得下「运行中」四个字 */} - - {c.text} - - - {c.short} - -
-
- {status?.gateway_addr && ( - - {status.gateway_addr} - - )} + {/* + 连接名在状态点和网关地址上面,点开是连接列表(见 `Switcher`)。连本机时 + 状态和地址照旧来自守护;连远程时来自连接那一层 + */} + + {status?.gateway_addr + ? `${c.text} · ${status.gateway_addr}` + : c.text} + {/* 监听设置没换成:上面的地址是还在服务的旧地址,原因写在这里 */} + {status?.listen_error && ( +
{t.listenStale(coreText(status.listen_error))}
+ )} + + ), + }} + /> @@ -1234,6 +1297,27 @@ export default function App() { 起来还是新鲜的。一条常驻的带子两件事都解决:数字留着,旁边写着 它们为什么不动了。 */} + {/* 连着远程时断线:一条横幅,内容留着、只读(设计稿 ⑥ 右) */} + {remoteLost && profile && ( +
+ + {ct.lostBanner(profile.name, remoteAttempt)} + + +
+ )} + {linked && tries > 0 && lost && (
@@ -1263,6 +1347,18 @@ export default function App() { **每一面自己滚。**工具栏钉在上面不动,这一层只负责给出高度; 真正滚的是下面这个容器(请求页是它自己的那一层,见下面)。 */} + {/* + 断线时整块只读:`fieldset disabled` 让里面的按钮、输入框、下拉一起失效, + 读、滚动、悬停说明照旧 —— 用户要看的是断开前的样子,改不了的东西就不该 + 让人点下去再报错 + */} +
setNudge((n) => n + 1)} /> + ) : ( + + ) + ) : tab === "dashboard" ? ( +
@@ -1715,6 +1820,7 @@ export default function App() { {launching && ( (null); return (
+ {/* 最上面:连不上的时候用户就是来这里的 */} + + diff --git a/src/connection/ConnectionProvider.tsx b/src/connection/ConnectionProvider.tsx new file mode 100644 index 00000000..9e38480c --- /dev/null +++ b/src/connection/ConnectionProvider.tsx @@ -0,0 +1,121 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { toast } from "sonner"; +import { errorText } from "@/i18n/core.i18n"; +import { LOCAL, connApi, type ConnView, type Profile, type ServerInfo } from "./api"; +import { ProfileDialog } from "./ProfileDialog"; +import { SwitchDialog } from "./SwitchDialog"; +import { useConnection } from "./useConnection"; + +interface Actions { + /** 连接列表和当前状态。还没读到是 null */ + view: ConnView | null; + add: () => void; + edit: (p: Profile) => void; + /** 打开设置页的「连接」一节 */ + manage: () => void; + /** 切到这一条。本机直接切;远程先试连、再确认 */ + switchTo: (id: string) => void; +} + +const Ctx = createContext({ + view: null, + add: () => {}, + edit: () => {}, + manage: () => {}, + switchTo: () => {}, +}); + +/** + * 让主界面落到某一页。主界面在按连接重挂的那一层里面,够不着它的状态,所以和菜单栏、 + * 通知一样发一条 `open-view`(见 `App` 里的 `go`) + */ +export function openView(view: string) { + window.dispatchEvent(new CustomEvent("tw-open-view", { detail: view })); +} + +export function useConnections(): Actions { + return useContext(Ctx); +} + +/** + * 连接的对话框和动作,**挂在主界面外层**:侧栏的切换器、设置里的「连接」一节、未连接 + * 页、菜单栏转过来的切换,打开的是同一个对话框。 + * + * 它在按连接重挂的那一层外面(见 `App`):切换成功、主界面整个换掉的时候,这里不跟着 + * 重来。 + */ +export function ConnectionProvider({ children }: { children: ReactNode }) { + const view = useConnection(); + const [editing, setEditing] = useState<{ profile: Profile | null } | null>(null); + const [switching, setSwitching] = useState<{ target: Profile; tested: ServerInfo | null } | null>( + null, + ); + + /** + * 列表还没读到时要切的那一条。菜单栏里选了一条远程连接、窗口是为这一下新建的: + * 落页比列表先到 + */ + const pending = useRef(null); + + const actions = useMemo( + () => ({ + view, + add: () => setEditing({ profile: null }), + manage: () => openView("settings"), + edit: (p) => { + setSwitching(null); + setEditing({ profile: p }); + }, + switchTo: (id) => { + if (!view) { + pending.current = id; + return; + } + if (id === view.current) return; + if (id === LOCAL) { + // 切回本机不用确认:本机 core 拉起来,指着服务器的客户端不受影响 + connApi.switchTo(LOCAL, false).catch((e) => toast.error(errorText(e))); + return; + } + const target = view.profiles.find((p) => p.id === id); + if (target) setSwitching({ target, tested: null }); + }, + }), + [view], + ); + + useEffect(() => { + if (!view || pending.current === null) return; + const id = pending.current; + pending.current = null; + actions.switchTo(id); + }, [view, actions]); + + return ( + + {children} + {editing && ( + setEditing(null)} + onSaved={(p, andSwitch) => { + setEditing(null); + if (andSwitch) setSwitching({ target: p, tested: andSwitch }); + }} + /> + )} + {switching && ( + setSwitching(null)} + onEdit={(p) => { + setSwitching(null); + setEditing({ profile: p }); + }} + /> + )} + + ); +} diff --git a/src/connection/ConnectionsSection.tsx b/src/connection/ConnectionsSection.tsx new file mode 100644 index 00000000..d51da181 --- /dev/null +++ b/src/connection/ConnectionsSection.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Segmented } from "@/ui/segmented"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { useLang, useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import { connApi, type ConnView, type Profile, type Startup } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { profileName } from "./describe"; + +/** 「9 月 20 日」/「Sep 20」 */ +function day(ms: number, lang: "zh" | "en"): string { + return new Date(ms).toLocaleDateString(lang === "zh" ? "zh-CN" : "en-US", { + month: lang === "zh" ? "long" : "short", + day: "numeric", + }); +} + +/** + * 设置 → 连接(设计稿 ②)。**放在设置页最上方。** + * + * 新建和编辑一律走对话框(③),页面上不做内联表单。当前连接不能删除,要先切走; + * 本机内置、删不掉。「启动时连接」是几个里选一个,用分段控件,和「外观」一样。 + * + * **不依赖 core 的任何数据**:远程连不上时这一节照样能用 —— 这正是用户这时要来的地方。 + */ +export function ConnectionsSection() { + const t = useText(connText); + const { view, add, edit, switchTo } = useConnections(); + const [deleting, setDeleting] = useState(null); + if (!view) return null; + + async function setStartup(s: Startup) { + try { + await connApi.setStartup(s); + } catch (e) { + toast.error(errorText(e)); + } + } + + return ( +
+
+

{t.title}

+ +
+

{t.intro}

+ +
    + {view.profiles.map((p) => ( + switchTo(p.id)} + onEdit={() => edit(p)} + onDelete={() => setDeleting(p)} + /> + ))} +
+ +

{t.startupGroup}

+ + + + value={view.startup} + label={t.startupLabel} + options={[ + { id: "last", label: t.startupLast }, + { id: "local", label: t.startupLocal }, + ]} + onChange={(v) => void setStartup(v)} + /> + + + + setDeleting(null)} /> +
+ ); +} + +function Row({ + p, + view, + onSwitch, + onEdit, + onDelete, +}: { + p: Profile; + view: ConnView; + onSwitch: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const lang = useLang(); + const current = p.id === view.current; + const Icon = p.local ? IconLocal : IconRemote; + let desc: string; + if (p.local) desc = t.localNote(view.data_dir); + else if (current && view.link.kind === "connected") + desc = `${p.addr} · ${t.coreVersion(view.link.info.core_version)}`; + else if (p.last_connected_at != null) desc = `${p.addr} · ${t.lastConnected(day(p.last_connected_at, lang))}`; + else desc = `${p.addr} · ${t.neverConnected}`; + + return ( +
  • +
    +

    + + {profileName(p)} + {current && {t.current}} +

    +

    {desc}

    +
    +
    + {!current && ( + + )} + {!p.local && ( + + )} + {/* 当前连着的删不掉,要先切走:按钮不出现,而不是点了才说不行 */} + {!p.local && !current && ( + + )} +
    +
  • + ); +} + +function DeleteDialog({ profile, onClose }: { profile: Profile | null; onClose: () => void }) { + const t = useText(connText); + const common = useText(commonText); + return ( + !o && onClose()}> + + + {profile ? t.deleteTitle(profile.name) : ""} + {t.deleteBody} + + + {common.cancel} + { + if (profile) connApi.remove(profile.id).catch((e) => toast.error(errorText(e))); + onClose(); + }} + > + {common.delete} + + + + + ); +} diff --git a/src/connection/PickerWindow.tsx b/src/connection/PickerWindow.tsx new file mode 100644 index 00000000..dbe587f6 --- /dev/null +++ b/src/connection/PickerWindow.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { connApi, type ConnView } from "./api"; +import { connText } from "./connection.i18n"; +import { profileName } from "./describe"; + +declare global { + interface Window { + /** 为什么先显示连接选择:按住了 ⌥,或者上次启动没能完成。Rust 建窗时注入 */ + __TW_PICK__?: "option" | "unfinished"; + } +} + +/** + * 启动时的连接选择(设计稿 ⑦ 右)。 + * + * 按住 ⌥ 启动,或者连着两次启动没能走到就绪时,**先显示这个小窗口,主窗口等选好了再开**。 + * 它不依赖 core 的任何数据:列表来自应用自己存的那一份。 + * + * 「连接」连选中的那一个、打开主界面;「管理连接…」同样连选中的那一个,主界面落到设置页 + * 的「连接」一节。**没有「不连」**:关掉这扇窗,菜单栏里的「连接」子菜单还能选。 + */ +export default function PickerWindow() { + const t = useText(connText); + const [view, setView] = useState(null); + const [chosen, setChosen] = useState(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(null); + const body = useRef(null); + const shown = useRef(false); + + useEffect(() => { + void connApi.view().then((v) => { + setView(v); + setChosen(v.profiles.some((p) => p.id === v.last_used) ? v.last_used : "local"); + }); + }, []); + + // 量出内容多高再定窗口,第一次量完才亮出来(和更新窗口一样,理由见 `window::fit`) + useEffect(() => { + const el = body.current; + if (!view || !el) return; + const fit = () => { + const height = Math.ceil(el.getBoundingClientRect().height); + void invoke("picker_fit", { height }) + .then(async () => { + if (shown.current) return; + shown.current = true; + const w = getCurrentWindow(); + await w.show(); + await w.setFocus(); + }) + .catch(() => {}); + }; + fit(); + const ro = new ResizeObserver(fit); + ro.observe(el); + return () => ro.disconnect(); + }, [view]); + + async function go(then: string | null) { + if (!chosen) return; + setBusy(true); + setFailed(null); + try { + await connApi.pick(chosen, then); + } catch (e) { + setFailed(errorText(e)); + setBusy(false); + } + } + + // 回车等于「连接」,上下键挪选中 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!view) return; + const ids = view.profiles.map((p) => p.id); + const i = ids.indexOf(chosen ?? ""); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const next = ids[Math.min(ids.length - 1, Math.max(0, i + (e.key === "ArrowDown" ? 1 : -1)))]; + if (next) setChosen(next); + } else if (e.key === "Enter" && !busy) { + e.preventDefault(); + void go(null); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + if (!view) return null; + const why = window.__TW_PICK__ === "option" ? t.pickOption : t.pickUnfinished; + + return ( +
    +
    +
    +

    {t.pickTitle}

    +

    {why}

    +
    +
    + {view.profiles.map((p) => { + const on = p.id === chosen; + const Icon = p.local ? IconLocal : IconRemote; + return ( + + ); + })} +
    + {failed &&

    {failed}

    } +
    + + +
    +
    +
    + ); +} diff --git a/src/connection/ProfileDialog.tsx b/src/connection/ProfileDialog.tsx new file mode 100644 index 00000000..47337445 --- /dev/null +++ b/src/connection/ProfileDialog.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { CheckIcon, XIcon } from "lucide-react"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { Spinner } from "@/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import { connApi, type ConnectError, type Invalid, type Profile, type ServerInfo } from "./api"; +import { connText } from "./connection.i18n"; +import { code, describeError } from "./describe"; + +/** 密钥:64 位十六进制。和 Rust 那边 `keychain::normalize` 同一条规矩 */ +const KEY_RE = /^[0-9a-fA-F]{64}$/; + +/** + * 添加、编辑一条远程连接(设计稿 ③)。 + * + * **编辑时密钥不回显。**它在钥匙串里,界面拿不回来:显示「已保存在钥匙串中」,点 + * 「更换」才填新的;不换就沿用。 + * + * 「保存并切换」**先试连**,没通过就停在这里显示原因,不存也不切。通过了才存, + * 然后交给切换确认(④)—— 那一步要说哪些客户端还指着本机。 + */ +export function ProfileDialog({ + editing, + isCurrent, + onClose, + onSaved, +}: { + /** 编辑的那一条;新建时是 null */ + editing: Profile | null; + /** 编辑的是当前连着的那一条:没有「保存并切换」,存完就按新的地址重连 */ + isCurrent: boolean; + onClose: () => void; + /** 存好了。`andSwitch`:点的是「保存并切换」,带着刚才试连的结果 */ + onSaved: (p: Profile, andSwitch: ServerInfo | null) => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const [name, setName] = useState(editing?.name ?? ""); + const [host, setHost] = useState(editing?.host ?? ""); + const [port, setPort] = useState(editing?.port != null ? String(editing.port) : ""); + /** 编辑时点过「更换」没有 */ + const [replacing, setReplacing] = useState(editing === null); + const [key, setKey] = useState(""); + const [busy, setBusy] = useState<"test" | "save" | "switch" | null>(null); + const [result, setResult] = useState< + { ok: true; info: ServerInfo } | { ok: false; error: ConnectError } | null + >(null); + const [invalid, setInvalid] = useState(null); + const [error, setError] = useState(null); + + const portNum = Number(port.trim()); + const portOk = /^\d+$/.test(port.trim()) && portNum >= 1 && portNum <= 65535; + const keyOk = !replacing || KEY_RE.test(key.trim()); + /** 还差什么。页脚左边那一句,和别的对话框一个样 */ + const missing = + name.trim() === "" + ? t.enterName + : host.trim() === "" + ? t.enterHost + : !portOk + ? t.badPort + : replacing && key.trim() === "" + ? t.enterKey + : !keyOk + ? t.badKey + : null; + /** Rust 那边查出来的(重名、地址格式) */ + const refusals: Record = { + "name:taken": t.nameTaken, + "name:empty": t.enterName, + "host:invalid": t.badHost, + "port:invalid": t.badPort, + "key:invalid": t.badKey, + "key:empty": t.enterKey, + }; + const refused = invalid && (refusals[`${invalid.field}:${invalid.reason}`] ?? t.badHost); + + function input() { + return { + id: editing?.id ?? null, + name: name.trim(), + host: host.trim(), + port: portNum, + key: replacing ? key.trim() : null, + }; + } + + // 改了哪一格,上一次的结果就不作数了 + function touched(set: (v: T) => void) { + return (v: T) => { + set(v); + setResult(null); + setInvalid(null); + }; + } + + async function test(): Promise { + setResult(null); + const r = await connApi.test(input()); + if (r.result === "ok") { + setResult({ ok: true, info: r.info }); + return r.info; + } + setResult({ ok: false, error: r.error }); + return null; + } + + async function run(kind: "test" | "save" | "switch") { + setBusy(kind); + setError(null); + try { + let info: ServerInfo | null = null; + if (kind !== "save") { + info = await test(); + if (!info || kind === "test") return; + } + const saved = await connApi.save(input()); + if (saved.result === "invalid") { + setInvalid(saved.invalid); + return; + } + onSaved(saved.profile, kind === "switch" ? info : null); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(null); + } + } + + const hint = missing ?? refused; + + return ( + !open && onClose()}> + + + {editing ? t.editTitle : t.addTitle} + {t.dialogDesc} + + + + + touched(setName)(e.target.value)} + /> + + + touched(setHost)(e.target.value)} + /> + + + touched(setPort)(e.target.value)} + /> + + + {replacing ? ( + touched(setKey)(e.target.value)} + /> + ) : ( +
    + + +
    + )} +
    +
    + + + + {error &&

    {error}

    } + + + {/* 测试在左边,和保存那一组分开:它不改任何东西 */} + + {hint && {hint}} + + + {!isCurrent && ( + + )} + +
    +
    + ); +} + +/** 试连的结果:成功一行,失败两行(发生了什么、下一步) */ +export function TestResult({ + result, + testing, +}: { + result: { ok: true; info: ServerInfo } | { ok: false; error: ConnectError } | null; + testing: boolean; +}) { + const t = useText(connText); + if (testing) { + return ( +
    + + {t.connecting}… +
    + ); + } + if (!result) return null; + if (result.ok) { + return ( +
    + + {t.testOk(result.info.core_version, result.info.gateway_addr)} +
    + ); + } + const d = describeError(result.error); + return ( +
    + +
    +

    {d.title}

    +

    {d.next}

    +
    +
    + ); +} diff --git a/src/connection/SwitchDialog.tsx b/src/connection/SwitchDialog.tsx new file mode 100644 index 00000000..64c0e277 --- /dev/null +++ b/src/connection/SwitchDialog.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from "react"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Spinner } from "@/ui/spinner"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { connApi, type Adopted, type ConnectError, type Profile, type ServerInfo, type SwitchError } from "./api"; +import { connText } from "./connection.i18n"; +import { TestResult } from "./ProfileDialog"; + +type Stage = + | { kind: "testing" } + | { kind: "failed"; error: ConnectError | null; text: string | null } + | { kind: "confirm"; info: ServerInfo; adopted: Adopted | null }; + +/** + * 切到一条远程连接(设计稿 ④)。 + * + * **先试连,通过了才出现确认。**选中另一项不会立即切换:试连没通过就说原因,留在 + * 原来的连接上。确认里说清切换的代价:还指着本机网关的客户端会失败、本机 core + * 要停、之后各页改的是服务器上的配置。 + * + * 从远程切回本机不走这里,直接切:本机 core 拉起来,指着服务器的客户端不受影响。 + */ +export function SwitchDialog({ + target, + tested, + onClose, + onEdit, +}: { + target: Profile; + /** 刚在编辑对话框里试连过:不再试一遍,直接到确认 */ + tested: ServerInfo | null; + onClose: () => void; + onEdit: (p: Profile) => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const [stage, setStage] = useState( + tested ? { kind: "confirm", info: tested, adopted: null } : { kind: "testing" }, + ); + const [retarget, setRetarget] = useState(false); + const [busy, setBusy] = useState(false); + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + + // 试连,同时数一数还指着本机的客户端 + useEffect(() => { + void (async () => { + const adopted = connApi.preflight().catch(() => null); + if (!tested) { + try { + const r = await connApi.test({ + id: target.id, + name: target.name, + host: target.host ?? "", + port: target.port ?? 0, + key: null, + }); + if (!alive.current) return; + if (r.result === "failed") { + setStage({ kind: "failed", error: r.error, text: null }); + return; + } + setStage({ kind: "confirm", info: r.info, adopted: await adopted }); + } catch (e) { + if (alive.current) setStage({ kind: "failed", error: null, text: errorText(e) }); + } + return; + } + const a = await adopted; + if (alive.current) setStage((s) => (s.kind === "confirm" ? { ...s, adopted: a } : s)); + })(); + }, [target, tested]); + + async function commit() { + setBusy(true); + try { + await connApi.switchTo(target.id, retarget); + onClose(); + } catch (e) { + const err = e as SwitchError; + if (err && typeof err === "object" && "kind" in err) { + setStage({ + kind: "failed", + error: err.kind === "connect" ? err.error : null, + text: err.kind === "keychain" ? t.keychainFailed : err.kind === "unknown" ? t.gone : null, + }); + } else { + setStage({ kind: "failed", error: null, text: errorText(e) }); + } + } finally { + if (alive.current) setBusy(false); + } + } + + const title = + stage.kind === "testing" + ? t.testingTitle(target.name) + : stage.kind === "failed" + ? t.failedTitle(target.name) + : t.confirmTitle(target.name); + const adopted = stage.kind === "confirm" ? stage.adopted : null; + + return ( + !open && onClose()}> + + + {title} + + + {stage.kind === "testing" && } + + {stage.kind === "failed" && + (stage.error ? ( + + ) : ( +

    {stage.text}

    + ))} + + {stage.kind === "confirm" && ( +
      + {adopted && adopted.count > 0 && ( +
    • + +
      +

      + + {t.adoptedWarn(adopted.count, adopted.local_addr ?? "127.0.0.1:8788")} + + {t.adoptedWarnNext(target.name)} +

      + +
      +
    • + )} +
    • + +

      + {t.localStops} + {t.localKept} +

      +
    • +
    • + +

      {t.remoteConfig(target.name)}

      +
    • +
    + )} + + + {stage.kind === "failed" && ( + + )} + + {stage.kind === "confirm" && ( + + )} + +
    +
    + ); +} diff --git a/src/connection/Switcher.tsx b/src/connection/Switcher.tsx new file mode 100644 index 00000000..01955bfb --- /dev/null +++ b/src/connection/Switcher.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from "react"; +import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/ui/dropdown-menu"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { Tip } from "@/ui/tip"; +import { textOf, useText } from "@/i18n"; +import { currentProfile, type ConnView, type Profile } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { profileName } from "./describe"; + +export type Tone = "ok" | "warn" | "bad"; + +/** 远程连接此刻怎么说:已连接 / 正在连接 / 未连接 */ +export function remoteStatus(v: ConnView): { text: string; tone: Tone; addr: string | null } { + const t = textOf(connText); + const p = currentProfile(v); + switch (v.link.kind) { + case "connected": + // 连上之后写服务器的网关地址:客户端要连的是它 + return { text: t.connected, tone: "ok", addr: v.link.info.gateway_addr ?? p?.addr ?? null }; + case "connecting": + return { text: t.connecting, tone: "warn", addr: p?.addr ?? null }; + default: + return { text: t.unlinked, tone: "bad", addr: p?.addr ?? null }; + } +} + +/** + * 侧栏底部:当前连接、它的状态、网关地址(设计稿 ①)。 + * + * **在原来的状态点和网关地址上方加一行连接名**,点开是连接列表。选中另一条不会立即 + * 切换:本机直接切,远程先试连、再确认(④)。侧栏收起成 80px 时只剩图标和状态点。 + * + * 连本机时状态和地址照旧来自守护(`local`);连远程时来自连接那一层。 + */ +export function Switcher({ + local, +}: { + /** 本机 core 的状态:几个字、收起时的短写、颜色、地址、悬停说明 */ + local: { text: string; short: string; tone: Tone; addr: string | null; tip: ReactNode }; +}) { + const t = useText(connText); + const { view, add, manage, switchTo } = useConnections(); + const p = view ? currentProfile(view) : undefined; + const remote = p && !p.local && view ? remoteStatus(view) : null; + const status = remote + ? { text: remote.text, short: remote.text, tone: remote.tone, addr: remote.addr, tip: remote.text } + : local; + const name = p ? profileName(p) : t.local; + const Icon = p && !p.local ? IconRemote : IconLocal; + + return ( +
    + {view && ( + + + + + {name} + + + + +

    {t.menuTitle}

    + {view.profiles.map((it) => ( + switchTo(it.id)} + /> + ))} + + {t.addRemote} + + {t.manage} + +
    +
    + )} + +
    + + {/* 展开时写全,收起时 80px 也放得下的短写 */} + {status.text} + {status.short} +
    +
    + {status.addr && ( + + {status.addr} + + )} +
    + ); +} + +function Choice({ + p, + current, + sub, + onSelect, +}: { + p: Profile; + current: boolean; + sub: string; + onSelect: () => void; +}) { + const Icon = p.local ? IconLocal : IconRemote; + return ( + + {current && } + + + + {profileName(p)} + + {sub} + + + ); +} diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx new file mode 100644 index 00000000..f3d89de3 --- /dev/null +++ b/src/connection/Unlinked.tsx @@ -0,0 +1,164 @@ +import { useState, type ReactNode } from "react"; +import { PlugZapIcon, TriangleAlertIcon } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import { Button } from "@/ui/button"; +import { Spinner } from "@/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { useNow } from "@/useNow"; +import { trouble } from "@/launch/trouble"; +import { troubleText } from "@/launch/trouble.i18n"; +import { LOCAL, connApi, currentProfile, type ConnView } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { shortReason } from "./describe"; + +/** + * 没连上时内容区里的那一页(设计稿 ⑤、⑥ 左)。 + * + * **启动画面最多等约 8 秒**,之后照常打开窗口显示这一页,而不是一直转圈:需要 core + * 数据的导航项置灰,设置始终可用(连接管理在那里),侧栏的切换器照常可点。 + * + * 连着远程时说连不上的原因、第几次、多久后再试,给「立即重试 / 编辑连接 / 切换到本机」。 + * 「切换到本机」直接切,不走确认:这时远程本来就不可用。版本不一致单独一页,给出两个 + * 版本号和在服务器上要执行的命令。 + * + * 连着本机时说本机 core 怎么了(和启动画面同一套说法),给「重新启动」。 + */ +export function Unlinked({ core }: { core: string }) { + const { view } = useConnections(); + const p = view ? currentProfile(view) : undefined; + if (!view || !p || p.local) return ; + if (view.link.kind === "down" && view.link.error.kind === "version_mismatch") + return ; + return ; +} + +function Page({ icon, tone, title, children }: { icon: ReactNode; tone: "bad" | "warn"; title: string; children: ReactNode }) { + return ( +
    +
    +
    + {icon} +
    +

    {title}

    + {children} +
    +
    + ); +} + +function Facts({ rows }: { rows: [string, ReactNode][] }) { + return ( +
    + {rows.map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} +
    + ); +} + +function RemoteDown({ view }: { view: ConnView }) { + const t = useText(connText); + const { edit, switchTo } = useConnections(); + const now = useNow(1_000); + const p = currentProfile(view)!; + const link = view.link; + const retry = + link.kind === "down" + ? t.retryIn(link.attempt + 1, Math.max(0, Math.ceil((link.at_ms + link.retry_in_ms - now) / 1000))) + : link.kind === "connecting" + ? t.retrying(link.attempt) + : null; + return ( + } tone="bad" title={link.kind === "connecting" && link.attempt <= 1 ? t.connectingTo(p.name) : t.cannotConnect(p.name)}> + {p.addr}], + ...(link.kind === "down" ? ([[t.reason, shortReason(link.error)]] as [string, ReactNode][]) : []), + ...(retry ? ([[t.retry, retry]] as [string, ReactNode][]) : []), + ]} + /> +
    + + + +
    +

    {t.autoResume}

    +
    + ); +} + +function Mismatch({ view, ours, theirs }: { view: ConnView; ours: string; theirs: string }) { + const t = useText(connText); + const { switchTo } = useConnections(); + const p = currentProfile(view)!; + return ( + } tone="warn" title={t.mismatchTitle(p.name)}> + core {theirs}], + [t.appNeeds, core {ours}], + ]} + /> +

    {t.runOnServer}

    +
    {t.upgradeCommand}
    +
    + + +
    +
    + ); +} + +/** 连着本机、本机 core 没起来(启动画面等过了 8 秒之后) */ +function LocalDown({ core }: { core: string }) { + const t = useText(connText); + const tt = useText(troubleText); + const [busy, setBusy] = useState(false); + const tr = trouble(core, 0); + return ( + } tone="bad" title={tr.what}> +

    {tr.next}

    + {tr.retry && ( +
    + +
    + )} +

    {t.autoResume}

    +
    + ); +} diff --git a/src/connection/api.ts b/src/connection/api.ts new file mode 100644 index 00000000..91656e4a --- /dev/null +++ b/src/connection/api.ts @@ -0,0 +1,116 @@ +/** + * 连接:连哪个 core。**列表和状态都来自 Rust 侧**(`src-tauri/src/connection/`), + * 不经 core —— 远程连不上时这些照样要能显示、能操作。 + * + * 密钥不在这里:添加、更换时从对话框递过去一次,之后再也拿不回来。 + */ +import { invoke } from "@tauri-apps/api/core"; + +/** 本机那一条的 id */ +export const LOCAL = "local"; + +export interface Profile { + id: string; + /** 本机那一条的名字由界面按语言写,不用这里的 */ + name: string; + local: boolean; + host: string | null; + port: number | null; + /** `host:port` */ + addr: string | null; + last_connected_at: number | null; +} + +export interface ServerInfo { + core_version: string; + /** 服务器的网关地址。客户端要连的是它 */ + gateway_addr: string | null; +} + +/** 连不上的原因,按种类。文案见 `describe.ts` */ +export type ConnectError = + | { kind: "unreachable"; addr: string } + | { kind: "closed"; addr: string } + | { kind: "wrong_key" } + | { kind: "version_mismatch"; ours: string; theirs: string } + | { kind: "timeout"; addr: string } + | { kind: "not_yet_available" }; + +export type LinkState = + /** 连的是本机:状态看 `core-state` */ + | { kind: "local" } + /** 还没开始连:连接选择开着 */ + | { kind: "waiting" } + | { kind: "connecting"; attempt: number; ever: boolean } + | { kind: "connected"; info: ServerInfo } + | { + kind: "down"; + error: ConnectError; + attempt: number; + at_ms: number; + retry_in_ms: number; + /** 这次运行里连上过它:运行中断线,不是启动时连不上 */ + ever: boolean; + }; + +export type Startup = "last" | "local"; + +export interface ConnView { + profiles: Profile[]; + current: string; + last_used: string; + startup: Startup; + link: LinkState; + /** 这一版应用配的 core 版本 */ + required_core: string; + /** 本机的数据目录,`~/.thinkwatch` */ + data_dir: string; +} + +export interface ProfileInput { + id: string | null; + name: string; + host: string; + port: number; + /** 新建时必填;编辑时不填就沿用钥匙串里的那一把 */ + key: string | null; +} + +export type Tested = { result: "ok"; info: ServerInfo } | { result: "failed"; error: ConnectError }; + +export type Invalid = { + field: "name" | "host" | "port" | "key"; + reason: "empty" | "taken" | "invalid"; +}; + +export type Saved = { result: "ok"; profile: Profile } | { result: "invalid"; invalid: Invalid }; + +/** 切换没做成:列表里已经没有这一条、钥匙串取不出密钥、试连没通过 */ +export type SwitchError = + | { kind: "unknown" } + | { kind: "keychain"; detail: string } + | { kind: "connect"; error: ConnectError }; + +/** 切过去之前要说的:这台机器上已接管、还指着本机网关的客户端 */ +export interface Adopted { + count: number; + local_addr: string | null; +} + +export const connApi = { + view: () => invoke("connections"), + setStartup: (startup: Startup) => invoke("set_connection_startup", { startup }), + test: (input: ProfileInput) => invoke("test_connection", { input }), + save: (input: ProfileInput) => invoke("save_connection", { input }), + remove: (id: string) => invoke("delete_connection", { id }), + preflight: () => invoke("switch_preflight"), + switchTo: (id: string, retargetClients: boolean) => + invoke("switch_connection", { id, retargetClients }), + retry: () => invoke("retry_connection"), + pick: (id: string, then: string | null) => invoke("pick_connection", { id, then }), +}; + +/** 当前那一条 */ +export function currentProfile(v: ConnView): Profile | undefined { + return v.profiles.find((p) => p.id === v.current); +} diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx new file mode 100644 index 00000000..60d070b4 --- /dev/null +++ b/src/connection/connection.i18n.tsx @@ -0,0 +1,298 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; +import { isLinux, isMac, isWindows } from "@/platform"; + +/** 句中嵌着的一段代码(配置项、命令)。怎么画由组件决定 */ +type Code = (text: string) => ReactNode; + +/** 密钥存在哪。按平台说用户认得的那个名字 */ +const vaultZh = isWindows ? "凭据管理器" : isLinux ? "系统密钥环" : "钥匙串"; +const vaultEn = isWindows ? "Credential Manager" : isLinux ? "the system keyring" : "the keychain"; +/** 启动时按住哪个键 */ +const holdZh = isMac ? "Option" : "Alt"; + +/** + * 连接:侧栏的切换器、设置里的「连接」一节、添加与编辑、切换确认、未连接页、 + * 断线横幅、启动时的连接选择。 + */ +export const connText = messages( + { + /** 内置的那一条 */ + local: "本机", + localNote: (dir: string) => `${dir} · 内置,不可删除`, + localCore: "本地 core", + localStopped: "本地 core · 已停止", + + // 侧栏 + switcherTip: "切换连接", + menuTitle: "连接", + addRemote: "添加远程连接…", + manage: "管理连接…", + connected: "已连接", + connecting: "正在连接", + unlinked: "未连接", + + // 设置 → 连接 + title: "连接", + add: "添加远程连接", + intro: isMac + ? "应用同一时间连接一个 core。连接到远程 core 时,本机 core 停止运行,本机数据保留。" + : "应用同一时间连接一个 core。连接到远程 core 时,本机的 core 停止运行,本机数据保留。", + current: "当前", + switchTo: "切换到此连接", + lastConnected: (when: string) => `上次连接 ${when}`, + neverConnected: "尚未连接", + coreVersion: (v: string) => `core ${v}`, + currentOnlyDelete: "当前连接不能删除,请先切换到其他连接。", + startupGroup: "启动", + startupLabel: "启动时连接", + startupLast: "上次使用的连接", + startupLocal: "本机", + startupHint: isLinux + ? "连续两次启动未能完成时,下次启动先显示连接选择。" + : `按住 ${holdZh} 键启动应用时,先显示连接选择。`, + deleteTitle: (name: string) => `删除连接「${name}」?`, + deleteBody: `保存在${vaultZh}中的密钥一并删除。服务器上的 core 不受影响。`, + + // 添加与编辑 + addTitle: "添加远程连接", + editTitle: "编辑连接", + dialogDesc: "连接到运行在另一台机器上的 ThinkWatch core。", + name: "名称", + namePlaceholder: "例如 home-server", + host: "地址", + hostHint: "主机名或 IP 地址。", + port: "控制端口", + portHint: (code: Code) => <>服务器配置中 {code("listen.control.remote.port")} 的值。, + key: "密钥", + keyHint: (code: Code) => ( + <> + 在服务器上执行 {code("twcore control-key")} 获取。保存在{vaultZh}中。 + + ), + keySaved: `已保存在${vaultZh}中`, + replaceKey: "更换", + test: "测试连接", + testHint: "完成握手并读取服务器 core 的版本。", + testOk: (version: string, gateway: string | null) => + `连接成功 · core ${version}${gateway ? ` · 网关 ${gateway}` : ""}`, + saveAndSwitch: "保存并切换", + enterName: "填写名称", + nameTaken: "已有同名的连接", + enterHost: "填写地址", + badHost: "地址格式不正确", + badPort: "端口应为 1–65535 之间的整数", + enterKey: "填写密钥", + badKey: "密钥应为 64 位十六进制字符", + + // 连接失败的几种说法:标题一句,下一步一句 + unreachable: (addr: string) => `无法连接到 ${addr}`, + unreachableNext: (code: Code) => ( + <>检查地址和端口,以及服务器配置中 {code("listen.control.remote")} 是否已启用。 + ), + timeoutNext: (code: Code) => ( + <>连接超时。检查地址和端口,以及服务器配置中 {code("listen.control.remote")} 是否已启用。 + ), + closed: "连接被服务器关闭", + closedNext: (code: Code) => ( + <>本机地址可能不在服务器配置 {code("listen.control.remote.allow_from")} 中。 + ), + wrongKey: "密钥不正确", + wrongKeyNext: (code: Code) => <>在服务器上执行 {code("twcore control-key")} 查看当前密钥。, + mismatch: (theirs: string, ours: string) => `版本不一致:服务器 core ${theirs},本应用需要 ${ours}`, + mismatchNext: "在服务器上升级 core 后再连接。", + unsupported: "此版本尚不支持连接远程 core", + unsupportedNext: "服务器可以访问。更新应用后即可连接。", + reasonTimeout: "连接超时", + reasonUnreachable: "无法访问该地址", + + // 切换 + testingTitle: (name: string) => `正在连接 ${name}…`, + failedTitle: (name: string) => `无法切换到 ${name}`, + confirmTitle: (name: string) => `切换到 ${name}?`, + adoptedWarn: (n: number, addr: string) => `已接管的 ${n} 个客户端仍指向本机网关 ${addr}。`, + adoptedWarnNext: (name: string) => + `切换后本机网关停止,这些客户端的请求会失败,直到重新连接本机,或在客户端页将它们改为指向 ${name}。`, + retarget: (name: string) => `同时将这些客户端改为指向 ${name}`, + localStops: "本机 core 将在在途请求结束后停止", + localKept: ",本机的配置、密钥和请求历史保留。", + remoteConfig: (name: string) => `切换后,上游、路由、密钥等页面显示和修改的是 ${name} 上的配置。`, + switchAction: "切换", + editConnection: "编辑连接", + retargetFailed: "客户端未能改为指向服务器,可在客户端页逐个修改。", + keychainFailed: `无法从${vaultZh}中读取密钥`, + gone: "该连接已被删除。", + + // 未连接页 + cannotConnect: (name: string) => `无法连接到 ${name}`, + connectingTo: (name: string) => `正在连接 ${name}`, + address: "地址", + reason: "原因", + retry: "重试", + retryIn: (attempt: number, secs: number) => `第 ${attempt} 次,${secs} 秒后`, + retrying: (attempt: number) => `第 ${attempt} 次,正在连接`, + retryNow: "立即重试", + switchToLocal: "切换到本机", + autoResume: "连接恢复后自动回到上次打开的页面。", + mismatchTitle: (name: string) => `${name} 上的 core 版本与本应用不一致`, + server: "服务器", + appNeeds: "本应用需要", + runOnServer: "在服务器上执行:", + upgradeCommand: "twcore upgrade", + reconnect: "重新连接", + localDown: "本机 core 未在运行", + restartLocal: "重新启动", + + // 断线横幅 + lostBanner: (name: string, attempt: number) => + attempt > 0 + ? `与 ${name} 的连接已断开,正在重连(第 ${attempt} 次)。页面内容为断开前的状态,暂不可修改。` + : `与 ${name} 的连接已断开,正在重连。页面内容为断开前的状态,暂不可修改。`, + + // 启动时的连接选择 + pickTitle: "选择连接", + pickOption: `按住 ${holdZh} 键启动。选择这次要连接的 core。`, + pickUnfinished: "上次启动未能完成。选择这次要连接的 core。", + lastUsed: "上次使用", + connect: "连接", + }, + { + local: isMac ? "This Mac" : "This computer", + localNote: (dir: string) => `${dir} · Built in, cannot be deleted`, + localCore: "Local core", + localStopped: "Local core · Stopped", + + switcherTip: "Switch connection", + menuTitle: "Connection", + addRemote: "Add remote connection…", + manage: "Manage connections…", + connected: "Connected", + connecting: "Connecting", + unlinked: "Not connected", + + title: "Connection", + add: "Add remote connection", + intro: isMac + ? "The app connects to one core at a time. While it is connected to a remote core, the core on this Mac stops running; its data is kept." + : "The app connects to one core at a time. While it is connected to a remote core, the core on this computer stops running; its data is kept.", + current: "Current", + switchTo: "Switch to this connection", + lastConnected: (when: string) => `Last connected ${when}`, + neverConnected: "Never connected", + coreVersion: (v: string) => `core ${v}`, + currentOnlyDelete: "The current connection cannot be deleted. Switch to another connection first.", + startupGroup: "Startup", + startupLabel: "Connect at startup", + startupLast: "Last used connection", + startupLocal: isMac ? "This Mac" : "This computer", + startupHint: isLinux + ? "After two startups in a row fail to finish, the next startup shows the connection choice first." + : `Hold ${holdZh} while opening the app to choose a connection first.`, + deleteTitle: (name: string) => `Delete the connection “${name}”?`, + deleteBody: `The key saved in ${vaultEn} is deleted as well. The core on the server is not affected.`, + + addTitle: "Add remote connection", + editTitle: "Edit connection", + dialogDesc: "Connect to a ThinkWatch core running on another machine.", + name: "Name", + namePlaceholder: "e.g. home-server", + host: "Address", + hostHint: "Host name or IP address.", + port: "Control port", + portHint: (code: Code) => <>The value of {code("listen.control.remote.port")} in the server's config., + key: "Key", + keyHint: (code: Code) => ( + <> + Run {code("twcore control-key")} on the server to get it. Saved in {vaultEn}. + + ), + keySaved: `Saved in ${vaultEn}`, + replaceKey: "Replace", + test: "Test connection", + testHint: "Completes the handshake and reads the server's core version.", + testOk: (version: string, gateway: string | null) => + `Connected · core ${version}${gateway ? ` · gateway ${gateway}` : ""}`, + saveAndSwitch: "Save and switch", + enterName: "Enter a name", + nameTaken: "A connection with this name already exists", + enterHost: "Enter an address", + badHost: "The address is not valid", + badPort: "The port must be a whole number from 1 to 65535", + enterKey: "Enter the key", + badKey: "The key must be 64 hexadecimal characters", + + unreachable: (addr: string) => `Cannot connect to ${addr}`, + unreachableNext: (code: Code) => ( + <>Check the address and port, and whether {code("listen.control.remote")} is enabled in the server's config. + ), + timeoutNext: (code: Code) => ( + <> + The connection timed out. Check the address and port, and whether {code("listen.control.remote")} is + enabled in the server's config. + + ), + closed: "The server closed the connection", + closedNext: (code: Code) => ( + <>This computer's address may be missing from {code("listen.control.remote.allow_from")} in the server's config. + ), + wrongKey: "The key is not correct", + wrongKeyNext: (code: Code) => <>Run {code("twcore control-key")} on the server to see the current key., + mismatch: (theirs: string, ours: string) => + `Version mismatch: the server runs core ${theirs}; this app needs ${ours}`, + mismatchNext: "Upgrade core on the server, then connect again.", + unsupported: "This version cannot connect to a remote core yet", + unsupportedNext: "The server is reachable. Connecting becomes possible after an app update.", + reasonTimeout: "The connection timed out", + reasonUnreachable: "The address is not reachable", + + testingTitle: (name: string) => `Connecting to ${name}…`, + failedTitle: (name: string) => `Cannot switch to ${name}`, + confirmTitle: (name: string) => `Switch to ${name}?`, + adoptedWarn: (n: number, addr: string) => + n === 1 + ? `1 connected client still points to the local gateway ${addr}.` + : `${n} connected clients still point to the local gateway ${addr}.`, + adoptedWarnNext: (name: string) => + `After the switch, the local gateway stops and their requests fail until the app switches back, or until they are pointed at ${name} on the Clients page.`, + retarget: (name: string) => `Also point these clients at ${name}`, + localStops: "The local core stops once in-progress requests finish", + localKept: "; its config, keys and request history are kept.", + remoteConfig: (name: string) => + `After the switch, the Upstreams, Routing, Keys and other pages show and change the config on ${name}.`, + switchAction: "Switch", + editConnection: "Edit connection", + retargetFailed: "The clients could not be pointed at the server. They can be changed one by one on the Clients page.", + keychainFailed: `The key could not be read from ${vaultEn}`, + gone: "This connection has been deleted.", + + cannotConnect: (name: string) => `Cannot connect to ${name}`, + connectingTo: (name: string) => `Connecting to ${name}`, + address: "Address", + reason: "Reason", + retry: "Retry", + retryIn: (attempt: number, secs: number) => `Attempt ${attempt}, in ${secs} s`, + retrying: (attempt: number) => `Attempt ${attempt}, connecting`, + retryNow: "Retry now", + switchToLocal: isMac ? "Switch to this Mac" : "Switch to this computer", + autoResume: "Once the connection is restored, the app returns to the page that was open.", + mismatchTitle: (name: string) => `The core on ${name} does not match this app`, + server: "Server", + appNeeds: "This app needs", + runOnServer: "Run on the server:", + upgradeCommand: "twcore upgrade", + reconnect: "Reconnect", + localDown: "The local core is not running", + restartLocal: "Restart", + + lostBanner: (name: string, attempt: number) => + attempt > 0 + ? `Disconnected from ${name}. Reconnecting (attempt ${attempt}). The page shows the state before the disconnect and cannot be changed for now.` + : `Disconnected from ${name}. Reconnecting. The page shows the state before the disconnect and cannot be changed for now.`, + + pickTitle: "Choose a connection", + pickOption: `${holdZh} was held at startup. Choose the core to connect to this time.`, + pickUnfinished: "The last startup did not finish. Choose the core to connect to this time.", + lastUsed: "Last used", + connect: "Connect", + }, +); diff --git a/src/connection/describe.test.tsx b/src/connection/describe.test.tsx new file mode 100644 index 00000000..ae8746db --- /dev/null +++ b/src/connection/describe.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ConnectError } from "./api"; +import { describeError, shortReason } from "./describe"; + +const ALL: ConnectError[] = [ + { kind: "unreachable", addr: "192.168.1.20:8789" }, + { kind: "timeout", addr: "192.168.1.20:8789" }, + { kind: "closed", addr: "192.168.1.20:8789" }, + { kind: "wrong_key" }, + { kind: "version_mismatch", ours: "0.48.0", theirs: "0.47.2" }, + { kind: "not_yet_available" }, +]; + +describe("连接失败的原因", () => { + /** 每一种都要说出发生了什么和下一步:设计稿 ③ 的四种说法,外加还没接上的那一种 */ + it("每一种都有标题和下一步,两种语言都是", () => { + for (const lang of ["zh", "en"] as const) { + setLang(lang); + for (const e of ALL) { + const d = describeError(e); + expect(d.title, `${lang} ${e.kind}`).toBeTruthy(); + expect(d.next, `${lang} ${e.kind}`).toBeTruthy(); + expect(shortReason(e), `${lang} ${e.kind}`).toBeTruthy(); + } + } + setLang("zh"); + }); + + it("版本不一致说出两个版本号", () => { + setLang("zh"); + const d = describeError({ kind: "version_mismatch", ours: "0.48.0", theirs: "0.47.2" }); + expect(d.title).toBe("版本不一致:服务器 core 0.47.2,本应用需要 0.48.0"); + }); + + it("超时和地址不通标题一样,下一步不一样", () => { + setLang("zh"); + const a = describeError({ kind: "timeout", addr: "h:1" }); + const b = describeError({ kind: "unreachable", addr: "h:1" }); + expect(a.title).toBe("无法连接到 h:1"); + expect(a.title).toBe(b.title); + expect(shortReason({ kind: "timeout", addr: "h:1" })).toBe("连接超时"); + }); +}); diff --git a/src/connection/describe.tsx b/src/connection/describe.tsx new file mode 100644 index 00000000..513196c9 --- /dev/null +++ b/src/connection/describe.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { textOf } from "@/i18n"; +import type { ConnectError } from "./api"; +import { connText } from "./connection.i18n"; + +/** 句中的配置项、命令:等宽 */ +export function code(text: string): ReactNode { + return {text}; +} + +/** + * 连不上的原因说成两句:**发生了什么**,和**下一步做什么**。 + * + * 四种都给出下一步(设计稿 ③)。「被关闭」只能说「可能」:core 对允许列表之外的地址 + * accept 之后直接关、一个字节不回,应用分不出那是不是唯一的原因。 + */ +export function describeError(e: ConnectError): { title: string; next: ReactNode } { + const t = textOf(connText); + switch (e.kind) { + case "unreachable": + return { title: t.unreachable(e.addr), next: t.unreachableNext(code) }; + case "timeout": + return { title: t.unreachable(e.addr), next: t.timeoutNext(code) }; + case "closed": + return { title: t.closed, next: t.closedNext(code) }; + case "wrong_key": + return { title: t.wrongKey, next: t.wrongKeyNext(code) }; + case "version_mismatch": + return { title: t.mismatch(e.theirs, e.ours), next: t.mismatchNext }; + case "not_yet_available": + return { title: t.unsupported, next: t.unsupportedNext }; + } +} + +/** 未连接页「原因」那一格:一个短语,不带下一步(下一步是页面上的按钮) */ +export function shortReason(e: ConnectError): string { + const t = textOf(connText); + switch (e.kind) { + case "timeout": + return t.reasonTimeout; + case "unreachable": + return t.reasonUnreachable; + case "closed": + return t.closed; + case "wrong_key": + return t.wrongKey; + case "version_mismatch": + return t.mismatch(e.theirs, e.ours); + case "not_yet_available": + return t.unsupported; + } +} + +/** 本机那一条的名字按界面语言写,远程的用用户起的名字 */ +export function profileName(p: { local: boolean; name: string }): string { + return p.local ? textOf(connText).local : p.name; +} diff --git a/src/connection/useConnection.ts b/src/connection/useConnection.ts new file mode 100644 index 00000000..577ad5ae --- /dev/null +++ b/src/connection/useConnection.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { connApi, type ConnView } from "./api"; + +/** + * 连接列表和当前连接的状态。**推过来的**(`connection` 事件),挂上时先读一次。 + * + * 先挂监听再读,顺序不能反:两者之间发生的那一次变化会丢。 + */ +export function useConnection(): ConnView | null { + const [view, setView] = useState(null); + useEffect(() => { + let alive = true; + const un = listen("connection", (e) => { + if (alive) setView(e.payload); + }); + void un + .then(() => connApi.view()) + .then((v) => { + if (alive) setView(v); + }) + .catch(() => { + /* 不在应用里(隔离预览没 mock 它):没有连接这一层 */ + }); + return () => { + alive = false; + void un.then((f) => f()); + }; + }, []); + return view; +} diff --git a/src/launch/LaunchScreen.tsx b/src/launch/LaunchScreen.tsx index 2977dbb5..1b65d8d8 100644 --- a/src/launch/LaunchScreen.tsx +++ b/src/launch/LaunchScreen.tsx @@ -8,6 +8,7 @@ import { errorText } from "@/i18n/core.i18n"; import { troubleText } from "./trouble.i18n"; import { launchText } from "./LaunchScreen.i18n"; import { launchPhase } from "./phase"; +import { connText } from "@/connection/connection.i18n"; /** * 至少停多久。**冷启动每次都停**:秒开也不跳过,四笔要画完、亮一下。 @@ -49,6 +50,7 @@ const STROKES: [string, number][] = [ * 用户本来在看数据,整窗盖住比留着旧值加一句说明更糟。 */ export function LaunchScreen({ + remote, state, linked, tries, @@ -56,6 +58,12 @@ export function LaunchScreen({ ready, onGone, }: { + /** + * 连的是远程 core 时是它的名字。**那时这一面只说「正在连接」**:本机 core 的那几种 + * 状态(起不来、安全模式)和它无关,重启本机 core 也不是出路 —— 连不上的原因和出路 + * 在交接之后的「未连接」页上 + */ + remote: string | null; /** `core_state` 的字符串 */ state: string; /** 状态读到过了 */ @@ -71,6 +79,7 @@ export function LaunchScreen({ }) { const t = useText(launchText); const tt = useText(troubleText); + const ct = useText(connText); const born = useRef(performance.now()); const [leaving, setLeaving] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -98,7 +107,10 @@ export function LaunchScreen({ return () => clearInterval(h); }, [leaving]); - const { what, problem } = launchPhase(state, linked, tries, linkError); + const { what, problem } = + remote !== null && !linked + ? { what: ct.connectingTo(remote), problem: null } + : launchPhase(state, linked, tries, linkError); const sub = failedAction ?? problem?.next ?? diff --git a/src/main.tsx b/src/main.tsx index ba179a01..0951e756 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; import UpdateWindow from "./UpdateWindow"; +import PickerWindow from "./connection/PickerWindow"; import { setLang, type Lang } from "./i18n"; import "./index.css"; @@ -23,7 +24,8 @@ function windowLabel(): string { } } -const Root = windowLabel() === "update" ? UpdateWindow : App; +const label = windowLabel(); +const Root = label === "update" ? UpdateWindow : label === "picker" ? PickerWindow : App; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index cad21008..e09a7a36 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -30,3 +30,5 @@ export { SlidersHorizontal as IconSettings } from "lucide-react"; // 设置 — export { PanelLeft as IconSidebar } from "lucide-react"; // 收起/展开源列表 export { Copy as IconCopy } from "lucide-react"; // 复制 —— 两张叠着的纸 export { Check as IconCopied } from "lucide-react"; // 已复制 —— 一个勾 +export { Monitor as IconLocal } from "lucide-react"; // 本机这一条连接 —— 一台显示器 +export { Network as IconRemote } from "lucide-react"; // 远程连接 —— 连到另一台机器 From a21c997c25c955fdcbae3e6e36f9374855bf1451 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:25:24 +0800 Subject: [PATCH 2/2] Store remote connection keys on disk, not in the OS keychain On macOS the keychain prompts for the login password for this item (again after each update of an unsigned app), which is not worth it for a key that can be copied from the server again. There is now one code path on every platform: connection-keys.json in the data directory, created 0600 from the start (temp file with the mode, then rename), in a directory created private (0700 on unix, protected DACL on Windows; ported from core's private_dir). Kept apart from connections.json so the frequent list writes never touch the secret and the code that reads the list cannot see it. Keys still never go to the webview. The keyring crate and all keychain copy are gone. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 477 ++------------------------ src-tauri/Cargo.toml | 9 +- src-tauri/src/connection/connector.rs | 4 +- src-tauri/src/connection/keychain.rs | 50 --- src-tauri/src/connection/mod.rs | 41 +-- src-tauri/src/connection/secrets.rs | 142 ++++++++ src-tauri/src/connection/store.rs | 6 +- src-tauri/src/lib.rs | 2 + src-tauri/src/private_dir.rs | 218 ++++++++++++ src/connection/ProfileDialog.tsx | 4 +- src/connection/SwitchDialog.tsx | 2 +- src/connection/api.ts | 6 +- src/connection/connection.i18n.tsx | 21 +- 13 files changed, 436 insertions(+), 546 deletions(-) delete mode 100644 src-tauri/src/connection/keychain.rs create mode 100644 src-tauri/src/connection/secrets.rs create mode 100644 src-tauri/src/private_dir.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ca69a8f7..c7cef611 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,17 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -126,17 +115,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - [[package]] name = "async-io" version = "2.6.0" @@ -333,15 +311,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -494,15 +463,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.4.5" @@ -546,12 +506,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" version = "0.10.2" @@ -575,16 +529,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clipboard-win" version = "5.4.1" @@ -824,35 +768,6 @@ dependencies = [ "syn 3.0.5", ] -[[package]] -name = "dbus" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2", - "zeroize", -] - [[package]] name = "defmt" version = "1.1.1" @@ -933,7 +848,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -974,7 +888,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1175,7 +1089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1770,24 +1684,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -2085,16 +1981,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2321,23 +2207,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "log", - "secret-service", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zbus 4.4.0", - "zeroize", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2374,15 +2243,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - [[package]] name = "libloading" version = "0.7.4" @@ -2549,7 +2409,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2582,19 +2442,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "8.0.0" @@ -2615,7 +2462,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.13.2", + "zbus", ] [[package]] @@ -2624,40 +2471,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", + "windows-sys 0.60.2", ] [[package]] @@ -2666,36 +2480,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3016,7 +2800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3391,24 +3175,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] @@ -3423,16 +3196,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3443,15 +3206,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -3641,7 +3395,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3667,7 +3421,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", + "security-framework", ] [[package]] @@ -3694,10 +3448,10 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3798,38 +3552,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "secret-service" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "hkdf", - "num", - "once_cell", - "rand 0.8.8", - "serde", - "sha2", - "zbus 4.4.0", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4041,17 +3763,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4135,7 +3846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4192,12 +3903,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.9.0" @@ -4615,7 +4320,7 @@ dependencies = [ "thiserror 2.0.20", "url", "windows", - "zbus 5.13.2", + "zbus", ] [[package]] @@ -4632,7 +4337,7 @@ dependencies = [ "tokio", "tracing", "windows-sys 0.60.2", - "zbus 5.13.2", + "zbus", ] [[package]] @@ -4789,7 +4494,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4822,7 +4527,6 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "keyring", "libc", "objc2", "objc2-app-kit", @@ -4852,7 +4556,7 @@ dependencies = [ "tw-api", "windows", "windows-sys 0.61.2", - "zbus 5.13.2", + "zbus", ] [[package]] @@ -5268,7 +4972,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5350,7 +5054,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5804,7 +5508,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6393,16 +6097,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "yoke" version = "0.8.3" @@ -6426,44 +6120,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-executor", - "async-fs", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix", - "ordered-stream", - "rand 0.8.8", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.13.2" @@ -6494,22 +6150,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.15", - "zbus_macros 5.13.2", - "zbus_names 4.3.1", - "zvariant 5.9.2", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -6522,20 +6165,9 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zbus_names 4.3.1", - "zvariant 5.9.2", - "zvariant_utils 3.3.0", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -6546,7 +6178,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.15", - "zvariant 5.9.2", + "zvariant", ] [[package]] @@ -6595,20 +6227,6 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" @@ -6682,19 +6300,6 @@ dependencies = [ "zune-core", ] -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - [[package]] name = "zvariant" version = "5.9.2" @@ -6705,21 +6310,8 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.15", - "zvariant_derive 5.9.2", - "zvariant_utils 3.3.0", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -6732,18 +6324,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils 3.3.0", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "zvariant_utils", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1cde345a..2eb05d07 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,11 +62,6 @@ tauri-plugin-updater = "2" reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "system-proxy"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } semver = "1" -# 远程连接的控制面密钥存在系统的钥匙串里:macOS 钥匙串、Windows 凭据管理器、Linux 的 -# Secret Service(见 `connection/keychain.rs`)。**Linux 那一支走纯 Rust 的 zbus** -# (`async-secret-service` + `async-io`),不链 libdbus —— 那要 CI 和用户机器上多装一个 -# 开发包 -keyring = { version = "3.6", features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } # Homebrew 那一档的「复制命令」。**在 Rust 这边写剪贴板**,理由见 # `update_copy_command`。 tauri-plugin-clipboard-manager = "2" @@ -138,6 +133,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_SystemInformation", # 启动时看 Alt 键有没有按着:按着就先显示连接选择(`connection::launch`) "Win32_UI_Input_KeyboardAndMouse", + # 数据目录建成只有自己能读(受保护的 DACL),见 `private_dir` + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", ] } # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index aa487143..e4712957 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -19,8 +19,8 @@ use tokio::io::{AsyncRead, AsyncWrite}; use crate::control::{ControlClient, Stream, Target}; -/// 远程的 core 在哪、拿什么进门。**密钥只在内存里**:存盘在钥匙串,不进设置文件, -/// 也不交给界面 +/// 远程的 core 在哪、拿什么进门。**密钥只在内存里**:存盘在 `secrets` 那个只有自己 +/// 能读的文件里,不进连接列表,也不交给界面 #[derive(Clone)] pub struct RemoteTarget { pub host: String, diff --git a/src-tauri/src/connection/keychain.rs b/src-tauri/src/connection/keychain.rs deleted file mode 100644 index 1999cc26..00000000 --- a/src-tauri/src/connection/keychain.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! 远程连接的控制面密钥:放进系统的钥匙串。 -//! -//! macOS 是钥匙串,Windows 是凭据管理器,Linux 是 Secret Service(GNOME 钥匙串、 -//! KWallet)。**不进设置文件,也不交给界面**:界面只在添加、更换时把用户粘进来的那一串 -//! 递过来一次,之后再也拿不回去 —— 编辑对话框里显示的是「已保存在钥匙串中」。 - -/// 钥匙串里这一项归谁 -const SERVICE: &str = "ThinkWatch Lite"; - -fn entry(id: &str) -> anyhow::Result { - Ok(keyring::Entry::new(SERVICE, &format!("control-key:{id}"))?) -} - -/// 用户粘进来的那一串整理成密钥:去掉首尾空白、统一成小写。**不是 64 位十六进制就不收** -/// —— 服务器那边也只认这种,收下一把短的只会在连接时换来一句「密钥不正确」 -pub fn normalize(raw: &str) -> Option { - let k = raw.trim().to_ascii_lowercase(); - (k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit())).then_some(k) -} - -pub fn store(id: &str, key: &str) -> anyhow::Result<()> { - entry(id)?.set_password(key)?; - Ok(()) -} - -pub fn load(id: &str) -> anyhow::Result { - Ok(entry(id)?.get_password()?) -} - -/// 删掉这一项。**本来就没有不算错**:连接删了,钥匙串那边早被用户清过也无妨 -pub fn remove(id: &str) -> anyhow::Result<()> { - match entry(id)?.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(e.into()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_64_hex_characters_are_a_key() { - let k = "9F2C".repeat(16); - assert_eq!(normalize(&format!(" {k}\n")), Some(k.to_ascii_lowercase())); - assert_eq!(normalize("abc"), None); - assert_eq!(normalize(&"g".repeat(64)), None); - assert_eq!(normalize(&"a".repeat(65)), None); - } -} diff --git a/src-tauri/src/connection/mod.rs b/src-tauri/src/connection/mod.rs index 33a366b4..02c7b1be 100644 --- a/src-tauri/src/connection/mod.rs +++ b/src-tauri/src/connection/mod.rs @@ -3,7 +3,8 @@ //! 应用同一时间只连一个 core。**几条原则,都来自同一种要避开的失败**:远程连不上, //! 界面就初始化不了,只能重装或者把远程修好才能切回本机。 //! -//! - 界面外壳不依赖 core。连接列表归应用自己存(`store`),密钥进钥匙串(`keychain`)。 +//! - 界面外壳不依赖 core。连接列表归应用自己存(`store`),密钥另存一个只有自己能读的 +//! 文件(`secrets`)。 //! - 「本机」内置、删不掉,任何状态下一步就能切回来。 //! - 先试连,再切换(`connector::test`)。失败就留在原来的连接上,说明原因。 //! - **绝不悄悄退回本机。**连不上远程时不自动拉起本机的 core:两边配置不同,客户端又 @@ -12,8 +13,8 @@ //! - 断线只报一次:一条系统通知,界面上一条横幅;连上后按现有的对账机制补齐。 pub mod connector; -pub mod keychain; pub mod launch; +pub mod secrets; pub mod store; use std::sync::Arc; @@ -326,7 +327,7 @@ fn begin_remote(app: &tauri::AppHandle, link: &Link, r: Remote, first: Option, } @@ -579,18 +580,18 @@ fn check(c: &store::Connections, p: &ProfileInput) -> Result<(), Invalid> { return bad("port", "invalid"); } match &p.key { - Some(k) if keychain::normalize(k).is_none() => bad("key", "invalid"), + Some(k) if secrets::normalize(k).is_none() => bad("key", "invalid"), None if p.id.is_none() => bad("key", "empty"), _ => Ok(()), } } -/// 试连对话框里填的这一条。**还没存**:编辑时没换密钥,就用钥匙串里那一把 +/// 试连对话框里填的这一条。**还没存**:编辑时没换密钥,就用已经保存的那一把 #[tauri::command] pub async fn test_connection(input: ProfileInput) -> Out { let key = match (&input.key, &input.id) { - (Some(k), _) => keychain::normalize(k).unwrap_or_default(), - (None, Some(id)) => keychain::load(id).map_err(|e| keychain_error(&e))?, + (Some(k), _) => secrets::normalize(k).unwrap_or_default(), + (None, Some(id)) => secrets::load(&data_dir(), id).map_err(|e| key_error(&e))?, (None, None) => String::new(), }; let target = RemoteTarget { @@ -618,7 +619,7 @@ pub enum Saved { Invalid { invalid: Invalid }, } -/// 存一条连接:密钥先进钥匙串,名字和地址再进列表。**当前连着的那一条改了地址或 +/// 存一条连接:密钥先存好,名字和地址再进列表。**当前连着的那一条改了地址或 /// 密钥,要重新连一次才生效** —— 这里顺手重连 #[tauri::command] pub async fn save_connection(app: tauri::AppHandle, input: ProfileInput) -> Out { @@ -628,8 +629,8 @@ pub async fn save_connection(app: tauri::AppHandle, input: ProfileInput) -> Out< return Ok(Saved::Invalid { invalid }); } let id = input.id.clone().unwrap_or_else(store::new_id); - if let Some(k) = input.key.as_deref().and_then(keychain::normalize) { - keychain::store(&id, &k).map_err(|e| keychain_error(&e))?; + if let Some(k) = input.key.as_deref().and_then(secrets::normalize) { + secrets::store(&dir, &id, &k).map_err(|e| key_error(&e))?; } let remote = Remote { id: id.clone(), @@ -675,8 +676,8 @@ pub fn delete_connection(app: tauri::AppHandle, id: String) -> Out { } }) .map_err(saving)?; - if let Err(e) = keychain::remove(&id) { - tracing::warn!("钥匙串里那一项没删掉:{e:#}"); + if let Err(e) = secrets::remove(&data_dir(), &id) { + tracing::warn!("保存的密钥没删掉:{e:#}"); } announce(&app); Ok(view(&app)) @@ -711,10 +712,10 @@ fn saving(e: anyhow::Error) -> CmdError { )) } -fn keychain_error(e: &anyhow::Error) -> CmdError { +fn key_error(e: &anyhow::Error) -> CmdError { CmdError::plain(tr!( - format!("无法访问钥匙串:{e:#}"), - format!("The keychain could not be accessed: {e:#}") + format!("无法读写保存的密钥:{e:#}"), + format!("The saved key could not be read or written: {e:#}") )) } @@ -779,7 +780,7 @@ mod tests { let v = serde_json::to_value(SwitchError::Connect { error: e.clone() }).unwrap(); assert_eq!(v["kind"], "connect"); assert_eq!(v["error"]["kind"], "timeout"); - let v = serde_json::to_value(SwitchError::Keychain { detail: "x".into() }).unwrap(); + let v = serde_json::to_value(SwitchError::KeyUnreadable { detail: "x".into() }).unwrap(); assert_eq!(v["detail"], "x"); let v = serde_json::to_value(Tested::Failed { error: e }).unwrap(); assert_eq!(v["result"], "failed"); diff --git a/src-tauri/src/connection/secrets.rs b/src-tauri/src/connection/secrets.rs new file mode 100644 index 00000000..f9bfa5f8 --- /dev/null +++ b/src-tauri/src/connection/secrets.rs @@ -0,0 +1,142 @@ +//! 远程连接的控制面密钥:存在这台电脑的数据目录里,一个只有自己能读的文件。 +//! +//! **不用系统钥匙串。**macOS 上钥匙串会为这一项弹框要登录密码(未签名的应用每次 +//! 更新之后还会再问),为一把随时能在服务器上重新抄一遍的密钥打断用户不值得;三个 +//! 平台各有一套钥匙串接口,也意味着三条各自出错、各自没人测的路。现在只有一条: +//! 数据目录(unix `0700`、Windows 受保护的 DACL,见 `crate::private_dir`)里的一个 +//! `0600` 文件 —— 和 config.yaml 里明文的上游 API key 是同一个保护等级。 +//! +//! **另放一个文件,不写进 `connections.json`**:那份列表时常被改写(上次连接的时刻、 +//! 上次用的是哪条),界面要的连接列表也从它来;密钥单独放,改列表的每一次写入都碰 +//! 不到它,读列表的代码也拿不到它。界面同样拿不到:添加、更换时从对话框递过来一次, +//! 之后再也不往回给 —— 编辑对话框里显示的是「已保存」。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const FILE: &str = "connection-keys.json"; + +fn path(dir: &Path) -> PathBuf { + dir.join(FILE) +} + +/// 用户粘进来的那一串整理成密钥:去掉首尾空白、统一成小写。**不是 64 位十六进制就不收** +/// —— 服务器那边也只认这种,收下一把短的只会在连接时换来一句「密钥不正确」 +pub fn normalize(raw: &str) -> Option { + let k = raw.trim().to_ascii_lowercase(); + (k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit())).then_some(k) +} + +/// 连接 id → 密钥。**文件不在就是空的**:还没添加过远程连接 +fn read_all(dir: &Path) -> anyhow::Result> { + match std::fs::read(path(dir)) { + Ok(b) => Ok(serde_json::from_slice(&b)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(e) => Err(e.into()), + } +} + +/// 整份写回。**从建出来那一刻就是 `0600`**:先写一个同样 `0600` 的临时文件,再改名 +/// 换上去 —— 不是写完再 `chmod`(中间有一个按 umask 谁都能读的窗口),写到一半断电 +/// 留下的也是旧的那份 +fn write_all(dir: &Path, keys: &BTreeMap) -> anyhow::Result<()> { + use std::io::Write; + crate::private_dir::create(dir)?; + let tmp = dir.join(format!("{FILE}.tmp")); + let _ = std::fs::remove_file(&tmp); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&tmp)?; + let mut text = serde_json::to_vec_pretty(keys)?; + text.push(b'\n'); + f.write_all(&text)?; + f.sync_all()?; + drop(f); + std::fs::rename(&tmp, path(dir))?; + Ok(()) +} + +pub fn store(dir: &Path, id: &str, key: &str) -> anyhow::Result<()> { + let mut all = read_all(dir)?; + all.insert(id.to_string(), key.to_string()); + write_all(dir, &all) +} + +pub fn load(dir: &Path, id: &str) -> anyhow::Result { + read_all(dir)? + .remove(id) + .ok_or_else(|| anyhow::anyhow!("no key is saved for connection {id}")) +} + +/// 删掉这一条。**本来就没有不算错** +pub fn remove(dir: &Path, id: &str) -> anyhow::Result<()> { + let mut all = read_all(dir)?; + if all.remove(id).is_some() { + write_all(dir, &all)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tw-secrets-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + p + } + + #[test] + fn only_64_hex_characters_are_a_key() { + let k = "9F2C".repeat(16); + assert_eq!(normalize(&format!(" {k}\n")), Some(k.to_ascii_lowercase())); + assert_eq!(normalize("abc"), None); + assert_eq!(normalize(&"g".repeat(64)), None); + assert_eq!(normalize(&"a".repeat(65)), None); + } + + #[test] + fn keys_are_stored_per_connection_and_removed() { + let dir = tmp("trip"); + store(&dir, "a1", &"a".repeat(64)).unwrap(); + store(&dir, "b2", &"b".repeat(64)).unwrap(); + store(&dir, "a1", &"c".repeat(64)).unwrap(); + assert_eq!(load(&dir, "a1").unwrap(), "c".repeat(64)); + assert_eq!(load(&dir, "b2").unwrap(), "b".repeat(64)); + remove(&dir, "a1").unwrap(); + assert!(load(&dir, "a1").is_err()); + remove(&dir, "a1").unwrap(); + assert_eq!(load(&dir, "b2").unwrap(), "b".repeat(64)); + } + + /// 文件和(新建的)目录都只给自己 + #[cfg(unix)] + #[test] + fn the_file_is_private_from_the_start() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("mode").join("data"); + store(&dir, "a1", &"a".repeat(64)).unwrap(); + store(&dir, "a2", &"b".repeat(64)).unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&path(&dir)), 0o600); + assert_eq!(mode(&dir), 0o700); + assert!(!dir.join(format!("{FILE}.tmp")).exists()); + } + + /// 连接列表那份文件里没有密钥:两份分开放 + #[test] + fn the_list_file_never_sees_a_key() { + let dir = tmp("apart"); + store(&dir, "a1", &"e".repeat(64)).unwrap(); + crate::connection::store::save(&dir, &crate::connection::store::Connections::default()) + .unwrap(); + let list = std::fs::read_to_string(dir.join("connections.json")).unwrap(); + assert!(!list.contains(&"e".repeat(64))); + } +} diff --git a/src-tauri/src/connection/store.rs b/src-tauri/src/connection/store.rs index d9407994..d7228a06 100644 --- a/src-tauri/src/connection/store.rs +++ b/src-tauri/src/connection/store.rs @@ -2,7 +2,7 @@ //! //! 界面外壳不能依赖 core —— 远程连不上时,连接列表、切换入口照样要能显示、能操作, //! 所以它归应用自己保存(和 `prefs.rs` 同一个目录,另一个文件)。**密钥不在这里**: -//! 进系统钥匙串(`keychain.rs`),这个文件里只有名字和地址。 +//! 另存一个只有自己能读的文件(`secrets.rs`),这个文件里只有名字和地址。 //! //! 「本机」不存:它是内置的、删不掉,列表里永远有它。 @@ -17,7 +17,7 @@ pub const LOCAL: &str = "local"; /// 一条远程连接 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Remote { - /// 随机生成,不随改名变。钥匙串里的那一项按它找 + /// 随机生成,不随改名变。保存的密钥按它找 pub id: String, pub name: String, /// 主机名或 IP @@ -88,7 +88,7 @@ fn path(dir: &Path) -> PathBuf { } /// 读列表。**读不出来就是只有本机**:文件不在(第一次运行)和文件坏了,对用户的意义 -/// 一样 —— 为一份列表让应用起不来,代价不对。密钥在钥匙串里,不会跟着丢 +/// 一样 —— 为一份列表让应用起不来,代价不对。密钥在另一个文件里,不会跟着丢 pub fn load(dir: &Path) -> Connections { std::fs::read(path(dir)) .ok() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d42915f..086e085e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,8 @@ pub mod memcheck; pub mod menubar; pub mod notices; pub mod prefs; +/// 建只有自己能读的数据目录,见模块头上 +pub mod private_dir; pub mod settings; pub mod supervisor; pub mod theme; diff --git a/src-tauri/src/private_dir.rs b/src-tauri/src/private_dir.rs new file mode 100644 index 00000000..c0a3b05a --- /dev/null +++ b/src-tauri/src/private_dir.rs @@ -0,0 +1,218 @@ +//! 建数据目录,**只给自己看**。 +//! +//! 和 core 的 `tw_config::private_dir` 是同一个做法(那边建数据目录用它;这里 +//! 不为这一个函数去依赖整个 tw-config):unix 上 `mkdir` 那一刻就是 `0700`,Windows +//! 上带一份受保护的 DACL(只有当前用户和 SYSTEM),里面新建的文件跟着继承。 +//! **已经存在的目录不去动** —— 用户自己放宽过的,是他的决定。 +//! +//! 应用这边要它,是因为远程连接的密钥落在这个目录里(见 `connection::secrets`), +//! 而用户可能在 core 第一次起来、建出目录之前就先添加了一条远程连接。 + +use std::path::Path; + +/// 建出数据目录。已经在了就什么都不做。 +pub fn create(dir: &Path) -> std::io::Result<()> { + if dir.exists() { + return Ok(()); + } + // 上级按常规建。**密钥在叶子目录里**,而叶子目录上一个受保护的 DACL + // 已经挡住了读它内容的人 —— 上级宽松只是让人知道有这么个目录。 + if let Some(parent) = dir.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + imp::create_private(dir) +} + +#[cfg(not(windows))] +mod imp { + use std::path::Path; + + /// unix:建成 `0700`。`mode` 是在 `mkdir(2)` 那一刻给的,没有先宽后收的窗口。 + pub fn create_private(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + match std::fs::DirBuilder::new().mode(0o700).create(dir) { + // 查过不在、建的时候却在了:别的进程抢先一步,和 `create_dir_all` 一样不算错 + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => Ok(()), + r => r, + } + } +} + +#[cfg(windows)] +mod imp { + use std::path::Path; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + GetTokenInformation, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + pub fn create_private(dir: &Path) -> std::io::Result<()> { + let sid = current_user_sid()?; + // `D:P` —— 一份**受保护的** DACL:不继承父目录那些条目,否则 + // `D:\` 上那条给 `Users` 的就跟着进来了,而那正是要挡的东西。 + // `OICI` 让里面新建的文件和子目录跟着继承这两条。 + // `FA` 完全控制:当前用户,以及 SYSTEM(`SY`,不给它的话备份、 + // 索引这类系统服务会在这个目录上报错)。 + let sddl = format!("D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)"); + with_security_attributes(&sddl, |sa| { + let path = wide(dir); + // SAFETY: 路径是以 NUL 结尾的 UTF-16,`sa` 在这次调用期间有效。 + let ok = unsafe { CreateDirectoryW(path.as_ptr(), sa) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + } + + fn wide(p: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + /// 当前用户的 SID,写成 `S-1-5-21-…` 那种字符串。 + /// + /// **问令牌要,不写死任何东西。**SDDL 里有 `CO`(Creator Owner)这类 + /// 简写,但它们是给可继承条目当占位符用的,放在对象自己的 DACL 上 + /// 不代表「建它的那个人」。 + fn current_user_sid() -> std::io::Result { + struct Token(HANDLE); + impl Drop for Token { + fn drop(&mut self) { + // SAFETY: 拿到过的句柄,只关一次 + unsafe { CloseHandle(self.0) }; + } + } + + let mut raw: HANDLE = std::ptr::null_mut(); + // SAFETY: 出参是本地变量 + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = Token(raw); + + // 先问要多大,再按那个大小要一次 + let mut len: u32 = 0; + // SAFETY: 传空缓冲区只为问尺寸;失败是预期的(ERROR_INSUFFICIENT_BUFFER) + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return Err(std::io::Error::last_os_error()); + } + // `TOKEN_USER` 后面跟着 SID 的字节,所以缓冲区要按它对齐 —— + // `Vec` 只保证 1 字节对齐,而我们要把它当成那个结构体读。 + let words = (len as usize).div_ceil(std::mem::size_of::()); + let mut buf = vec![0u64; words.max(1)]; + // SAFETY: 缓冲区至少 `len` 字节,且对齐得下 TOKEN_USER + let ok = unsafe { + GetTokenInformation( + token.0, + TokenUser, + buf.as_mut_ptr() as *mut std::ffi::c_void, + len, + &mut len, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: 调用成功,缓冲区开头是一个 TOKEN_USER + let sid = unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid }; + let mut s: *mut u16 = std::ptr::null_mut(); + // SAFETY: `sid` 指向刚拿到的那份数据,出参是本地变量 + if unsafe { ConvertSidToStringSidW(sid, &mut s) } == 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: 转换成功,`s` 是一段以 NUL 结尾的 UTF-16,由 LocalFree 释放 + let out = unsafe { + let mut n = 0usize; + while *s.add(n) != 0 { + n += 1; + } + let out = String::from_utf16_lossy(std::slice::from_raw_parts(s, n)); + LocalFree(s as *mut std::ffi::c_void); + out + }; + Ok(out) + } + + /// 把一段 SDDL 变成 `SECURITY_ATTRIBUTES`,用完释放。 + fn with_security_attributes( + sddl: &str, + f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> std::io::Result, + ) -> std::io::Result { + let text: Vec = sddl.encode_utf16().chain(std::iter::once(0)).collect(); + let mut sd: *mut std::ffi::c_void = std::ptr::null_mut(); + // SAFETY: `text` 是以 NUL 结尾的 UTF-16;出参是本地变量 + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + text.as_ptr(), + SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: sd, + bInheritHandle: 0, + }; + let r = f(&sa); + // SAFETY: `sd` 由上面那次转换分配,只释放一次 + unsafe { LocalFree(sd) }; + r + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("tw-private-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + p + } + + #[test] + fn it_creates_the_directory_and_its_parents() { + let dir = tmp("parents").join("a/b/data"); + create(&dir).unwrap(); + assert!(dir.is_dir()); + } + + /// 已经在了就不动它 + #[test] + fn creating_one_that_is_already_there_is_not_an_error() { + let dir = tmp("again"); + create(&dir).unwrap(); + std::fs::write(dir.join("x"), "1").unwrap(); + create(&dir).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("x")).unwrap(), "1"); + } + + #[cfg(unix)] + #[test] + fn a_new_directory_is_private_on_unix() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("mode").join("data"); + create(&dir).unwrap(); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{mode:o}"); + } +} diff --git a/src/connection/ProfileDialog.tsx b/src/connection/ProfileDialog.tsx index 47337445..783ca832 100644 --- a/src/connection/ProfileDialog.tsx +++ b/src/connection/ProfileDialog.tsx @@ -20,13 +20,13 @@ import { connApi, type ConnectError, type Invalid, type Profile, type ServerInfo import { connText } from "./connection.i18n"; import { code, describeError } from "./describe"; -/** 密钥:64 位十六进制。和 Rust 那边 `keychain::normalize` 同一条规矩 */ +/** 密钥:64 位十六进制。和 Rust 那边 `secrets::normalize` 同一条规矩 */ const KEY_RE = /^[0-9a-fA-F]{64}$/; /** * 添加、编辑一条远程连接(设计稿 ③)。 * - * **编辑时密钥不回显。**它在钥匙串里,界面拿不回来:显示「已保存在钥匙串中」,点 + * **编辑时密钥不回显。**它存在这台电脑上,界面拿不回来:显示「已保存」,点 * 「更换」才填新的;不换就沿用。 * * 「保存并切换」**先试连**,没通过就停在这里显示原因,不存也不切。通过了才存, diff --git a/src/connection/SwitchDialog.tsx b/src/connection/SwitchDialog.tsx index 64c0e277..e4ac5c9d 100644 --- a/src/connection/SwitchDialog.tsx +++ b/src/connection/SwitchDialog.tsx @@ -98,7 +98,7 @@ export function SwitchDialog({ setStage({ kind: "failed", error: err.kind === "connect" ? err.error : null, - text: err.kind === "keychain" ? t.keychainFailed : err.kind === "unknown" ? t.gone : null, + text: err.kind === "key_unreadable" ? t.keyUnreadable : err.kind === "unknown" ? t.gone : null, }); } else { setStage({ kind: "failed", error: null, text: errorText(e) }); diff --git a/src/connection/api.ts b/src/connection/api.ts index 91656e4a..9cbfe2db 100644 --- a/src/connection/api.ts +++ b/src/connection/api.ts @@ -72,7 +72,7 @@ export interface ProfileInput { name: string; host: string; port: number; - /** 新建时必填;编辑时不填就沿用钥匙串里的那一把 */ + /** 新建时必填;编辑时不填就沿用已经保存的那一把 */ key: string | null; } @@ -85,10 +85,10 @@ export type Invalid = { export type Saved = { result: "ok"; profile: Profile } | { result: "invalid"; invalid: Invalid }; -/** 切换没做成:列表里已经没有这一条、钥匙串取不出密钥、试连没通过 */ +/** 切换没做成:列表里已经没有这一条、保存的密钥读不出来、试连没通过 */ export type SwitchError = | { kind: "unknown" } - | { kind: "keychain"; detail: string } + | { kind: "key_unreadable"; detail: string } | { kind: "connect"; error: ConnectError }; /** 切过去之前要说的:这台机器上已接管、还指着本机网关的客户端 */ diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx index 60d070b4..87cb4b34 100644 --- a/src/connection/connection.i18n.tsx +++ b/src/connection/connection.i18n.tsx @@ -1,13 +1,10 @@ import type { ReactNode } from "react"; import { messages } from "@/i18n"; -import { isLinux, isMac, isWindows } from "@/platform"; +import { isLinux, isMac } from "@/platform"; /** 句中嵌着的一段代码(配置项、命令)。怎么画由组件决定 */ type Code = (text: string) => ReactNode; -/** 密钥存在哪。按平台说用户认得的那个名字 */ -const vaultZh = isWindows ? "凭据管理器" : isLinux ? "系统密钥环" : "钥匙串"; -const vaultEn = isWindows ? "Credential Manager" : isLinux ? "the system keyring" : "the keychain"; /** 启动时按住哪个键 */ const holdZh = isMac ? "Option" : "Alt"; @@ -52,7 +49,7 @@ export const connText = messages( ? "连续两次启动未能完成时,下次启动先显示连接选择。" : `按住 ${holdZh} 键启动应用时,先显示连接选择。`, deleteTitle: (name: string) => `删除连接「${name}」?`, - deleteBody: `保存在${vaultZh}中的密钥一并删除。服务器上的 core 不受影响。`, + deleteBody: "保存在本机的密钥一并删除。服务器上的 core 不受影响。", // 添加与编辑 addTitle: "添加远程连接", @@ -67,10 +64,10 @@ export const connText = messages( key: "密钥", keyHint: (code: Code) => ( <> - 在服务器上执行 {code("twcore control-key")} 获取。保存在{vaultZh}中。 + 在服务器上执行 {code("twcore control-key")} 获取。仅保存在本机,只有当前用户可以读取。 ), - keySaved: `已保存在${vaultZh}中`, + keySaved: "已保存在本机", replaceKey: "更换", test: "测试连接", testHint: "完成握手并读取服务器 core 的版本。", @@ -120,7 +117,7 @@ export const connText = messages( switchAction: "切换", editConnection: "编辑连接", retargetFailed: "客户端未能改为指向服务器,可在客户端页逐个修改。", - keychainFailed: `无法从${vaultZh}中读取密钥`, + keyUnreadable: "无法读取保存在本机的密钥,请在编辑连接时重新填写。", gone: "该连接已被删除。", // 未连接页 @@ -189,7 +186,7 @@ export const connText = messages( ? "After two startups in a row fail to finish, the next startup shows the connection choice first." : `Hold ${holdZh} while opening the app to choose a connection first.`, deleteTitle: (name: string) => `Delete the connection “${name}”?`, - deleteBody: `The key saved in ${vaultEn} is deleted as well. The core on the server is not affected.`, + deleteBody: "The key saved on this computer is deleted as well. The core on the server is not affected.", addTitle: "Add remote connection", editTitle: "Edit connection", @@ -203,10 +200,10 @@ export const connText = messages( key: "Key", keyHint: (code: Code) => ( <> - Run {code("twcore control-key")} on the server to get it. Saved in {vaultEn}. + Run {code("twcore control-key")} on the server to get it. Saved only on this computer, readable by the current user alone. ), - keySaved: `Saved in ${vaultEn}`, + keySaved: "Saved on this computer", replaceKey: "Replace", test: "Test connection", testHint: "Completes the handshake and reads the server's core version.", @@ -262,7 +259,7 @@ export const connText = messages( switchAction: "Switch", editConnection: "Edit connection", retargetFailed: "The clients could not be pointed at the server. They can be changed one by one on the Clients page.", - keychainFailed: `The key could not be read from ${vaultEn}`, + keyUnreadable: "The key saved on this computer could not be read. Enter it again by editing the connection.", gone: "This connection has been deleted.", cannotConnect: (name: string) => `Cannot connect to ${name}`,