From ea3afc395a66a148aa83a7cafe1ff5f26b450eff Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:03:36 +0800 Subject: [PATCH 1/5] Control handshake: local and remote connections go through tw-link - Pin tw-api and the new tw-link to core main 1fae554 (same rev for both). - Local mode: no more per-launch token. ControlClient reads listen.control.key from config.yaml on every connection and runs the Noise handshake on the socket / Windows loopback; a missing config or key counts as "core not ready yet"; after WrongKey the key is read once more (rotation). token.rs, the Bearer header and TW_CONTROL_TOKEN are gone. - Remote mode: connector::handshake now uses tw_link::connect; LinkError maps onto ConnectError, VersionMismatch carries the server core's own version. ConnectError::NotYetAvailable is removed. - The app's required core version comes from the locked tw-api version (works for tags and revs). - Integration tests run against a real core with the key from its config, a wrong key, and a key rotated with `twcore control-key --rotate`. - Regenerated tw-api.ts (CONTROL_API_VERSION 18); zh translations for config.control_key_missing, config.control_key_invalid, control.control_key_locked; control.unauthorized removed. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 634 ++++++++++++++++---------- src-tauri/Cargo.toml | 16 +- src-tauri/build.rs | 19 +- src-tauri/src/connection/connector.rs | 150 ++++-- src-tauri/src/control.rs | 219 ++++++--- src-tauri/src/diagnostics.rs | 3 +- src-tauri/src/gateway.rs | 4 +- src-tauri/src/lib.rs | 19 +- src-tauri/src/supervisor/mod.rs | 29 +- src-tauri/src/supervisor/user_env.rs | 4 +- src-tauri/src/token.rs | 46 -- src-tauri/tests/control_plane.rs | 140 ++++-- src/App.tsx | 4 +- src/connection/api.ts | 3 +- src/connection/connection.i18n.tsx | 4 - src/connection/describe.test.tsx | 3 +- src/connection/describe.tsx | 4 - src/generated/tw-api.ts | 2 +- src/i18n/core.zh.json | 4 +- 19 files changed, 823 insertions(+), 484 deletions(-) delete mode 100644 src-tauri/src/token.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2af228ce..1c6b9be3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -211,7 +221,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -301,13 +311,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -408,7 +427,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -429,9 +448,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -456,7 +475,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -471,9 +490,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.5" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -508,9 +527,20 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] [[package]] name = "chacha20" @@ -523,6 +553,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -535,6 +578,17 @@ 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", + "zeroize", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -625,7 +679,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types", "foreign-types", @@ -638,7 +692,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "libc", ] @@ -663,9 +717,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -740,6 +794,31 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.24.1" @@ -760,7 +839,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -771,7 +850,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -802,7 +881,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -854,6 +933,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -893,7 +973,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users 0.5.3", "windows-sys 0.61.2", ] @@ -903,7 +983,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -917,7 +997,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1033,7 +1113,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -1145,6 +1225,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1167,9 +1253,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fixedbitset" @@ -1224,7 +1310,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1304,7 +1390,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1527,7 +1613,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -1770,9 +1856,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1820,7 +1906,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1844,12 +1930,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1857,9 +1944,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1870,9 +1957,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1884,16 +1971,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1904,15 +1992,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1942,9 +2030,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1952,9 +2040,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2002,7 +2090,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -2016,6 +2104,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2152,7 +2249,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -2237,7 +2334,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] @@ -2258,7 +2355,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -2310,9 +2407,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -2439,9 +2536,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2464,7 +2561,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -2474,7 +2571,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2513,7 +2610,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -2545,7 +2642,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -2610,7 +2707,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -2631,7 +2728,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -2642,7 +2739,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -2653,7 +2750,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -2664,7 +2761,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -2697,7 +2794,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2709,7 +2806,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2737,7 +2834,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -2750,7 +2847,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -2761,7 +2858,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -2773,7 +2870,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2785,7 +2882,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -2806,7 +2903,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-core-location", @@ -2819,7 +2916,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -2833,11 +2930,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", @@ -2897,7 +3000,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3049,13 +3152,13 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "indexmap 2.14.2", - "quick-xml 0.38.4", + "quick-xml 0.42.0", "serde", "time", ] @@ -3079,7 +3182,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -3100,6 +3203,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3171,7 +3285,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -3221,18 +3335,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -3274,7 +3388,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3316,7 +3430,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3332,13 +3446,12 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3358,7 +3471,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3392,11 +3505,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -3470,11 +3583,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3582,7 +3695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0b1b76852c5643f558e7347e4de549474c3ecb7154295a189f4668428c1b453" dependencies = [ "arraydeque", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3657,7 +3770,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3680,7 +3793,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -3742,7 +3855,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3777,7 +3890,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3828,7 +3941,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3947,9 +4060,23 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "rustc_version", + "subtle", +] [[package]] name = "socket2" @@ -4085,9 +4212,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -4105,13 +4232,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -4120,7 +4247,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4154,7 +4281,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "core-foundation 0.10.1", "core-graphics", @@ -4182,7 +4309,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core", + "windows-core 0.61.2", "windows-version", "x11-dl", ] @@ -4217,9 +4344,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" dependencies = [ "anyhow", "bytes", @@ -4256,7 +4383,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tray-icon", "url", @@ -4307,7 +4434,7 @@ dependencies = [ "sha2", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "url", "uuid", @@ -4355,7 +4482,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4370,7 +4497,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4387,7 +4514,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "url", "windows-registry 0.5.3", @@ -4408,7 +4535,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "url", ] @@ -4429,7 +4556,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "windows", "zbus", @@ -4445,7 +4572,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "windows-sys 0.60.2", @@ -4454,9 +4581,9 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -4477,7 +4604,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tokio", "url", @@ -4503,7 +4630,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webview2-com", @@ -4566,8 +4693,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.20", - "toml 1.1.5+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4582,7 +4709,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -4591,7 +4718,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "windows", "windows-version", ] @@ -4661,7 +4788,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "tracing-subscriber", @@ -4669,6 +4796,7 @@ dependencies = [ "tw-adopt", "tw-api", "tw-guard", + "tw-link", "tw-scan", "tw-types", "windows", @@ -4687,11 +4815,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4707,13 +4835,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4727,9 +4855,9 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", @@ -4818,7 +4946,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4873,9 +5001,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.5+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap 2.14.2", "serde_core", @@ -4939,9 +5067,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", @@ -4986,7 +5114,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -5089,7 +5217,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -5116,7 +5244,7 @@ version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "ts-rs-macros", ] @@ -5139,8 +5267,8 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", - "toml_edit 0.25.13+spec-1.1.0", + "thiserror 2.0.21", + "toml_edit 0.25.15+spec-1.1.0", "tw-api", "tw-types", "tw-yaml", @@ -5149,8 +5277,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "serde", "serde_json", @@ -5160,8 +5288,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "serde", "serde_json", @@ -5169,18 +5297,32 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "base64 0.22.1", "regex", "serde", "serde_json", "serde_yaml_ng", - "thiserror 2.0.20", + "thiserror 2.0.21", "tw-dialect", ] +[[package]] +name = "tw-link" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" +dependencies = [ + "serde", + "serde_json", + "snow", + "thiserror 2.0.21", + "tokio", + "tw-api", + "tw-yaml", +] + [[package]] name = "tw-scan" version = "0.1.0" @@ -5196,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "serde", "ts-rs", @@ -5205,21 +5347,21 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "notify", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] [[package]] name = "tw-yaml" -version = "0.46.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.46.0#5c16e6bb8018c2a4818b3d8ab218e5274f6c2780" +version = "0.47.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.47.0#8095405672d6271bc495d8b8336b3e1b17b84524" dependencies = [ "saphyr-parser", - "thiserror 2.0.20", + "thiserror 2.0.21", "tw-types", ] @@ -5289,9 +5431,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -5299,6 +5441,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5344,9 +5496,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5419,9 +5571,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] @@ -5468,7 +5620,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -5513,7 +5665,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustix", "wayland-backend", "wayland-scanner", @@ -5521,11 +5673,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5537,7 +5689,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5648,7 +5800,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core", + "windows-core 0.61.2", "windows-implement", "windows-interface", ] @@ -5670,9 +5822,9 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "windows", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -5734,7 +5886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -5746,7 +5898,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -5762,13 +5914,26 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] @@ -5813,7 +5978,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -6138,9 +6303,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -6172,9 +6334,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" @@ -6186,7 +6348,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.20", + "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -6233,13 +6395,13 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", "windows", - "windows-core", + "windows-core 0.61.2", "windows-version", "x11-dl", ] @@ -6305,21 +6467,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zbus" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -6344,7 +6506,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -6352,14 +6514,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -6367,29 +6529,38 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.4", "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", @@ -6407,13 +6578,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -6453,7 +6624,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -6470,9 +6641,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" @@ -6482,55 +6653,56 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.4.12" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] [[package]] name = "zvariant" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", - "winnow 0.7.15", + "syn 3.0.6", + "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4b806fe4..45e9d5b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,11 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.46.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.46.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.46.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.46.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.46.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.47.0" } [lib] name = "thinkwatch_lite_lib" @@ -64,9 +65,12 @@ tw-adopt = { path = "crates/tw-adopt" } tw-scan = { path = "crates/tw-scan" } tw-types = { workspace = true } tw-guard = { workspace = true } +# 控制面的握手与加密(Noise NNpsk0),和 core 用的是同一份实现。握手里交换的控制面 +# 版本号来自 tw-api,所以两者必须钉在同一个 tag 上(见上面 `[workspace.dependencies]`) +tw-link = { workspace = true } anyhow = "1" -# 生成控制面的凭据(见 src/token.rs) +# 新连接的 id、诊断包的临时目录名 rand = "0.10" libc = "0.2" # 界面和 Rust 侧之间不经过 core 的那些类型,导出成 TypeScript(`wire.rs`)。 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 30c674da..50114ef8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -3,30 +3,25 @@ fn main() { tauri_build::build() } -/// 这一版应用配的是哪一版 core:`Cargo.lock` 里 tw-api 锁到的 tag,去掉开头的 `v`。 +/// 这一版应用配的是哪一版 core:`Cargo.lock` 里锁到的 tw-api 的版本号。 /// /// 连远程 core 时版本对不上,界面要说出「本应用需要 core X」。**从锁文件读,不另写 -/// 一份**:打包脚本取 twcore 看的也是这一行(`scripts/fetch-core.sh`),两处说的 -/// 必然是同一版。读不到(临时用 `[patch]` 指向本地 core 时就是这样)记成 `dev` +/// 一份**。读版本号而不是 tag:临时钉在某个提交上(还没发版)时没有 tag,而 core 的 +/// 各个 crate 和 twcore 共用同一个版本号 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; + let mut version = 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()); + } else if in_api && let Some(v) = line.strip_prefix("version = ") { + version = Some(v.trim_matches('"').to_string()); } } println!( "cargo:rustc-env=TW_CORE_TAG={}", - tag.unwrap_or_else(|| "dev".into()) + version.unwrap_or_else(|| "dev".into()) ); } diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index e4712957..8efdbeaf 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -6,12 +6,9 @@ //! 失败分五种说([`ConnectError`]),每一种界面上都给出下一步:地址不通、被对面 //! 关掉、密钥不对、版本不一致、超时。 //! -//! # 远程那一档还没接上(P2) -//! -//! 远程控制面的握手(Noise,密钥来自服务器的 `listen.control.key`)由 core 的 -//! `tw-link` 提供,还没发版。这里先把 TCP 连上 —— 地址不通和超时现在就能如实说 —— -//! 握手那一步集中在 [`handshake`] 一个函数里,眼下一律回 [`ConnectError::NotYetAvailable`]。 -//! 接上 P2 时只改它(和删掉那一种错误)。 +//! 远程这一档:TCP 连上服务器的远程控制端口,再做和本机同一套握手(`tw-link`, +//! Noise NNpsk0,钥匙是服务器上 `twcore control-key` 给的那一把)。版本在握手里就 +//! 对过了,所以对不上时在任何 HTTP 请求之前就能说出两个确切的版本号。 use std::time::Duration; @@ -75,8 +72,6 @@ pub enum ConnectError { VersionMismatch { ours: String, theirs: String }, /// 在限定时间内没连上,或者握手没走完 Timeout { addr: String }, - /// 这一版应用还不会远程握手。**P2 接上握手时删掉这一种** - NotYetAvailable, } /// 一句话。**界面不用它**(界面按种类说,带下一步);它给的是经控制面客户端冒出来、 @@ -103,11 +98,6 @@ impl std::fmt::Display for ConnectError { 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) } @@ -129,6 +119,9 @@ pub struct Handshake { pub core_version: String, } +/// 应用自己的版本,握手时报给 core(只进它的日志) +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + /// 试连:连上、握手、问一次 `/status`。**切换之前、启动时、断线重连都走它。** pub async fn test(target: &Target) -> Result { match target { @@ -189,18 +182,12 @@ pub(crate) async fn open(r: &RemoteTarget) -> Result<(Box, Handshake .map_err(|_| ConnectError::Timeout { addr })? } -/// **P2 在这里接上握手。** -/// -/// 要做的:用 `tw-link` 的 Noise 握手(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`,PSK 是 -/// `key` 的 32 字节)把 `io` 包成加密的流,第一条消息带上应用的控制面版本和应用版本; -/// 按对面第二条消息的结果返回: +/// 握手:把 `io` 包成加密的流,顺带问到对面是哪一版 core。 /// -/// - 解不开、收到明文拒绝字节 → [`ConnectError::WrongKey`] -/// - `accept=false`(版本不一致)→ [`ConnectError::VersionMismatch`],`ours` 用 -/// [`REQUIRED_CORE`],`theirs` 用对面报的 core 版本 -/// - 对面一个字节不回就关掉 → [`ConnectError::Closed`] -/// -/// 返回的流交给 hyper 跑 HTTP/1.1,事件流(SSE)也走它。 +/// 失败按 [`ConnectError`] 的几种说:解不开、收到拒绝字节 → 钥匙不对;版本对不上 +/// → 两个版本号(`ours` 是这一版应用配的 core,`theirs` 是服务器的 core 自己报的 +/// 版本);一个字节不回就关 → 被关闭。钥匙本身写得不对(钥匙串里那一串不是 64 位 +/// 十六进制)也按钥匙不对说:结果一样,去服务器上重新抄一遍 async fn handshake( io: S, key: &str, @@ -209,9 +196,34 @@ async fn handshake( where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let _ = (io, key); - tracing::debug!("{addr}:TCP 已连上,这一版还不会远程握手"); - Err(ConnectError::NotYetAvailable) + let key = tw_link::ControlKey::parse(key).map_err(|_| ConnectError::WrongKey)?; + let (secure, hello) = tw_link::connect(io, &key, APP_VERSION) + .await + .map_err(|e| link_error(e, addr))?; + Ok(( + Box::new(secure), + Handshake { + core_version: hello.core, + }, + )) +} + +/// `tw-link` 的失败对到界面的几种说法 +fn link_error(e: tw_link::LinkError, addr: &str) -> ConnectError { + use tw_link::LinkError; + tracing::debug!("{addr}:握手没过:{e}"); + let addr = addr.to_string(); + match e { + LinkError::Unreachable(_) => ConnectError::Unreachable { addr }, + LinkError::WrongKey => ConnectError::WrongKey, + LinkError::VersionMismatch { peer_version, .. } => ConnectError::VersionMismatch { + ours: REQUIRED_CORE.to_string(), + theirs: peer_version, + }, + LinkError::Timeout => ConnectError::Timeout { addr }, + // 握手中途断了、或者对面说的不是这套协议:都是「被关掉了」 + LinkError::Closed | LinkError::Io(_) => ConnectError::Closed { addr }, + } } #[cfg(test)] @@ -280,22 +292,94 @@ mod tests { ); } - /// 有人听、握手还没接上:说「这一版还不支持」,不是别的 + fn key() -> String { + "ab".repeat(32) + } + + /// 一个只会握手的「core」:用 `server_key` 那一把,自称 9.9.9,握手之后什么都不做 + async fn fake_core(server_key: String) -> u16 { + 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 key = tw_link::ControlKey::parse(&server_key).unwrap(); + let acceptor = tw_link::Acceptor::new(move || Some(key.clone()), "9.9.9"); + while let Ok((s, _)) = l.accept().await { + let _ = acceptor.accept(s).await; + } + }); + port + } + + fn at(port: u16, key: String) -> Target { + Target::Remote(RemoteTarget { + host: "127.0.0.1".into(), + port, + key, + }) + } + + /// 对面一个字节不回就关:被关闭(不在允许列表里时就是这样) #[tokio::test] - async fn a_listening_port_reaches_the_handshake_seam() { + async fn a_server_that_hangs_up_is_closed() { 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; + while let Ok((s, _)) = l.accept().await { + drop(s); + } }); + let e = test(&at(port, key())).await.unwrap_err(); + assert!(matches!(e, ConnectError::Closed { .. }), "{e:?}"); + } + + #[tokio::test] + async fn another_key_is_the_wrong_key() { + let port = fake_core("cd".repeat(32)).await; + assert_eq!( + test(&at(port, key())).await.unwrap_err(), + ConnectError::WrongKey + ); + // 钥匙串里那一串压根不是一把钥匙,说的也是这一句 + assert_eq!( + test(&at(port, "short".into())).await.unwrap_err(), + ConnectError::WrongKey + ); + } + + /// 同一把钥匙:握手通过,问到服务器 core 自己报的版本 + #[tokio::test] + async fn the_same_key_gets_through_and_learns_the_core_version() { + let port = fake_core(key()).await; let r = RemoteTarget { host: "127.0.0.1".into(), port, - key: "k".into(), + key: key(), }; + let (_, hello) = open(&r).await.unwrap(); + assert_eq!(hello.core_version, "9.9.9"); + } + + /// 版本不一致:说出服务器 core 自己报的版本和这一版应用要的版本 + #[test] + fn a_version_mismatch_names_both_versions() { + let e = link_error( + tw_link::LinkError::VersionMismatch { + ours: 18, + theirs: 17, + peer_version: "0.47.2".into(), + }, + "h:1", + ); + assert_eq!( + e, + ConnectError::VersionMismatch { + ours: REQUIRED_CORE.to_string(), + theirs: "0.47.2".into(), + } + ); assert_eq!( - test(&Target::Remote(r)).await.unwrap_err(), - ConnectError::NotYetAvailable + link_error(tw_link::LinkError::Timeout, "h:1"), + ConnectError::Timeout { addr: "h:1".into() } ); } } diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index b779f56a..b81621be 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -4,9 +4,10 @@ //! 系统给的;Windows 上没有对等物,控制面落在回环端口上,而本机任意进程都 //! 连得上一个回环端口。 //! -//! **凭据两边都带。**回环那一侧它是唯一的门,而只在一个平台上生效的防线没 -//! 人日常测 —— 两条路走同一份代码,才不会有一半从来没被跑过。凭据怎么来的 -//! 见 `crate::token`。 +//! **每条连接都先握手**(`tw-link`,Noise NNpsk0),钥匙是 config.yaml 里的 +//! `listen.control.key`。回环那一侧它是唯一的门,而只在一个平台上生效的防线没 +//! 人日常测 —— socket、回环、远程端口走同一份代码,才不会有一半从来没被跑过。 +//! **这一侧只读钥匙,不写**:由 core 生成、补上。 //! //! **端点只有一个入口:[`ControlClient::call`]。**方法、路径、请求和响应的类型 //! 都来自 `tw_api::ep` 里那一行声明,这里不再为每个端点各写一个方法 —— 以前 @@ -41,6 +42,110 @@ fn read_port(f: &Path) -> Result { Ok(text.trim().parse::()?) } +fn not_running(e: &dyn std::fmt::Display) -> anyhow::Error { + tracing::debug!("控制面连不上:{e}"); + anyhow::anyhow!(tr!( + "core 未在运行,或尚未启动完成", + "core is not running, or has not finished starting" + )) +} + +/// 应用自己的版本,握手时报给 core(只进它的日志) +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 本机那份配置里的钥匙。 +/// +/// **文件不在、还没有这一项,都当作 core 还没好**:core 在控制面开始监听之前才把 +/// 钥匙写进去,刚把它拉起来的那一小段里这两种都是常态,交给上面本来就有的重试。 +/// 写了但不是一把钥匙,是配置坏了:说出来 +fn local_key(config: &Path) -> Result { + use tw_link::KeyReadError; + tw_link::read_key(config).map_err(|e| match e { + KeyReadError::Io { .. } | KeyReadError::Missing => not_running(&e), + other => { + tracing::warn!("本机配置里的控制面钥匙读不出来:{other}"); + anyhow::anyhow!(tr!( + "配置文件中的控制面密钥(listen.control.key)无效", + "The control key in the config file (listen.control.key) is not valid" + )) + } + }) +} + +/// 连上本机的控制面:socket(Windows 上是回环端口),再握手。 +async fn local(at: &tw_api::control::Address, config: &Path) -> Result> { + let key = local_key(config)?; + match handshake(at, &key).await { + // **钥匙不对就再读一次。**读钥匙和握手之间 core 换了钥匙(`--rotate`), + // 手上这一把就是旧的;再读到的还是同一把,才是真的不对 + Err(tw_link::LinkError::WrongKey) => { + let again = local_key(config)?; + if again == key { + return Err(link_failed(tw_link::LinkError::WrongKey)); + } + handshake(at, &again).await.map_err(link_failed) + } + r => r.map_err(link_failed), + } +} + +async fn handshake( + at: &tw_api::control::Address, + key: &tw_link::ControlKey, +) -> std::result::Result, tw_link::LinkError> { + use tw_api::control::Address; + let raw: Box = match at { + #[cfg(unix)] + Address::Socket(path) => Box::new( + tokio::net::UnixStream::connect(path) + .await + .map_err(tw_link::LinkError::unreachable)?, + ), + #[cfg(not(unix))] + Address::Socket(_) => { + return Err(tw_link::LinkError::unreachable(std::io::Error::other( + "这个平台上没有 unix socket", + ))); + } + Address::Loopback { port_file } => { + let port = read_port(port_file) + .map_err(|e| tw_link::LinkError::unreachable(std::io::Error::other(e)))?; + Box::new( + tokio::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port)) + .await + .map_err(tw_link::LinkError::unreachable)?, + ) + } + }; + let (secure, _) = tw_link::connect(raw, key, APP_VERSION).await?; + Ok(Box::new(secure)) +} + +/// 本机握手失败说成一句话。连不上、被关、超时都是「core 不在」;钥匙不对和版本 +/// 不一致各说各的 —— 等多久都不会自己好 +fn link_failed(e: tw_link::LinkError) -> anyhow::Error { + use tw_link::LinkError; + match e { + LinkError::WrongKey => anyhow::anyhow!(tr!( + "控制面密钥不匹配:core 拒绝了配置文件中的密钥", + "The control key does not match: core rejected the key in the config file" + )), + LinkError::VersionMismatch { + ours, + theirs, + peer_version, + } => anyhow::anyhow!(tr!( + format!( + "控制面协议版本不一致:core {peer_version} 为 {theirs},界面为 {ours}。请重新构建。" + ), + format!( + "Control plane protocol versions differ: core {peer_version} uses {theirs}, the interface uses {ours}. Rebuild the app." + ) + )), + other => not_running(&other), + } +} + /// 控制面拒绝了这个请求:非 2xx,响应体是一条 [`tw_api::ErrorBody`]。 /// /// **码原样留着**,一路带到界面,界面按码翻(见 `crate::error`)。在这里就 @@ -71,11 +176,12 @@ fn refused(status: hyper::StatusCode, body: &[u8]) -> anyhow::Error { /// 控制面在哪、凭什么进去。 #[derive(Clone)] pub enum Target { - /// 本机的 core:数据目录里的 socket(Windows 上是回环端口),凭据是这一次启动生成的 + /// 本机的 core:数据目录里的 socket(Windows 上是回环端口)。钥匙每次连接时从 + /// `config` 现读 —— 换过钥匙(`twcore control-key --rotate`),下一条连接就用新的 Local { at: tw_api::control::Address, - /// 这一次启动的凭据,见 `crate::token`。 - token: String, + /// 本机那份 config.yaml + config: std::path::PathBuf, }, /// 另一台机器上的 core,见 `crate::connection` Remote(crate::connection::connector::RemoteTarget), @@ -95,8 +201,8 @@ pub struct ControlClient { } impl ControlClient { - pub fn new(at: tw_api::control::Address, token: String) -> Self { - Self::to(Target::Local { at, token }) + pub fn new(at: tw_api::control::Address, config: std::path::PathBuf) -> Self { + Self::to(Target::Local { at, config }) } pub fn to(target: Target) -> Self { @@ -126,55 +232,19 @@ impl ControlClient { self.target.read().expect("锁未中毒").clone() } - /// 连上控制面。 + /// 连上控制面:建连,再握手。 /// /// **连不上时只说 core 现在不在。**socket 的路径和「No such file or /// directory (os error 2)」是给写代码的人看的,而几乎每个命令在 core 不在 /// 的时候回给界面的都是这一句。原话进日志 async fn connect(&self) -> Result> { - use tw_api::control::Address; - let not_running = |e: &dyn std::fmt::Display| { - tracing::debug!("控制面连不上:{e}"); - anyhow::anyhow!(tr!( - "core 未在运行,或尚未启动完成", - "core is not running, or has not finished starting" - )) - }; - 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) - .await - .map_err(|e| not_running(&e))?, - )), - #[cfg(not(unix))] - Address::Socket(_) => Err(not_running(&"这个平台上没有 unix socket")), - Address::Loopback { port_file } => { - let port = read_port(port_file).map_err(|e| not_running(&e))?; - Ok(Box::new( - tokio::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port)) - .await - .map_err(|e| not_running(&e))?, - )) - } - } - } - - /// 每个请求都带上的那个头。**远程连接不带**:那边进门靠的是握手,不是这个头 - fn auth(&self) -> String { match self.target() { - Target::Local { token, .. } => format!("Bearer {token}"), - Target::Remote(_) => String::new(), + Target::Local { at, config } => local(&at, &config).await, + // 远程那一档的建连和握手见 `connection::connector` + Target::Remote(r) => crate::connection::connector::open(&r) + .await + .map(|(stream, _)| stream) + .map_err(anyhow::Error::new), } } @@ -245,11 +315,7 @@ impl ControlClient { .method(method.as_str()) .uri(path) // unix socket 上没有真正的 host,但 HTTP/1.1 要求这个头存在 - .header(hyper::header::HOST, "localhost") - // **读写都带凭据。**写请求的那一处曾经漏了:读的请求都带着,于是 - // 界面一切正常地显示,而每一次保存、新建、接管都被控制面拒掉 —— - // 两个平台都是。见 tests/control_plane.rs 里走写请求的那一条 - .header(hyper::header::AUTHORIZATION, self.auth()); + .header(hyper::header::HOST, "localhost"); if body.is_some() { req = req.header(hyper::header::CONTENT_TYPE, "application/json"); } @@ -350,7 +416,6 @@ impl ControlClient { let req = hyper::Request::builder() .uri(ep::Events::PATH) .header(hyper::header::HOST, "localhost") - .header(hyper::header::AUTHORIZATION, self.auth()) .header(hyper::header::ACCEPT, "text/event-stream") .body(String::new())?; let mut resp = sender.send_request(req).await?; @@ -455,14 +520,48 @@ mod tests { assert_eq!(ops.len(), 6); } + /// 一份写着钥匙的配置。**钥匙是在的**:下面几条要测的是建连那一步,不是读钥匙 + fn key_file(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tw-key-{}-{name}.yaml", std::process::id())); + std::fs::write( + &p, + format!("listen:\n control:\n key: \"{}\"\n", "ab".repeat(32)), + ) + .unwrap(); + p + } + /// 一个一定连不上的客户端:这个平台默认的那种传输,指向一个不存在的地方。 fn unreachable() -> ControlClient { ControlClient::new( tw_api::control::Address::in_dir(Path::new("/tmp/tw-definitely-not-there-xyz")), - "t".into(), + key_file("unreachable"), ) } + /// 配置还没写出来(或者还没写进钥匙):**和 socket 不在同一句话** —— core 起来时 + /// 才补上钥匙,那一小段里这是常态 + #[tokio::test] + async fn no_key_yet_also_says_core_is_not_there() { + let missing = ControlClient::new( + tw_api::control::Address::in_dir(Path::new("/tmp/tw-definitely-not-there-xyz")), + PathBuf::from("/tmp/tw-no-such-config-xyz.yaml"), + ); + let msg = format!("{:#}", missing.status().await.unwrap_err()); + assert!(msg.contains("core"), "{msg}"); + assert!(!msg.contains("tw-no-such-config"), "{msg}"); + + let p = std::env::temp_dir().join(format!("tw-key-{}-nokey.yaml", std::process::id())); + std::fs::write(&p, "listen:\n gateway:\n port: 1\n").unwrap(); + let no_key = ControlClient::new( + tw_api::control::Address::in_dir(Path::new("/tmp/tw-definitely-not-there-xyz")), + p.clone(), + ); + let msg = format!("{:#}", no_key.status().await.unwrap_err()); + assert!(msg.contains("core"), "{msg}"); + let _ = std::fs::remove_file(p); + } + /// 连不上时说 core 不在,**不带地址和系统原话**:几乎每个命令在 /// core 不在的时候回给界面的都是这一句 #[tokio::test] @@ -485,7 +584,7 @@ mod tests { tw_api::control::Address::Loopback { port_file: PathBuf::from("/tmp/tw-no-such-port-file-xyz"), }, - "t".into(), + key_file("noport"), ); let msg = format!("{:#}", c.status().await.unwrap_err()); assert!(msg.contains("core"), "{msg}"); @@ -502,7 +601,7 @@ mod tests { tw_api::control::Address::Loopback { port_file: d.clone(), }, - "t".into(), + key_file("halfport"), ); // 12 是个能解析的端口号,但没人听 —— 连接失败,同一句话 let msg = format!("{:#}", c.status().await.unwrap_err()); diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 3431e4c3..f72205f9 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -69,7 +69,8 @@ mod tests { /// 诊断包从建出来那一刻就只有属主能读;同一秒再写一次也照样是 0600、内容是新的 #[test] fn the_diagnostics_file_is_private_from_the_start_and_can_be_rewritten() { - let dir = std::env::temp_dir().join(format!("tw-diag-{}", crate::token::generate())); + let dir = + std::env::temp_dir().join(format!("tw-diag-{}", crate::connection::store::new_id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("diagnostics-1.md"); write_private(&path, b"first").unwrap(); diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 427b203a..a641dc81 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -273,12 +273,12 @@ pub(crate) fn describe_state(s: &CoreState) -> String { /// 心跳循环。 pub(crate) async fn heartbeat_loop( at: tw_api::control::Address, - token: String, + key_file: std::path::PathBuf, sup: Arc, app: tauri::AppHandle, ) { use supervisor::{HealthTracker, Verdict, health}; - let client = ControlClient::new(at, token); + let client = ControlClient::new(at, key_file); let mut tracker = HealthTracker::new(); loop { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6689e5c5..129d1c01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,7 +47,6 @@ pub mod scan; pub mod settings; pub mod supervisor; pub mod theme; -mod token; pub mod uninstall; pub mod update; pub mod updater; @@ -239,16 +238,16 @@ pub fn run() { // **找不到 core 也要把窗口开起来。**这里原来是 `?` —— // 而它把「找不到一个文件」变成了「应用打不开」。 let located = locate_core(&handle); - // 控制面听在哪由平台决定,凭据这一次启动生成一个。**两样都只在 - // 这里定一次**,守护拿它去 spawn core,客户端拿它去连。 + // 控制面听在哪由平台决定,钥匙在 config.yaml 里(core 生成,这一侧 + // 只读)。**两样都只在这里定一次**,守护和客户端用的是同一对 let at = control_address(); - let token = token::generate(); + let key_file = tw_api::control::config_file(&data_dir()); // 起好了没有,问控制面:`/status` 答得上来才算。半秒答不上这一次就 // 算没答应,守护隔一会儿再问 let ready = { - let (at, token) = (at.clone(), token.clone()); + let (at, key_file) = (at.clone(), key_file.clone()); supervisor::probe(move || { - let control = ControlClient::new(at.clone(), token.clone()); + let control = ControlClient::new(at.clone(), key_file.clone()); async move { control .ping(std::time::Duration::from_millis(500)) @@ -266,7 +265,7 @@ pub fn run() { None, ready, at.clone(), - token.clone(), + key_file.clone(), ) .with_user_env(), ); @@ -320,7 +319,7 @@ pub fn run() { let start_id = conns.startup_target(); let local = control::Target::Local { at: at.clone(), - token: token.clone(), + config: key_file.clone(), }; let link = Arc::new(connection::Link::new( handle.clone(), @@ -396,9 +395,9 @@ pub fn run() { // —— 一个死锁的进程既不退出也不关 socket,前两条信号都看 // 不见它,而它对用户的表现和挂了一模一样。 let h = handle.clone(); - let (at2, tok2) = (at.clone(), token.clone()); + let (at2, key2) = (at.clone(), key_file.clone()); tauri::async_runtime::spawn(async move { - heartbeat_loop(at2, tok2, sup, h).await; + heartbeat_loop(at2, key2, sup, h).await; }); // 静默启动:开机拉起来的时候屏幕上什么都不该出现, diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index f1b5db3e..2b6a07fe 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -109,8 +109,6 @@ pub struct Supervisor { binary: PathBuf, config: Option, ready: Probe, - /// 控制面的凭据,spawn 时通过环境变量交给 core。见 `crate::token`。 - token: String, /// 请 core 退出要用到它。**Windows 上没有 SIGTERM**,那条路只能走控制面。 control: crate::control::ControlClient, policy: Mutex, @@ -144,14 +142,14 @@ impl Supervisor { config: Option, ready: Probe, at: tw_api::control::Address, - token: String, + // 控制面的钥匙在哪份配置里。**这一侧只读**:core 起来时生成、补上 + key_file: PathBuf, ) -> Self { Self { binary, config, ready, - control: crate::control::ControlClient::new(at, token.clone()), - token, + control: crate::control::ControlClient::new(at, key_file), policy: Mutex::new(RestartPolicy::new()), state: watch::channel(CoreState::Stopped).0, intentional: Arc::new(AtomicBool::new(false)), @@ -394,14 +392,11 @@ impl Supervisor { let mut cmd = tokio::process::Command::new(&self.binary); if self.user_env { - // 先补这些,下面的凭据再覆盖 —— 虽然 `TW_*` 本来就不会被带过来 cmd.envs(user_env::load().await); } + // **不交凭据。**控制面的钥匙在 config.yaml 里,由 core 自己生成、补上; + // 这一侧连接时从同一个文件读(见 `crate::control`) cmd.args(&args) - // 控制面的凭据**走环境变量交过去,不进 argv** —— Windows 上任意 - // 同用户进程都看得见别人的命令行,而这串东西是那个平台上控制面 - // 唯一的门。见 `crate::token`。 - .env(tw_api::control::TOKEN_ENV, &self.token) // core 的日志走它自己的 stderr;UI 侧只需要知道它活着。 .kill_on_drop(true); // **不给它开控制台窗口。**twcore 是个命令行程序,Windows 上起一个控制台 @@ -539,7 +534,7 @@ mod tests { Some(PathBuf::from("/tmp/c.yaml")), always_ready(), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), ) } @@ -607,7 +602,7 @@ mod tests { None, always_ready(), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), ); assert!(!s.command_args(false).contains(&"--config".to_string())); } @@ -688,7 +683,7 @@ mod tests { None, always_ready(), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), )); let looped = { let s = s.clone(); @@ -739,7 +734,7 @@ mod tests { None, third_time, test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), )); let mut rx = s.watch(); let looped = { @@ -773,7 +768,7 @@ mod tests { None, probe(|| async { false }), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), ); let next = s.run_once(false).await.unwrap(); assert_eq!(next, Next::Again); @@ -795,7 +790,7 @@ mod tests { None, always_ready(), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), )); let first = { let s = s.clone(); @@ -827,7 +822,7 @@ mod tests { None, always_ready(), test_address(), - "t".into(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), )); let looped = { let s = s.clone(); diff --git a/src-tauri/src/supervisor/user_env.rs b/src-tauri/src/supervisor/user_env.rs index 7450a827..ae445b08 100644 --- a/src-tauri/src/supervisor/user_env.rs +++ b/src-tauri/src/supervisor/user_env.rs @@ -31,7 +31,7 @@ pub async fn load() -> Vec<(String, String)> { /// core 行为、而不只是给 `${…}` 取值的那些一律不带,保持应用自己的: /// /// - 自己人:`THINKWATCH_HOME` 决定数据目录,从 shell 带一个不同的过去,core 和 -/// 界面就各看各的数据了;`TW_CONTROL_TOKEN` 是控制面的凭据。 +/// 界面就各看各的数据了;`TW_*` 是 core 自己的开关。 /// - 代理:「系统代理」这一档会读 `HTTPS_PROXY` 这些。终端里为了别的工具设的 /// 代理,不该悄悄改掉网关的出站路径。 /// - `PATH`:Windows 上系统和用户各有一份、要拼起来才对,单拿一份会更糟。 @@ -312,7 +312,7 @@ mod tests { "HTTPS_PROXY", "no_proxy", "THINKWATCH_HOME", - "TW_CONTROL_TOKEN", + "TW_LOG", "RUST_LOG", "DYLD_INSERT_LIBRARIES", "SHLVL", diff --git a/src-tauri/src/token.rs b/src-tauri/src/token.rs deleted file mode 100644 index 4c8dcc6d..00000000 --- a/src-tauri/src/token.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! 控制面的凭据,这一次启动用的那一个。 -//! -//! # 为什么由这一侧生成 -//! -//! 桌面端**自己 spawn core,从不接管已经在跑的**。所以「这一次的凭据是什么」 -//! 由这边说了算,core 从环境变量里接([`tw_api::control::TOKEN_ENV`])。 -//! -//! **走环境变量,不走 argv** —— Windows 上任意同用户进程都看得见别人的命令行。 -//! **也不落盘**:core 手工启动时才需要那个文件,而这条路上没有人要读它。 -//! -//! # 为什么每次启动换一个 -//! -//! 它只需要活到这一对进程结束。留着不会更安全,只会多一份要保管的东西。 -//! -//! # 为什么需要它 -//! -//! macOS 上控制面是一个 `0700` 的 unix socket,权限是文件系统给的。Windows -//! 上没有对等物,那里控制面落在回环端口上,而本机任意进程都连得上一个回环 -//! 端口、连上之后也问不出对端是谁。**那个平台上这个 token 是唯一的门。** -//! 两边走同一条路,因为只在一个平台上生效的防线没人日常测。 - -/// 生成这一次启动用的凭据:32 字节随机数,写成十六进制。 -pub fn generate() -> String { - let mut bytes = [0u8; 32]; - rand::fill(&mut bytes); - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - use std::fmt::Write; - let _ = write!(out, "{b:02x}"); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_is_64_hex_characters_and_never_the_same_twice() { - let a = generate(); - let b = generate(); - assert_eq!(a.len(), 64); - assert!(a.chars().all(|c| c.is_ascii_hexdigit())); - assert_ne!(a, b, "每次启动该换一个"); - } -} diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index 6d7dc2d6..6a28ab57 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -3,8 +3,8 @@ //! # 为什么这条不能只靠单元测试 //! //! 单元测试能证明「连不上时说的是哪句话」,证明不了**连得上**。而这一侧新加的 -//! 那几样 —— 凭据从哪儿来、怎么交给 core、每个请求带不带得对、控制面的地址由 -//! 谁说了算 —— 只有在两个真进程之间才成立或者不成立。 +//! 那几样 —— 钥匙从哪儿读、每条连接的握手、控制面的地址由谁说了算、换钥匙之后 +//! 接不接得上 —— 只有在两个真进程之间才成立或者不成立。 //! //! 它接的是包里那一份 `resources/twcore`,也就是**发出去的包里装的那一个**。 //! 换句话说这条测试同时在问:桌面端钉的这一版 core,和桌面端自己编进去的那份 @@ -40,7 +40,6 @@ fn core_binary() -> PathBuf { struct Core { child: Child, home: PathBuf, - token: String, } /// 同一个测试二进制里的第几个 core。 @@ -70,7 +69,6 @@ impl Core { let home = std::env::temp_dir().join(format!("tw-ctl-{}-{nth}", std::process::id())); let _ = std::fs::remove_dir_all(&home); std::fs::create_dir_all(&home).unwrap(); - let token = "test-token-not-a-real-one".to_string(); let child = Command::new(core_binary()) .args(["serve", "--config"]) .arg(home.join("config.yaml")) @@ -78,26 +76,45 @@ impl Core { // core、另一个检出里同时在跑的这条测试、这台机器上别的什么 .args(["--port", &free_port().to_string()]) .env("THINKWATCH_HOME", &home) - .env(tw_api::control::TOKEN_ENV, &token) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .expect("起不来 twcore"); - Self { child, home, token } + Self { child, home } + } + + /// core 的配置。**钥匙由 core 写进去**,桌面端从这里读 + fn config(&self) -> PathBuf { + self.home.join("config.yaml") + } + + /// 一份写着另一把钥匙的配置:拿它去连,就是「钥匙不对」 + fn wrong_key(&self) -> PathBuf { + let p = self.home.join("wrong.yaml"); + std::fs::write( + &p, + format!("listen:\n control:\n key: \"{}\"\n", "0f".repeat(32)), + ) + .unwrap(); + p } fn address(&self) -> Address { Address::in_dir(&self.home) } - fn client(&self, token: &str) -> ControlClient { - ControlClient::new(self.address(), token.to_string()) + fn client(&self, key_file: PathBuf) -> ControlClient { + ControlClient::new(self.address(), key_file) + } + + fn ok(&self) -> ControlClient { + self.client(self.config()) } /// 等到它答得上话。**等的是 `/status` 有应答,不是 socket 文件出现** —— /// 后者在它还没把路由挂上去的时候就已经在了。 async fn wait_ready(&self) { - let c = self.client(&self.token); + let c = self.ok(); let deadline = Instant::now() + Duration::from_secs(30); loop { if c.ping(Duration::from_millis(500)).await.is_ok() { @@ -117,18 +134,17 @@ impl Drop for Core { } } -/// 带着对的凭据连得上,带着错的连不上。 +/// 拿着配置里的钥匙连得上,拿着别的钥匙连不上。 /// -/// 后半条是这里真正要钉住的:**漏带凭据的样子是一切照常** —— 在 macOS 上 -/// socket 的权限本来就挡住了别人,于是「忘了加那个头」要到 Windows 上才发作, -/// 而那时表现为界面连不上一个正在跑的网关。 +/// 后半条是这里真正要钉住的:**握手漏掉的样子是一切照常** —— 在 macOS 上 socket +/// 的权限本来就挡住了别人,于是门没关上要到 Windows 上才发作。 #[tokio::test] -async fn the_desktop_side_gets_in_with_its_token_and_not_without() { +async fn the_desktop_side_gets_in_with_the_key_and_not_without() { let core = Core::start(); core.wait_ready().await; - let ok = core.client(&core.token).status().await; - assert!(ok.is_ok(), "带着对的凭据反而连不上:{:?}", ok.err()); + let ok = core.ok().status().await; + assert!(ok.is_ok(), "拿着对的钥匙反而连不上:{:?}", ok.err()); let status = ok.unwrap(); assert_eq!( status.api_version, @@ -136,40 +152,38 @@ async fn the_desktop_side_gets_in_with_its_token_and_not_without() { "桌面端编进去的协议版本和它钉的那版 core 对不上" ); - let wrong = core.client("not-the-token").status().await; - assert!(wrong.is_err(), "凭据不对居然进去了"); + let wrong = core.client(core.wrong_key()).status().await; + let e = format!("{:#}", wrong.expect_err("钥匙不对居然进去了")); + assert!(e.contains("密钥") || e.contains("key"), "{e}"); } -/// 写请求也要带凭据。 +/// 写请求也走同一条握手。 /// -/// **读和写是两条代码路径**(`get` 和 `send_json`),2026.9.10 就是只有读的 -/// 那条带了:界面照常显示数据,而保存、新建、接管全部被拒。这里用 -/// `/shutdown` 当那个写请求 —— 它走的正是 `send_json`,又不会改任何配置。 +/// **读和写是两条代码路径**,2026.9.10 就是只有读的那条带了凭据:界面照常显示 +/// 数据,而保存、新建、接管全部被拒。这里用 `/shutdown` 当那个写请求,它不会改 +/// 任何配置。 #[tokio::test] -async fn writes_carry_the_token_too() { +async fn writes_go_through_the_handshake_too() { let core = Core::start(); core.wait_ready().await; - let wrong = core.client("not-the-token").shutdown().await; - assert!(wrong.is_err(), "凭据不对的写请求居然进去了"); + let wrong = core.client(core.wrong_key()).shutdown().await; + assert!(wrong.is_err(), "钥匙不对的写请求居然进去了"); - let ok = core.client(&core.token).shutdown().await; - assert!(ok.is_ok(), "带着对的凭据写请求被拒:{:?}", ok.err()); + let ok = core.ok().shutdown().await; + assert!(ok.is_ok(), "拿着对的钥匙写请求被拒:{:?}", ok.err()); } -/// 事件流也要带凭据。 -/// -/// **它和别的请求不是同一条代码路径**(长连接那条自己拼请求),所以漏掉那个头 -/// 的话,界面上的表现是「什么都能点,但一切都不自己更新」—— 一种很难往凭据上 -/// 联想的坏法。 +/// 事件流也走握手。**它和别的请求不是同一条代码路径**(长连接那条自己拼请求), +/// 坏了的样子是「什么都能点,但一切都不自己更新」 #[tokio::test] -async fn the_event_stream_carries_the_token_too() { +async fn the_event_stream_goes_through_the_handshake_too() { let core = Core::start(); core.wait_ready().await; let opened = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let o = opened.clone(); - let c = core.client(&core.token); + let c = core.ok(); // 流是长连接,不会自己结束 —— 给它一点时间把头发出去、把响应收回来 let _ = tokio::time::timeout( Duration::from_secs(10), @@ -181,14 +195,52 @@ async fn the_event_stream_carries_the_token_too() { .await; assert!( opened.load(std::sync::atomic::Ordering::SeqCst), - "事件流没开起来 —— 多半是那个请求没带凭据" + "事件流没开起来" ); } +/// 换钥匙(`twcore control-key --rotate`)之后,**下一条连接就用新的**:桌面端每次 +/// 连接都现读配置,不用重启。用旧钥匙开着的事件流被 core 关掉,重连时读到的是新的 +#[tokio::test] +async fn after_the_key_is_rotated_the_next_connection_gets_in() { + let core = Core::start(); + core.wait_ready().await; + let before = tw_link::read_key(&core.config()).unwrap(); + + let rotated = Command::new(core_binary()) + .args(["control-key", "--rotate", "--config"]) + .arg(core.config()) + .env("THINKWATCH_HOME", &core.home) + .output() + .expect("twcore control-key 跑不起来"); + assert!(rotated.status.success(), "{rotated:?}"); + let after = tw_link::read_key(&core.config()).unwrap(); + assert!(after != before, "钥匙没换"); + + // core 一秒之内换上新钥匙 + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match core.ok().status().await { + Ok(_) => break, + Err(e) => assert!(Instant::now() < deadline, "换钥匙之后一直连不上:{e:#}"), + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + // 旧钥匙进不去了 + let old = core.home.join("old.yaml"); + std::fs::write( + &old, + format!("listen:\n control:\n key: \"{}\"\n", before.to_hex()), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while core.client(old.clone()).status().await.is_ok() { + assert!(Instant::now() < deadline, "旧钥匙还进得去"); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + /// 控制面拒绝时,**带着 core 的码回来**,不是一段要界面再解析一遍的文本。 -/// -/// 401 用的是 core 最外层替框架回的那条(没带凭据),所以这里顺带钉住了 -/// 「框架回的失败也是 `ErrorBody`」。 #[tokio::test] async fn a_refusal_comes_back_with_its_code() { use thinkwatch_lite_lib::control::Refused; @@ -196,14 +248,8 @@ async fn a_refusal_comes_back_with_its_code() { let core = Core::start(); core.wait_ready().await; - let e = core.client("not-the-token").status().await.unwrap_err(); - let Some(Refused(m)) = e.downcast_ref::() else { - panic!("401 没按 ErrorBody 读出来:{e:#}"); - }; - assert!(!m.code.is_empty(), "{m:?}"); - let e = core - .client(&core.token) + .ok() .call::(&["987654321"], &()) .await .unwrap_err(); @@ -221,7 +267,7 @@ async fn adoption_gets_its_gateway_and_its_key_from_core() { let core = Core::start(); core.wait_ready().await; - let c = core.client(&core.token); + let c = core.ok(); let base = thinkwatch_lite_lib::clients::gateway_base(&c, "127.0.0.1") .await @@ -255,7 +301,7 @@ async fn the_generic_call_speaks_every_shape() { let core = Core::start(); core.wait_ready().await; - let c = core.client(&core.token); + let c = core.ok(); // GET + 查询串 let rows = c diff --git a/src/App.tsx b/src/App.tsx index 71ba5fdc..a269e9d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -470,9 +470,7 @@ function Shell({ first }: { first: boolean }) { }, []); const lasting = link?.kind === "down" && - (link.error.kind === "wrong_key" || - link.error.kind === "version_mismatch" || - link.error.kind === "not_yet_available"); + (link.error.kind === "wrong_key" || link.error.kind === "version_mismatch"); /** 概览那一页的第一份数据到了。启动画面等它,交接时数字已经是对的 */ const [landed, setLanded] = useState(false); /** 连上之后首屏迟迟取不齐:不再等,交给那一页自己的骨架 */ diff --git a/src/connection/api.ts b/src/connection/api.ts index 99f35f7c..2eaa9c1b 100644 --- a/src/connection/api.ts +++ b/src/connection/api.ts @@ -34,8 +34,7 @@ export type ConnectError = | { kind: "closed"; addr: string } | { kind: "wrong_key" } | { kind: "version_mismatch"; ours: string; theirs: string } - | { kind: "timeout"; addr: string } - | { kind: "not_yet_available" }; + | { kind: "timeout"; addr: string }; export type LinkState = /** 连的是本机:状态看 `core-state` */ diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx index 583988ad..a1da17d1 100644 --- a/src/connection/connection.i18n.tsx +++ b/src/connection/connection.i18n.tsx @@ -98,8 +98,6 @@ export const connText = messages( wrongKeyNext: (code: Code) => <>在服务器上执行 {code("twcore control-key")} 查看当前密钥。, mismatch: (theirs: string, ours: string) => `版本不一致:服务器 core ${theirs},本应用需要 ${ours}`, mismatchNext: "在服务器上升级 core 后再连接。", - unsupported: "此版本尚不支持连接远程 core", - unsupportedNext: "服务器可以访问。更新应用后即可连接。", reasonTimeout: "连接超时", reasonUnreachable: "无法访问该地址", @@ -237,8 +235,6 @@ export const connText = messages( 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", diff --git a/src/connection/describe.test.tsx b/src/connection/describe.test.tsx index ae8746db..a32f5b4a 100644 --- a/src/connection/describe.test.tsx +++ b/src/connection/describe.test.tsx @@ -9,11 +9,10 @@ const ALL: ConnectError[] = [ { 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); diff --git a/src/connection/describe.tsx b/src/connection/describe.tsx index 513196c9..76ae7a33 100644 --- a/src/connection/describe.tsx +++ b/src/connection/describe.tsx @@ -27,8 +27,6 @@ export function describeError(e: ConnectError): { title: string; next: ReactNode 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 }; } } @@ -46,8 +44,6 @@ export function shortReason(e: ConnectError): string { return t.wrongKey; case "version_mismatch": return t.mismatch(e.theirs, e.ours); - case "not_yet_available": - return t.unsupported; } } diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 1be3d18a..435d4c64 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 17; +export const CONTROL_API_VERSION = 18; /** * 改一条内置规则在拦截档下做什么。只有工具调用审查和内容过滤的规则有这一项 —— diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 382e93e4..4a4506c7 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -419,7 +419,7 @@ "control.zai_login.key_without_secret": "返回的 API 密钥缺少密钥值。", "control.provider_test.oauth_unsaved": "OAuth 凭据需要先保存才能检查。如需现在检查,请同时填写访问令牌。", "control.rotation.written": "已写回 {path}(版本 {version})。", - "control.unauthorized": "控制面需要启动时生成的令牌。桌面版会自动携带;自行编写的客户端需从配置目录中的 control.token 读取。", + "control.control_key_locked": "listen.control.key 不能在这里修改。请在 core 所在的机器上执行 twcore control-key --rotate,或直接编辑那里的配置文件。", "control.internal_error": "网关内部出错:{detail}", "control.records_unreadable": "无法读取请求记录:{detail}", "control.listen.bind_invalid": "bind 只能是 loopback、all、网卡名(如 en0)或地址(如 192.168.1.5),当前为 {bind}。", @@ -471,6 +471,8 @@ "config.rejected": "配置有{stage:stage}错误:{detail}", "config.rejected_at": "配置第 {line} 行有{stage:stage}错误:{detail}", "config.schema_too_new": "配置的格式版本为 {found},当前 twcore 最高支持 {supported}。请升级应用,或将配置改回旧格式。", + "config.control_key_missing": "配置中没有 listen.control.key,桌面应用凭它连接 core。twcore serve 启动时会自动写入;twcore control-key --rotate 会写入一把新的。", + "config.control_key_invalid": "listen.control.key 应为 64 位十六进制字符。twcore control-key --rotate 会写入一把新的。", "config.no_clients": "配置的 clients 中没有网关密钥,所有请求都会被拒绝。首次启动时会自动生成一把。", "config.duplicate_upstream": "上游名称「{upstream}」重复。路由规则按名称引用上游,名称必须唯一。", "config.duplicate_key_name": "网关密钥名称「{key}」重复。", From 34d3b11ab4644bc5cfdbee2c189c9b9f5e01692b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:17:28 +0800 Subject: [PATCH 2/5] Settings stay usable while a remote is disconnected; menu dump for the preview; no footer flash before the list loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while verifying the real app: - The disconnect banner made the whole content read-only, including Settings > Connection, where the fix usually is (replace the key, switch to this Mac). Settings now stays usable; only the sections that edit the server's config go read-only. - The sidebar footer drew "This Mac · Stopped" for a moment before the connection list arrived, even when connected to a remote. - menubar_preview gains --dump (and a remote state) to print the native NSMenu, submenus included, without screen recording. Co-Authored-By: Claude Opus 5.5 --- src-tauri/examples/menubar_preview.rs | 34 ++++++++++++++++++++- src-tauri/src/menubar/macos.rs | 43 +++++++++++++++++++++++++++ src/App.tsx | 7 +++-- src/Config.tsx | 8 +++++ src/connection/Switcher.tsx | 2 ++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src-tauri/examples/menubar_preview.rs b/src-tauri/examples/menubar_preview.rs index 358aa951..fd40ffcf 100644 --- a/src-tauri/examples/menubar_preview.rs +++ b/src-tauri/examples/menubar_preview.rs @@ -8,7 +8,8 @@ //! ``` //! //! 状态:`sub`(订阅账号、有请求在跑)、`alert`(额度用完、有提醒)、`payg`(按量计费、 -//! 有新版本)、`down`(网关无法启动)、`starting`。另可加 `--en`、`--icon`、`--numbers`。 +//! 有新版本)、`down`(网关无法启动)、`starting`、`remote`(连着远程、断线重连中)。 +//! 另可加 `--en`、`--icon`、`--numbers`,以及 `--dump`(打出原生菜单里的项就退出)。 #[cfg(target_os = "macos")] fn main() { @@ -82,6 +83,13 @@ fn main() { ); let (bar, rows) = model::build(&snapshot(&name), style); macos::apply(mtm, &bar, &rows); + // `--dump`:把原生菜单里实际有的项打出来就退出(看子菜单、勾选、分隔线) + if args.iter().any(|a| a == "--dump") { + for line in macos::describe_menu() { + println!("{line}"); + } + return; + } // 进行中的秒数每秒走:和应用里一样,只在后台线程里现算、投递到主线程 if name == "sub" || name == "alert" { std::thread::spawn(move || { @@ -136,8 +144,32 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot { notices_on: true, undo_at_ms: Some(now - 40 * 60_000), now_ms: now, + connections: vec![ + Connection { + id: "local".into(), + name: "本机".into(), + current: name != "remote", + }, + Connection { + id: "a1".into(), + name: "home-server".into(), + current: name == "remote", + }, + Connection { + id: "b2".into(), + name: "office-nas".into(), + current: false, + }, + ], ..Default::default() }; + if name == "remote" { + return Snapshot { + gateway: Gateway::Unlinked, + remote: Some("home-server".into()), + ..base + }; + } match name { "sub" => Snapshot { today: Some(Today { diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 1eee9e6f..6736ff69 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -937,6 +937,49 @@ pub fn confirm_quit(mtm: MainThreadMarker, in_flight: usize) -> bool { /// Esc 的键码 const KEY_ESCAPE: u16 = 53; +/// 此刻菜单里有什么,一行一项,子菜单缩进、勾选的打 ✓、分隔线写成 `---`。 +/// **给预览程序用**(`menubar_preview -- --dump`):没有屏幕录制权限时,核对原生菜单 +/// 真长什么样只能靠它 +pub fn describe_menu() -> Vec { + fn walk(menu: &NSMenu, depth: usize, out: &mut Vec) { + for i in 0..menu.numberOfItems() { + let Some(item) = menu.itemAtIndex(i) else { + continue; + }; + let pad = " ".repeat(depth); + if item.isSeparatorItem() { + out.push(format!("{pad}---")); + continue; + } + let title = item + .attributedTitle() + .map(|t| t.string().to_string()) + .unwrap_or_else(|| item.title().to_string()); + let check = if item.state() == NSControlStateValueOn { + "✓ " + } else { + "" + }; + let title = if title.is_empty() && item.view().is_some() { + "[自绘]".to_string() + } else { + title + }; + out.push(format!("{pad}{check}{title}")); + if let Some(sub) = item.submenu() { + walk(&sub, depth + 1, out); + } + } + } + UI.with(|ui| { + let mut out = Vec::new(); + if let Some(ui) = ui.borrow().as_ref() { + walk(&ui.menu, 0, &mut out); + } + out + }) +} + /// 一句话的提示框(检查更新之后「已是最新版本」这类) pub fn inform(mtm: MainThreadMarker, title: &str, body: &str) { let app = NSApplication::sharedApplication(mtm); diff --git a/src/App.tsx b/src/App.tsx index a269e9d8..c6a4370d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1351,10 +1351,12 @@ function Shell({ first }: { first: boolean }) { 让人点下去再报错 */}
setNudge((n) => n + 1)} /> ) : ( diff --git a/src/Config.tsx b/src/Config.tsx index b9e9c763..f350bb52 100644 --- a/src/Config.tsx +++ b/src/Config.tsx @@ -37,10 +37,13 @@ import type { RemoteCore } from "@/connection/api"; export default function Config({ ov, status, + coreReadOnly = false, onChanged, }: { ov: Overview | null; status: CoreStatus | null; + /** 连着的远程 core 断了:改它配置的几节只读,应用自己的设置照常能改 */ + coreReadOnly?: boolean; /** 存完监听设置之后叫一声,状态和概览跟着重读 */ onChanged: () => void; }) { @@ -102,6 +105,10 @@ export default function Config({ <> {/* 监听原来在「接入」页上,和密钥同屏。它是配一次就不动的网关设置, 和密钥(要天天拿去填客户端)不是一类东西 */} +
{ov && ( )} +
); diff --git a/src/connection/Switcher.tsx b/src/connection/Switcher.tsx index 5842f859..37a4bf6c 100644 --- a/src/connection/Switcher.tsx +++ b/src/connection/Switcher.tsx @@ -60,6 +60,8 @@ export function Switcher({ : local; const name = p ? profileName(p) : t.local; const Icon = p && !p.local ? IconRemote : IconLocal; + // 列表还没读到:什么都不画。先按本机画的话,连着远程时会闪一下「本机 · 已停止」 + if (!view) return null; return (
From eac7ff845e59244c9eaae045938891987f078075 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:31:06 +0800 Subject: [PATCH 3/5] Follow core's remote control listener (CONTROL_API_VERSION 19) - Pin tw-api and tw-link to core 892e90e (ThinkWatch-Core#187, P4). - Remote gateway address for the sidebar / switch dialog: the host this computer dialled for the control port plus the gateway's port; gateway_reachable only as the fallback when core reports no gateway address. - zh translations for control.remote.shutdown_refused, control.remote.diagnostics_refused, control.remote.control_section_locked, config.remote_port_zero, config.remote_port_is_gateway, config.bad_remote_allow_from; regenerated tw-api.ts; notice fixture gains the two new Status fields. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/connection/connector.rs | 48 +++++++++++++++++++- src-tauri/src/notices/fixtures/snapshot.json | 4 +- src/generated/tw-api.ts | 46 ++++++++++++++++++- src/i18n/core.zh.json | 6 +++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index 8efdbeaf..723713ef 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -149,17 +149,34 @@ pub async fn test(target: &Target) -> Result { .await .map_err(|_| ConnectError::Closed { addr: r.addr() })?; Ok(ServerInfo { + gateway_addr: remote_gateway(&r.host, &s), core_version: if s.version.is_empty() { hello.core_version } else { s.version }, - gateway_addr: s.gateway_addr, }) } } } +/// 客户端连服务器网关用的地址:**这台电脑拨通控制端口的那个主机名**,加上网关的端口。 +/// +/// core 自己报的 `gateway_addr` 是它绑的地址(常常是 `0.0.0.0:8788`),别的机器照抄 +/// 连不上;它也看不见 NAT、端口转发、域名。而拨控制端口的那个主机名刚刚被证明从这里 +/// 连得通。`gateway_reachable`(core 按网卡列的地址)只在网关没报地址时兜底 +fn remote_gateway(host: &str, s: &tw_api::Status) -> Option { + let port = s + .gateway_addr + .as_deref() + .and_then(|a| a.rsplit_once(':')) + .and_then(|(_, p)| p.parse::().ok()); + match port { + Some(port) => Some(display_addr(host.trim_matches(['[', ']']), port)), + None => s.gateway_reachable.first().cloned(), + } +} + /// 打开一条到远程控制面的流:TCP 连上,再握手。控制面客户端每个请求都走它 pub(crate) async fn open(r: &RemoteTarget) -> Result<(Box, Handshake), ConnectError> { let addr = r.addr(); @@ -230,6 +247,35 @@ fn link_error(e: tw_link::LinkError, addr: &str) -> ConnectError { mod tests { use super::*; + fn status(gateway: Option<&str>, reachable: &[&str]) -> tw_api::Status { + serde_json::from_value(serde_json::json!({ + "api_version": tw_api::CONTROL_API_VERSION, "version": "x", "pid": 1, + "gateway_addr": gateway, "config_path": "/x", "clients": 0, "providers": 0, + "uptime_secs": 0, "in_flight": 0, + "remote_control": { "enabled": true, "addr": null, "allow_from": [], "reachable": [] }, + "gateway_reachable": reachable, + })) + .unwrap() + } + + /// 网关绑在 0.0.0.0 上:客户端要连的是拨通控制端口的那个主机名,不是 0.0.0.0 + #[test] + fn the_gateway_address_uses_the_host_that_was_dialled() { + let s = status(Some("0.0.0.0:8788"), &["192.168.1.20:8788"]); + assert_eq!( + remote_gateway("nas.local", &s).as_deref(), + Some("nas.local:8788") + ); + assert_eq!(remote_gateway("::1", &s).as_deref(), Some("[::1]:8788")); + // 安全模式下网关没有地址:用 core 列的那几个兜底,一个都没有就不说 + let none = status(None, &["192.168.1.20:8788"]); + assert_eq!( + remote_gateway("h", &none).as_deref(), + Some("192.168.1.20:8788") + ); + assert_eq!(remote_gateway("h", &status(None, &[])), None); + } + #[test] fn ipv6_addresses_get_brackets() { assert_eq!(display_addr("192.168.1.20", 8789), "192.168.1.20:8789"); diff --git a/src-tauri/src/notices/fixtures/snapshot.json b/src-tauri/src/notices/fixtures/snapshot.json index ddf11af8..883caa4a 100644 --- a/src-tauri/src/notices/fixtures/snapshot.json +++ b/src-tauri/src/notices/fixtures/snapshot.json @@ -21,7 +21,9 @@ "clients": 1, "providers": 1, "uptime_secs": 19, - "in_flight": 0 + "in_flight": 0, + "remote_control": { "enabled": false, "addr": null, "allow_from": [], "reachable": [] }, + "gateway_reachable": [] }, "overview": { "config_version": "blake3:c71fd577406c", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 435d4c64..097ad3ae 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 18; +export const CONTROL_API_VERSION = 19; /** * 改一条内置规则在拦截档下做什么。只有工具调用审查和内容过滤的规则有这一项 —— @@ -2274,6 +2274,32 @@ resets_at_ms?: number | null, status?: string | null, }; */ export type ReferenceView = { "kind": "rule_target", route: string, rule: string, } | { "kind": "rule_condition", route: string, rule: string, } | { "kind": "group", group: string, }; +/** + * 远程控制端口(`listen.control.remote`)。 + */ +export type RemoteControlView = { +/** + * 配置里开着吗 + */ +enabled: boolean, +/** + * **此刻真的在听的地址**。开着却是空的,原因在 `error`;换端口没换成时 + * 这里仍是旧的那个 + */ +addr: string | null, +/** + * 配置里的地址没能听上的原因(端口被占、网卡没有地址) + */ +error?: Msg | null, +/** + * 放行哪些来源。本机永远放行 + */ +allow_from: Array, +/** + * 别的机器连这个端口用的地址(`地址:端口`),规则同 `Status.gateway_reachable` + */ +reachable: Array, }; + /** * 原来那一次长什么样,用来并排比。 */ @@ -3070,7 +3096,23 @@ config_rejected?: ConfigRejection | null, config_path: string, clients: number, * * 重启网关之前要看它 —— 重启会掐断所有还没结束的流。 */ -in_flight: number, }; +in_flight: number, +/** + * 远程控制端口此刻的样子。 + */ +remote_control: RemoteControlView, +/** + * 别的机器连网关该用的地址(`地址:端口`),**不含回环**。 + * + * 网关绑在一张网卡上时就是那一个;绑 `all` 时是这台机器每张网卡的地址 + * (每张一个,有 IPv4 用 IPv4);只绑回环时是空的 —— 别的机器根本连不上。 + * + * **core 不知道对方是从哪条路过来的**(NAT、端口转发、域名都看不见)。 + * 桌面端连远程 core 时,优先用它自己连控制面时拨的那个主机加上网关的 + * 端口(`gateway_addr` 里的端口):那个主机名已经被证明从那台 Mac 上 + * 连得通。这里的清单是给它核对和兜底用的。 + */ +gateway_reachable: Array, }; /** * 观测这一层在不在记。 diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 4a4506c7..c904bf1d 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -419,6 +419,9 @@ "control.zai_login.key_without_secret": "返回的 API 密钥缺少密钥值。", "control.provider_test.oauth_unsaved": "OAuth 凭据需要先保存才能检查。如需现在检查,请同时填写访问令牌。", "control.rotation.written": "已写回 {path}(版本 {version})。", + "control.remote.shutdown_refused": "无法通过远程连接停止 core。服务器上的 core 由服务管理器管理,例如执行 systemctl stop twcore。", + "control.remote.diagnostics_refused": "诊断包只能在 core 所在的机器上生成。", + "control.remote.control_section_locked": "listen.control 不能通过远程连接修改:当前连接正是经由其中的端口进入。请在服务器上使用 twcore remote 修改,或直接编辑那里的配置文件。", "control.control_key_locked": "listen.control.key 不能在这里修改。请在 core 所在的机器上执行 twcore control-key --rotate,或直接编辑那里的配置文件。", "control.internal_error": "网关内部出错:{detail}", "control.records_unreadable": "无法读取请求记录:{detail}", @@ -471,6 +474,9 @@ "config.rejected": "配置有{stage:stage}错误:{detail}", "config.rejected_at": "配置第 {line} 行有{stage:stage}错误:{detail}", "config.schema_too_new": "配置的格式版本为 {found},当前 twcore 最高支持 {supported}。请升级应用,或将配置改回旧格式。", + "config.remote_port_zero": "listen.control.remote.port 为 0,应在 1–65535 之间。twcore remote enable 会自动选择一个空闲端口。", + "config.remote_port_is_gateway": "listen.control.remote.port 为 {port},与网关端口相同。两者需要使用不同的端口。", + "config.bad_remote_allow_from": "listen.control.remote.allow_from 中的「{entry}」有误:不是有效的 IP 地址或 CIDR,应写作 192.168.0.0/16 这样的格式。", "config.control_key_missing": "配置中没有 listen.control.key,桌面应用凭它连接 core。twcore serve 启动时会自动写入;twcore control-key --rotate 会写入一把新的。", "config.control_key_invalid": "listen.control.key 应为 64 位十六进制字符。twcore control-key --rotate 会写入一把新的。", "config.no_clients": "配置的 clients 中没有网关密钥,所有请求都会被拒绝。首次启动时会自动生成一把。", From 250c30dfb2f283740c6097fbfce0bbf5c36f2b79 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:42:02 +0800 Subject: [PATCH 4/5] Version mismatch names the control protocol when both sides report the same version Unreleased builds carry the same version number on both sides; the page then read "server core 0.46.0, this app needs 0.46.0". When the two strings are equal, the protocol numbers from the handshake are added. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/connection/connector.rs | 44 ++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index 723713ef..9ee78f7f 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -233,10 +233,32 @@ fn link_error(e: tw_link::LinkError, addr: &str) -> ConnectError { match e { LinkError::Unreachable(_) => ConnectError::Unreachable { addr }, LinkError::WrongKey => ConnectError::WrongKey, - LinkError::VersionMismatch { peer_version, .. } => ConnectError::VersionMismatch { - ours: REQUIRED_CORE.to_string(), - theirs: peer_version, - }, + LinkError::VersionMismatch { + ours, + theirs, + peer_version, + } => { + // 两边报的版本号一样、协议却不一样:没发版的构建之间就是这样(版本号还没 + // 跟着改)。只写版本号会成为「服务器 0.46.0,本应用需要 0.46.0」,看不出 + // 差在哪,所以这时把控制面协议的版本号一起写上 + let with_proto = |v: &str, p: u32| -> String { + tr!( + format!("{v}(控制面协议 {p})"), + format!("{v} (control protocol {p})") + ) + }; + if peer_version == REQUIRED_CORE { + ConnectError::VersionMismatch { + ours: with_proto(REQUIRED_CORE, ours), + theirs: with_proto(&peer_version, theirs), + } + } else { + ConnectError::VersionMismatch { + ours: REQUIRED_CORE.to_string(), + theirs: peer_version, + } + } + } LinkError::Timeout => ConnectError::Timeout { addr }, // 握手中途断了、或者对面说的不是这套协议:都是「被关掉了」 LinkError::Closed | LinkError::Io(_) => ConnectError::Closed { addr }, @@ -423,6 +445,20 @@ mod tests { theirs: "0.47.2".into(), } ); + // 版本号一样、协议不一样:把协议号带上,不然两边说的是同一个数 + let same = link_error( + tw_link::LinkError::VersionMismatch { + ours: 19, + theirs: 18, + peer_version: REQUIRED_CORE.into(), + }, + "h:1", + ); + let ConnectError::VersionMismatch { ours, theirs } = same else { + panic!("{same:?}"); + }; + assert_ne!(ours, theirs); + assert!(theirs.contains("18"), "{theirs}"); assert_eq!( link_error(tw_link::LinkError::Timeout, "h:1"), ConnectError::Timeout { addr: "h:1".into() } From 5e90bc72196a09af47439a3417d4c686f97be9d5 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:55:23 +0800 Subject: [PATCH 5/5] Pin every core crate to v0.47.0; drop the v0.46.0 transition - All core crates (tw-api, tw-types, tw-yaml, tw-guard, tw-watch, tw-link) on tag v0.47.0; regenerated tw-api.ts (CONTROL_API_VERSION 20) and lite-api.ts. - rotate_key no longer reads core's synced/failed fields: core does not sync adopted clients any more, the app does it. - scan_alert / clients_changed are gone from the core event switch in types.ts; control.key_bind_failed is gone from core.zh.json (no longer in core's msg codes). - tw-scan watch tests: a received signal is required, a closed channel no longer passes as one. Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/tw-scan/src/watch.rs | 16 +- src-tauri/src/keys.rs | 28 +- src/generated/lite-api.ts | 296 ++++++++++++++++++++- src/generated/tw-api.ts | 366 +------------------------- src/i18n/core.zh.json | 1 - src/types.ts | 4 +- 6 files changed, 312 insertions(+), 399 deletions(-) diff --git a/src-tauri/crates/tw-scan/src/watch.rs b/src-tauri/crates/tw-scan/src/watch.rs index df546734..6fc164f8 100644 --- a/src-tauri/crates/tw-scan/src/watch.rs +++ b/src-tauri/crates/tw-scan/src/watch.rs @@ -214,10 +214,12 @@ mod tests { let (_w, mut rx) = watch(std::slice::from_ref(&dir)).unwrap(); std::fs::write(dir.join("CLAUDE.md"), "# 二\n").unwrap(); + // 超时和通道关了都不算:要的是真收到一个信号 assert!( - tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await - .is_ok(), + matches!( + tokio::time::timeout(Duration::from_secs(5), rx.recv()).await, + Ok(Some(())) + ), "没收到信号" ); } @@ -244,10 +246,12 @@ mod tests { // 而我们关心的那种照样能叫醒它 std::fs::write(dir.join("CLAUDE.md"), "# 改了\n").unwrap(); + // 超时和通道关了都不算:要的是真收到一个信号 assert!( - tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await - .is_ok(), + matches!( + tokio::time::timeout(Duration::from_secs(5), rx.recv()).await, + Ok(Some(())) + ), "该醒的时候没醒" ); } diff --git a/src-tauri/src/keys.rs b/src-tauri/src/keys.rs index 184838d3..4201701b 100644 --- a/src-tauri/src/keys.rs +++ b/src-tauri/src/keys.rs @@ -113,33 +113,7 @@ pub async fn rotate_key( synced: Vec::new(), failed: Vec::new(), }; - // 钉着的这一版 core 自己还会同步(它那边的接管代码还在)。它报了的就照它报的 - // 说,不再写一遍 —— 再写一遍会把「接管于」刷成此刻。core 不再同步之后这一段 - // 随它的字段一起删掉 - for s in r.synced { - out.synced.push(wire::KeySynced { - client: s.client, - name: s.name, - takes_effect: match s.takes_effect { - tw_api::TakesEffect::Immediately => wire::TakesEffect::Immediately, - tw_api::TakesEffect::OnRestart => wire::TakesEffect::OnRestart, - }, - backup: s.backup, - }); - } - for f in r.failed { - out.failed.push(wire::KeySyncFailed { - client: f.client, - name: f.name, - error: f.error, - }); - } - let reported = |id: &str| { - out.synced.iter().any(|s| s.client == id) || out.failed.iter().any(|f| f.client == id) - }; - if let Some(c) = owner - && !reported(c.id) - { + if let Some(c) = owner { match crate::clients::sync_rotated(&state, &c, &out.key).await { Ok(s) => out.synced.push(s), Err(f) => out.failed.push(f), diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index e183362a..0c7f1c0d 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -1,6 +1,118 @@ // Generated from src-tauri/src/wire.rs (`tests/ts_bindings.rs`). Do not edit by hand. -import type { HookView, KeySyncFailed, KeySynced, McpView, ScanFinding, SkillView } from "./tw-api"; +import type { Msg } from "./tw-api"; + +export type AdoptResponse = { real: string, backup: string, created: boolean, +/** + * 不至于失败、但用户该知道的事(符号链接、权限太松……) + */ +warnings: Array, +/** + * 改动什么时候生效 + */ +takes_effect: TakesEffect, }; + +export type ClientsResponse = { clients: Array, manual: Array, +/** + * 客户端该连的地址 + */ +gateway_base: string, +/** + * config.yaml 里有哪几把网关密钥可选 + */ +keys: Array, }; + +/** + * 一个客户端此刻的样子。 + */ +export type DetectedClient = { id: string, name: string, +/** + * 用户认得的那个路径 + */ +path: string, +/** + * 跟完符号链接的真身。**和 `path` 不同时要显示出来** —— 用户以为 + * 在改 ~/.claude/settings.json,实际写的可能是他 dotfiles 仓库里 + * 的那份,而那是个会被 git 提交的地方 + */ +real: string, installed: boolean, has_config: boolean, adopted_at_ms: number | null, +/** + * 配置里此刻的端点。**读出来的**,不是拿我们自己的记录充数 + */ +endpoint: string | null, shadows: Array, takes_effect: TakesEffect, +/** + * 接管之后要不要在「一直没收到请求」时提示。 + * + * **需要重开终端的客户端不提示** —— 用户可能一整天都没重开过,那时 + * 弹「是不是没生效」是狼来了 + */ +warns_when_silent: boolean, +/** + * `measured`(在本机实际运行验证过)| `fields_only`(字段名查证过, + * 没有在本机实际运行验证) + */ +verified: Verification, +/** + * 接管之后会失去或改变的功能 + */ +costs: Array, +/** + * 为它生成的那把网关密钥(取消接管之后仍然记着)。还没有就不给 + */ +key?: string | null, +/** + * 最后一次收到**那把密钥**的请求。**接管有没有真的生效,只有它能证明。** + * + * 按密钥算,不按请求头里自报的客户端标识 —— 后者可以伪造,而「接好了没有」 + * 要的正是一个不能伪造的答案。没有密钥就没有这个值 + */ +last_seen_ms?: number | null, +/** + * 手动配置的方法:没检测到它(配置文件不在默认位置)时照着做 + */ +manual: ManualSetup, }; + +/** + * 配置文件里的一处改动。 + */ +export type FieldChange = { +/** + * `set` | `remove` + */ +op: FieldOp, +/** + * 字段路径,按层级用 `.` 连起来:`env.ANTHROPIC_BASE_URL` + */ +path: string, +/** + * 要写入的值。写的是网关密钥时不给 + */ +value?: string | null, +/** + * 这一项是网关密钥。**值不回显**,哪怕是打码的;界面写成「密钥 xxx」 + */ +secret?: boolean, }; + +/** + * 对一个字段做什么。 + */ +export type FieldOp = "set" | "remove"; + +/** + * 一条诊断发现的结论。 + */ +export type FindingLevel = "blocking" | "suspect" | "clear"; + +/** + * 一条诊断发现。 + */ +export type FindingView = { level: FindingLevel, title: Msg, detail: Msg, +/** + * 用户可以自己执行的下一步。**我们不替他执行。** + */ +fix: Msg | null, }; + +export type HookView = { client: string, event: string, command: string, source: string, }; /** * 换完之后的结果:core 换好的那把,加上**这台机器上**跟着改好、或者没能改好的客户端。 @@ -23,6 +135,22 @@ synced: Array, */ failed: Array, }; +export type KeySyncFailed = { client: string, name: string, error: Msg, }; + +export type KeySynced = { +/** + * 客户端 id(`claude-code` …) + */ +client: string, +/** + * 界面上显示的名字 + */ +name: string, takes_effect: TakesEffect, +/** + * 改之前的全文备份在哪 + */ +backup: string, }; + /** * 这台机器上发生的、界面要跟上的事(Tauri 事件 `local-event`)。 * @@ -31,11 +159,160 @@ failed: Array, }; */ export type LocalEvent = { "kind": "clients_changed", at_ms: number, } | { "kind": "scan_alert", alerts: Array, at_ms: number, }; +/** + * 接管不了、只能给指引的。 + */ +export type ManualClient = { +/** + * `cursor` / `continue` / `gemini-cli`。为它生成专用密钥时用 + */ +id: string, name: string, +/** + * 为它生成的那把网关密钥。还没有就不给 + */ +key?: string | null, +/** + * 最后一次收到那把密钥的请求 + */ +last_seen_ms?: number | null, setup: ManualSetup, +/** + * 配完还漏什么(Cursor 的补全不经过网关之类) + */ +caveat: Msg, }; + +/** + * 手动配置一个客户端的方法。 + * + * **地址和密钥不写进句子里**:界面各给一个复制按钮。写进句子的话,用户 + * 得从一句话里抠出一段 URL,而密钥根本不该出现在一句说明里。 + */ +export type ManualSetup = { +/** + * 按顺序做的几步 + */ +steps: Array, +/** + * 要写进配置文件的字段,就是接管时写的那几项。只有能接管的客户端有; + * 密钥那一项不给值(`secret` 为真),界面换成密钥的复制按钮 + */ +fields: Array, +/** + * 要填的网关地址,这个客户端要的写法(有的带 `/v1`) + */ +endpoint: string, }; + +/** + * MCP 矩阵上的一下。 + */ +export type McpOp = "copy" | "remove"; + +/** + * 在矩阵上点一下。 + */ +export type McpOpRequest = { +/** + * `copy` 或 `remove` + */ +op: McpOp, name: string, +/** + * `copy` 时从哪个客户端取 + */ +from: string | null, +/** + * 写到(或从中删掉)哪个客户端 + */ +to: string, }; + +/** + * 哪些客户端能被写入,哪些只能看。 + */ +export type McpTargetView = { client: string, name: string, path: string, +/** + * 能不能往里写。**不能写的照样在清单里** —— 看得见是第一目标 + */ +copyable: boolean, +/** + * 不能写的话,为什么。能写的时候没有 + */ +why_not?: Msg | null, }; + +export type McpView = { name: string, client: string, command: string, args: Array, +/** + * 远端型的地址 + */ +url: string | null, +/** + * **只有名字,没有值** + */ +env_keys: Array, enabled: boolean, source: string, +/** + * 远端而且不在本机 + */ +third_party: boolean, }; + +/** + * 算好但还没落盘的改动。**UI 拿它画 diff 让用户确认。** + */ +export type PlanView = { client: string, path: string, +/** + * 改之前的原文,**密钥已打码**。 + */ +before: string | null, +/** + * 改之后的原文,**密钥已打码 —— 落盘写的是真值**。 + * + * 界面上永远不显示真正的密钥,diff 里也不行:用户会截图这一屏来问 + * 「这样对吗」。 + */ +after: string, notes: Array, shadows: Array, +/** + * 已经是这样了,什么都不用改 + */ +noop: boolean, carries_secret: boolean, +/** + * 这次会改哪些字段。diff 之外再给一份摘要 + */ +fields: Array, +/** + * 写进去的是哪把网关密钥(还原时是留下来的那把)。MCP 的改动没有 + */ +key?: string | null, +/** + * 那把密钥要在接管的那一刻新建(此前没有为这个客户端留着的) + */ +key_created?: boolean, }; + /** * 把接管着的客户端改为指向另一个 core 之后:改好的、没改成的 */ export type Retargeted = { synced: Array, failed: Array, }; +/** + * 一处发现。 + */ +export type ScanFinding = { level: ScanLevel, +/** + * 哪条规则命中的 + */ +rule: string, +/** + * `hooks` | `mcp` | `skill` | `command` | `agent` | `instructions` + */ +kind: ScanSource, client: string, path: string, +/** + * 第几行,从 1 开始 + */ +line: number, title: Msg, detail: Msg, +/** + * 命中的那一行,**不可见字符已经换成可见记号** + */ +excerpt: string, }; + +/** + * 一处扫描发现有多要紧。 + */ +export type ScanLevel = "high" | "medium" | "low"; + /** * 扫一次的结果:用户级的配置面,此刻磁盘上的样子。 * @@ -52,3 +329,20 @@ conflicting: Array, */ unreadable: Array, scanned: number, }; +/** + * 扫描发现出在客户端配置面的哪一类东西里。 + */ +export type ScanSource = "hooks" | "mcp" | "skill" | "command" | "agent" | "instructions"; + +export type SkillView = { name: string, client: string, path: string, allowed_tools: Array, }; + +/** + * 改了客户端的配置之后,什么时候生效。 + */ +export type TakesEffect = "immediately" | "on_restart"; + +/** + * 一个客户端的接管方式验证到什么程度。 + */ +export type Verification = "measured" | "fields_only"; + diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 097ad3ae..a551abdb 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 19; +export const CONTROL_API_VERSION = 20; /** * 改一条内置规则在拦截档下做什么。只有工具调用审查和内容过滤的规则有这一项 —— @@ -12,22 +12,6 @@ export type ActionSave = { */ action: RuleAction, base_version?: string | null, }; -export type AdoptRequest = { client: string, -/** - * 用哪把网关密钥。不写就用第一把 —— 为「一个 key 就够」的人设计 - */ -key_name: string | null, }; - -export type AdoptResponse = { real: string, backup: string, created: boolean, -/** - * 不至于失败、但用户该知道的事(符号链接、权限太松……) - */ -warnings: Array, -/** - * 改动什么时候生效 - */ -takes_effect: TakesEffect, }; - /** * 尝试链里一跳的结果。 */ @@ -241,16 +225,6 @@ default?: boolean, */ last_seen_ms?: number | null, }; -export type ClientsResponse = { clients: Array, manual: Array, -/** - * 客户端该连的地址 - */ -gateway_base: string, -/** - * config.yaml 里有哪几把网关密钥可选 - */ -keys: Array, }; - /** * 路由规则 `when` 里的键。 */ @@ -477,56 +451,6 @@ export type DefaultKeySave = { name: string, base_version?: string | null, }; */ export type DefaultRouteSave = { name: string, base_version?: string | null, }; -/** - * 一个客户端此刻的样子。 - */ -export type DetectedClient = { id: string, name: string, -/** - * 用户认得的那个路径 - */ -path: string, -/** - * 跟完符号链接的真身。**和 `path` 不同时要显示出来** —— 用户以为 - * 在改 ~/.claude/settings.json,实际写的可能是他 dotfiles 仓库里 - * 的那份,而那是个会被 git 提交的地方 - */ -real: string, installed: boolean, has_config: boolean, adopted_at_ms: number | null, -/** - * 配置里此刻的端点。**读出来的**,不是拿我们自己的记录充数 - */ -endpoint: string | null, shadows: Array, takes_effect: TakesEffect, -/** - * 接管之后要不要在「一直没收到请求」时提示。 - * - * **需要重开终端的客户端不提示** —— 用户可能一整天都没重开过,那时 - * 弹「是不是没生效」是狼来了 - */ -warns_when_silent: boolean, -/** - * `measured`(在本机实际运行验证过)| `fields_only`(字段名查证过, - * 没有在本机实际运行验证) - */ -verified: Verification, -/** - * 接管之后会失去或改变的功能 - */ -costs: Array, -/** - * 为它生成的那把网关密钥(取消接管之后仍然记着)。还没有就不给 - */ -key?: string | null, -/** - * 最后一次收到**那把密钥**的请求。**接管有没有真的生效,只有它能证明。** - * - * 按密钥算,不按请求头里自报的客户端标识 —— 后者可以伪造,而「接好了没有」 - * 要的正是一个不能伪造的答案。没有密钥就没有这个值 - */ -last_seen_ms?: number | null, -/** - * 手动配置的方法:没检测到它(配置文件不在默认位置)时照着做 - */ -manual: ManualSetup, }; - /** * 请求体的格式(方言)。 */ @@ -847,7 +771,7 @@ action: RuleAction, /** * 真的切断了流吗。**拦截档 + 规则是切断**两者同时成立才会 */ -blocked: boolean, at_ms: number, } | { "kind": "scan_alert", id: number, alerts: Array, at_ms: number, } | { "kind": "request_priced", id: number, cost_micros?: number | null, cost_estimated?: boolean, cache_saved_micros?: number | null, at_ms: number, } | { "kind": "clients_changed", id: number, at_ms: number, } | { "kind": "health_changed", id: number, provider: string, +blocked: boolean, at_ms: number, } | { "kind": "request_priced", id: number, cost_micros?: number | null, cost_estimated?: boolean, cache_saved_micros?: number | null, at_ms: number, } | { "kind": "health_changed", id: number, provider: string, /** * `open` = 熔断中,不进候选链;`closed` = 可以用 */ @@ -945,46 +869,6 @@ probe: ProbeClass, at_ms: number, } | { "kind": "events_dropped", id: number, co */ export type FailureSource = "auth" | "config" | "upstream" | "request" | "rate_limited" | "denied" | "internal"; -/** - * 配置文件里的一处改动。 - */ -export type FieldChange = { -/** - * `set` | `remove` - */ -op: FieldOp, -/** - * 字段路径,按层级用 `.` 连起来:`env.ANTHROPIC_BASE_URL` - */ -path: string, -/** - * 要写入的值。写的是网关密钥时不给 - */ -value?: string | null, -/** - * 这一项是网关密钥。**值不回显**,哪怕是打码的;界面写成「密钥 xxx」 - */ -secret?: boolean, }; - -/** - * 对一个字段做什么。 - */ -export type FieldOp = "set" | "remove"; - -/** - * 一条诊断发现的结论。 - */ -export type FindingLevel = "blocking" | "suspect" | "clear"; - -/** - * 一条诊断发现。 - */ -export type FindingView = { level: FindingLevel, title: Msg, detail: Msg, -/** - * 用户可以自己执行的下一步。**我们不替他执行。** - */ -fix: Msg | null, }; - /** * 新建或修改一个策略组时交过来的定义。 */ @@ -1194,11 +1078,9 @@ key_masked?: string | null, */ security?: Array, }; -export type HookView = { client: string, event: string, command: string, source: string, }; - /** * 一把密钥上用户能改的东西。**密钥的值不在里面** —— 它由 core 生成, - * 要换就走更换,那条路会把新值同步给已接管的客户端。 + * 要换就走更换(新值写进已接管的客户端,由桌面端做)。 */ export type KeyInput = { name: string, max_concurrent?: number | null, /** @@ -1220,39 +1102,15 @@ export type KeyRotate = { base_version?: string | null, }; */ export type KeyRotated = { version: string, /** - * 新的密钥值。**只在这里给一次**,之后列表里只有脱敏的 + * 新的密钥值。**只在这里给一次**:正被接管的客户端的配置要换成它,由桌面端写 */ -key: string, -/** - * 跟着改好的客户端。空的就是没有客户端在用它 - */ -synced: Array, -/** - * 同步不上的客户端。**密钥已经换了**,这些要用户自己去改 - */ -failed: Array, }; +key: string, }; /** * 新建或保存一把网关密钥(`POST /keys`、`PUT /keys/{name}`)。 */ export type KeySave = { key: KeyInput, base_version?: string | null, }; -export type KeySyncFailed = { client: string, name: string, error: Msg, }; - -export type KeySynced = { -/** - * 客户端 id(`claude-code` …) - */ -client: string, -/** - * 界面上显示的名字 - */ -name: string, takes_effect: TakesEffect, -/** - * 改之前的全文备份在哪 - */ -backup: string, }; - /** * 一把密钥的明文(`GET /keys/{name}/value`)。「复制」按名字取此刻配置里的 * 值,不依赖界面手里那份列表是不是最新的。 @@ -1405,102 +1263,11 @@ tokens_per_sec: number | null, }; */ export type LoginStatus = "pending" | "done" | "failed" | "expired" | "cancelled"; -/** - * 接管不了、只能给指引的。 - */ -export type ManualClient = { -/** - * `cursor` / `continue` / `gemini-cli`。为它生成专用密钥时用 - */ -id: string, name: string, -/** - * 为它生成的那把网关密钥。还没有就不给 - */ -key?: string | null, -/** - * 最后一次收到那把密钥的请求 - */ -last_seen_ms?: number | null, setup: ManualSetup, -/** - * 配完还漏什么(Cursor 的补全不经过网关之类) - */ -caveat: Msg, }; - -/** - * 手动配置一个客户端的方法。 - * - * **地址和密钥不写进句子里**:界面各给一个复制按钮。写进句子的话,用户 - * 得从一句话里抠出一段 URL,而密钥根本不该出现在一句说明里。 - */ -export type ManualSetup = { -/** - * 按顺序做的几步 - */ -steps: Array, -/** - * 要写进配置文件的字段,就是接管时写的那几项。只有能接管的客户端有; - * 密钥那一项不给值(`secret` 为真),界面换成密钥的复制按钮 - */ -fields: Array, -/** - * 要填的网关地址,这个客户端要的写法(有的带 `/v1`) - */ -endpoint: string, }; - /** * 一条内置规则按什么认。**给界面说明用**,界面按类型写成自己的话。 */ export type Matcher = { "kind": "prefix", prefix: string, min_tail: number, } | { "kind": "openai-legacy", min_len: number, } | { "kind": "pem" } | { "kind": "jwt" } | { "kind": "conn-string" } | { "kind": "private-ip" } | { "kind": "domain-suffix", suffixes: Array, } | { "kind": "regex", pattern: string, } | { "kind": "contains", text: string, } | { "kind": "codepoints", ranges: Array, }; -/** - * MCP 矩阵上的一下。 - */ -export type McpOp = "copy" | "remove"; - -/** - * 在矩阵上点一下。 - */ -export type McpOpRequest = { -/** - * `copy` 或 `remove` - */ -op: McpOp, name: string, -/** - * `copy` 时从哪个客户端取 - */ -from: string | null, -/** - * 写到(或从中删掉)哪个客户端 - */ -to: string, }; - -/** - * 哪些客户端能被写入,哪些只能看。 - */ -export type McpTargetView = { client: string, name: string, path: string, -/** - * 能不能往里写。**不能写的照样在清单里** —— 看得见是第一目标 - */ -copyable: boolean, -/** - * 不能写的话,为什么。能写的时候没有 - */ -why_not?: Msg | null, }; - -export type McpView = { name: string, client: string, command: string, args: Array, -/** - * 远端型的地址 - */ -url: string | null, -/** - * **只有名字,没有值** - */ -env_keys: Array, enabled: boolean, source: string, -/** - * 远端而且不在本机 - */ -third_party: boolean, }; - /** * 一个没对上的条件。 */ @@ -1724,38 +1491,6 @@ export type PatchOp = { "op": "replace", path: string, value: PatchValue, } | { export type PatchValue = string | number | boolean | null; -/** - * 算好但还没落盘的改动。**UI 拿它画 diff 让用户确认。** - */ -export type PlanView = { client: string, path: string, -/** - * 改之前的原文,**密钥已打码**。 - */ -before: string | null, -/** - * 改之后的原文,**密钥已打码 —— 落盘写的是真值**。 - * - * 界面上永远不显示真正的密钥,diff 里也不行:用户会截图这一屏来问 - * 「这样对吗」。 - */ -after: string, notes: Array, shadows: Array, -/** - * 已经是这样了,什么都不用改 - */ -noop: boolean, carries_secret: boolean, -/** - * 这次会改哪些字段。diff 之外再给一份摘要 - */ -fields: Array, -/** - * 写进去的是哪把网关密钥(还原时是留下来的那把)。MCP 的改动没有 - */ -key?: string | null, -/** - * 那把密钥要在接管的那一刻新建(此前没有为这个客户端留着的) - */ -key_created?: boolean, }; - /** * 一个模型的单价,**每百万 tokens 的美元**,和厂商定价页上印的一样。 * @@ -2618,65 +2353,6 @@ client_hint: string | null, model: string, */ provider: string, at_ms: number, }; -/** - * 一处发现。 - */ -export type ScanFinding = { level: ScanLevel, -/** - * 哪条规则命中的 - */ -rule: string, -/** - * `hooks` | `mcp` | `skill` | `command` | `agent` | `instructions` - */ -kind: ScanSource, client: string, path: string, -/** - * 第几行,从 1 开始 - */ -line: number, title: Msg, detail: Msg, -/** - * 命中的那一行,**不可见字符已经换成可见记号** - */ -excerpt: string, }; - -/** - * 一处扫描发现有多要紧。 - */ -export type ScanLevel = "high" | "medium" | "low"; - -/** - * `POST /scan`:除了用户级的配置面,还扫哪些项目目录。**我们不去找项目, - * 只看用户指的。** - * - * 是请求体不是查询串:一串目录在查询串里是同一个键写好几次,而 axum 的 - * `Query` 读不了那种写法 —— 以前是 `GET /scan?project=…`,带上一个目录就 400。 - */ -export type ScanRequest = { projects: Array, }; - -/** - * 扫一次的结果。 - * - * **不存任何东西**:这是此刻磁盘上的真实情况,页面关了就没了。 - */ -export type ScanResponse = { findings: Array, mcp: Array, skills: Array, hooks: Array, -/** - * 同名但配置不同的 MCP server 名字(矩阵上要标记号) - */ -conflicting: Array, -/** - * 读不动的文件。**要显示** —— 悄悄跳过会给人「查过了」的错觉 - */ -unreadable: Array, scanned: number, -/** - * 这次连哪些项目目录一起扫了 - */ -projects: Array, }; - -/** - * 扫描发现出在客户端配置面的哪一类东西里。 - */ -export type ScanSource = "hooks" | "mcp" | "skill" | "command" | "agent" | "instructions"; - /** * 一个密钥类的值怎么改。**三态**,因为视图里拿不到原值:不动就得有「保持原样」。 */ @@ -2991,8 +2667,6 @@ field: SetField, value: string, }; */ export type SheetRef = { "kind": "default" } | { "kind": "named", name: string, } | { "kind": "draft", sheet: PriceSheetInput, }; -export type SkillView = { name: string, client: string, path: string, allowed_tools: Array, }; - /** * 一个被跳过的候选上游。 */ @@ -3182,11 +2856,6 @@ security: SecurityCounts, */ pricing_date: string, }; -/** - * 改了客户端的配置之后,什么时候生效。 - */ -export type TakesEffect = "immediately" | "on_restart"; - /** * 一次请求做过的格式转换。 */ @@ -3241,11 +2910,6 @@ export type UsageView = { input: number, output: number, cache_read: number, cac */ cache_1h?: boolean, }; -/** - * 一个客户端的接管方式验证到什么程度。 - */ -export type Verification = "measured" | "fields_only"; - /** * 聚合类端点的时间窗(`GET /summary`、`/latency`)。**缺省是「今天」而不是 * 「最近 24 小时」** —— 用户问的是「今天花了多少」,那是个从零点算起的问题。 @@ -3355,17 +3019,7 @@ export const ENDPOINTS = { ReplayQuote: { method: "POST", path: "/replay/quote", params: [], format: "json" }, ReplayRun: { method: "POST", path: "/replay/run", params: [], format: "json" }, DryRun: { method: "POST", path: "/dryrun", params: [], format: "json" }, - Scan: { method: "POST", path: "/scan", params: [], format: "json" }, - Clients: { method: "GET", path: "/clients", params: [], format: "json" }, - PlanAdopt: { method: "POST", path: "/clients/plan", params: [], format: "json" }, - Adopt: { method: "POST", path: "/clients/adopt", params: [], format: "json" }, - PlanRestore: { method: "GET", path: "/clients/{id}/restore/plan", params: ["id"], format: "json" }, - Restore: { method: "POST", path: "/clients/{id}/restore", params: ["id"], format: "json" }, - Why: { method: "GET", path: "/clients/{id}/why", params: ["id"], format: "json" }, ClientKey: { method: "POST", path: "/clients/{id}/key", params: ["id"], format: "json" }, - McpTargets: { method: "GET", path: "/mcp/targets", params: [], format: "json" }, - McpPlan: { method: "POST", path: "/mcp/plan", params: [], format: "json" }, - McpApply: { method: "POST", path: "/mcp/apply", params: [], format: "json" }, Keys: { method: "GET", path: "/keys", params: [], format: "json" }, CreateKey: { method: "POST", path: "/keys", params: [], format: "json" }, UpdateKey: { method: "PUT", path: "/keys/{name}", params: ["name"], format: "json" }, @@ -3459,17 +3113,7 @@ export type Endpoints = { ReplayQuote: { req: ReplayRequest; res: ReplayQuote }; ReplayRun: { req: ReplayRequest; res: ReplayResult }; DryRun: { req: DryRunRequest; res: DryRunResult }; - Scan: { req: ScanRequest; res: ScanResponse }; - Clients: { req: null; res: ClientsResponse }; - PlanAdopt: { req: AdoptRequest; res: PlanView }; - Adopt: { req: AdoptRequest; res: AdoptResponse }; - PlanRestore: { req: null; res: PlanView }; - Restore: { req: null; res: AdoptResponse }; - Why: { req: null; res: Array }; ClientKey: { req: null; res: ClientKey }; - McpTargets: { req: null; res: Array }; - McpPlan: { req: McpOpRequest; res: PlanView }; - McpApply: { req: McpOpRequest; res: AdoptResponse }; Keys: { req: null; res: Array }; CreateKey: { req: KeySave; res: ConfigWritten }; UpdateKey: { req: KeySave; res: ConfigWritten }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index c904bf1d..55136f1c 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -366,7 +366,6 @@ "control.store_unavailable": "请求记录不可用,数据库无法打开或磁盘出错,转发不受影响。", "control.no_keys": "config.yaml 中尚无网关密钥,请先创建网关密钥,再接管客户端。", "control.key_create_failed": "无法创建网关密钥:{detail}", - "control.key_bind_failed": "无法记录密钥归属:{detail}", "control.name_empty": "{kind:kind}名称不能为空。", "control.name_whitespace": "{kind:kind}名称首尾不能包含空白。", "control.name_reserved": "{kind:kind}名称不能以 {prefix} 开头,该前缀保留给内置项。", diff --git a/src/types.ts b/src/types.ts index f209b94b..ed1b7f9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -193,8 +193,6 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { case "config_reloaded": case "listen_changed": case "config_rejected": - case "scan_alert": - case "clients_changed": case "health_changed": case "models_changed": case "quota_seen": @@ -209,7 +207,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { // 都不进请求列表。三项请求和输出防护的命中在安全日志和请求详情里;拦下的 // 请求随后有一条失败事件,那一行照常标成失败。 // - // 其余几种也不进。配置事件、扫描告警、熔断、额度、凭据、代理说的都是 + // 其余几种也不进。配置事件、熔断、额度、凭据、代理说的都是 // 「现在什么情况」,而这张表装的是「刚才发生过什么」。App 单独接。 break; case "secrets_found": {