diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6467d0d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,178 @@ +name: Build and Release + +on: + push: + tags: ['v*'] + branches: + - main + - 'feat/**' + pull_request: + branches: ['main'] + +jobs: + # ---- Windows 桌面 app ---- + build-windows: + needs: build-helper + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + artifact: CSSwitch-Windows-x64 + - target: aarch64-pc-windows-msvc + artifact: CSSwitch-Windows-arm64 + steps: + - uses: actions/checkout@v4 + - name: Download Linux Helper Assets + uses: actions/download-artifact@v4 + with: + pattern: csswitch-helper-linux-* + path: desktop/src-tauri/helper-assets + merge-multiple: true + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Build Desktop App + run: | + cd desktop + npm install + npx tauri build --target ${{ matrix.target }} --bundles nsis + - name: Upload NSIS Installer + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: desktop/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe + + # ---- macOS 桌面 app ---- + build-macos: + needs: build-helper + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Download Linux Helper Assets + uses: actions/download-artifact@v4 + with: + pattern: csswitch-helper-linux-* + path: desktop/src-tauri/helper-assets + merge-multiple: true + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Build Desktop App + run: | + cd desktop + npm install + npx tauri build --target aarch64-apple-darwin --bundles dmg + - name: Upload DMG Installer + uses: actions/upload-artifact@v4 + with: + name: CSSwitch-macOS-arm64 + path: desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + + # ---- Linux Helper ---- + build-helper: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + asset_arch: x86_64 + - target: aarch64-unknown-linux-musl + asset_arch: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: taiki-e/install-action@v2 + with: + tool: cross + - name: Build Helper + env: + CSSWITCH_BUNDLED_PROXY_DIR: ${{ github.workspace }}/proxy + run: | + cd desktop/src-tauri + cross build --bin csswitch-helper --no-default-features --release --target ${{ matrix.target }} + mkdir -p helper-out + cp target/${{ matrix.target }}/release/csswitch-helper helper-out/csswitch-helper-linux-${{ matrix.asset_arch }} + - name: Upload Helper + uses: actions/upload-artifact@v4 + with: + name: csswitch-helper-linux-${{ matrix.asset_arch }} + path: desktop/src-tauri/helper-out/csswitch-helper-linux-${{ matrix.asset_arch }} + + # ---- Tests ---- + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + - name: Run Tests + run: cd desktop/src-tauri && cargo test --lib + - name: Run Helper Tests + run: cd desktop/src-tauri && cargo test --bin csswitch-helper --no-default-features + + # ---- GitHub Release (tag push only) ---- + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-windows, build-macos, build-helper, test] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + - name: Resolve Release Notes + id: release_notes + shell: bash + run: | + tag="${GITHUB_REF_NAME}" + version="${tag#v}" + if [[ -f "docs/release-notes/${tag}.md" ]]; then + echo "path=docs/release-notes/${tag}.md" >> "$GITHUB_OUTPUT" + elif [[ -f "docs/release-notes/${version}.md" ]]; then + echo "path=docs/release-notes/${version}.md" >> "$GITHUB_OUTPUT" + else + echo "path=" >> "$GITHUB_OUTPUT" + fi + - name: Create Release with curated notes + if: steps.release_notes.outputs.path != '' + uses: softprops/action-gh-release@v1 + with: + files: | + CSSwitch-Windows-*/*.exe + CSSwitch-macOS-arm64/*.dmg + csswitch-helper-*/* + draft: false + body_path: ${{ steps.release_notes.outputs.path }} + - name: Create Release with generated notes + if: steps.release_notes.outputs.path == '' + uses: softprops/action-gh-release@v1 + with: + files: | + CSSwitch-Windows-*/*.exe + CSSwitch-macOS-arm64/*.dmg + csswitch-helper-*/* + draft: false + generate_release_notes: true diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 79d2183..6ac2dc2 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -82,6 +82,17 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -299,6 +310,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -445,6 +465,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.65" @@ -752,7 +781,12 @@ version = "0.3.6" dependencies = [ "aes-gcm", "base64 0.22.1", + "dirs 5.0.1", + "fs2", "hkdf", + "keyring", + "lazy_static", + "rand", "serde", "serde_json", "sha2", @@ -772,13 +806,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -789,7 +844,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1089,6 +1144,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1801,6 +1866,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1946,6 +2012,33 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1daef93704e6f6506d5b273175189bf8a5e00371930387fb77fd871e15c0d94" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2135,12 +2228,76 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2622,6 +2779,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2720,6 +2886,27 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2744,6 +2931,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2938,6 +3136,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3407,7 +3647,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3457,7 +3697,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3997,7 +4237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4528,6 +4768,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -4583,6 +4836,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4616,6 +4878,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4656,6 +4933,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4668,6 +4951,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4680,6 +4969,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4698,6 +4993,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4710,6 +5011,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4722,6 +5029,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4734,6 +5047,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4796,7 +5115,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -4909,6 +5228,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.16.0" @@ -4935,6 +5265,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4956,6 +5306,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6c7c0f0..b41b847 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,31 +1,50 @@ [package] name = "desktop" version = "0.3.6" -description = "CSSwitch 菜单栏 app(进程管家 + 配置面板)" +description = "CSSwitch 菜单栏 app(进程管家 + 配置面板 + 远程服务器管理)" authors = ["CSSwitch"] edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +default-run = "csswitch" [lib] -# The `_lib` suffix may seem redundant but it is necessary -# to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[[bin]] +name = "csswitch" +path = "src/main.rs" +required-features = ["desktop"] + +[[bin]] +name = "csswitch-helper" +path = "src/bin/csswitch-helper.rs" + +[[bin]] +name = "csswitch-ssh-askpass" +path = "src/bin/csswitch-ssh-askpass.rs" +required-features = ["desktop"] + +[features] +default = ["desktop"] +desktop = ["tauri", "tauri-build", "tauri-plugin-opener"] + [build-dependencies] -tauri-build = { version = "2", features = [] } +tauri-build = { version = "2", features = [], optional = true } [dependencies] -tauri = { version = "2", features = [] } -tauri-plugin-opener = "2" +tauri = { version = "2", features = [], optional = true } +tauri-plugin-opener = { version = "2", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" +rand = "0.8" +dirs = "5" +# P1-5 修复:文件锁,防止并发写入 remote-hosts.json 时数据丢失 +fs2 = "0.4" +# P1-8 修复:health check 缓存,减少频繁 SSH 连接 +lazy_static = "1.4" # 虚拟 OAuth 伪造器(Rust 原生,去 node 依赖 —— 见 src/oauth_forge.rs)。 -# 纯 RustCrypto,全部编进二进制,不引外部运行时。与 .mjs 的 v2 GCM 格式字节兼容 -# (由 oauth_forge.rs 内 tests 的 node↔rust 双向对拍单测保证)。 aes-gcm = "0.10" hkdf = "0.12" sha2 = "0.10" base64 = "0.22" +keyring = "4.1.3" diff --git a/desktop/src-tauri/Cross.toml b/desktop/src-tauri/Cross.toml new file mode 100644 index 0000000..2c61698 --- /dev/null +++ b/desktop/src-tauri/Cross.toml @@ -0,0 +1,3 @@ +[build.env] +passthrough = ["CSSWITCH_BUNDLED_PROXY_DIR"] +volumes = ["CSSWITCH_BUNDLED_PROXY_DIR"] diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e..24dd5b8 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,53 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + fn main() { + configure_bundled_proxy_dir(); + + #[cfg(feature = "desktop")] tauri_build::build() } + +fn configure_bundled_proxy_dir() { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required")); + let proxy_dir = env::var_os("CSSWITCH_BUNDLED_PROXY_DIR") + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + manifest_dir.join(path) + } + }) + .unwrap_or_else(|| manifest_dir.join("..").join("..").join("proxy")); + let proxy_dir_str = proxy_dir.to_string_lossy().replace('\\', "/"); + + println!("cargo:rerun-if-env-changed=CSSWITCH_BUNDLED_PROXY_DIR"); + for resource in [ + "csswitch_proxy.py", + "dsml_shim.py", + "provider_policy.py", + "anthropic_compat.py", + ] { + require_bundled_proxy_file(&proxy_dir, resource); + println!( + "cargo:rerun-if-changed={}", + proxy_dir.join(resource).display() + ); + } + println!("cargo:rustc-env=CSSWITCH_BUNDLED_PROXY_DIR={proxy_dir_str}"); +} + +fn require_bundled_proxy_file(proxy_dir: &Path, resource: &str) { + let path = proxy_dir.join(resource); + if !path.is_file() { + panic!( + "bundled proxy resource '{}' not found at {}; set CSSWITCH_BUNDLED_PROXY_DIR to the repository proxy directory", + resource, + path.display() + ); + } +} diff --git a/desktop/src-tauri/helper-assets/.gitkeep b/desktop/src-tauri/helper-assets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/desktop/src-tauri/helper-assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/desktop/src-tauri/src/bin/csswitch-helper.rs b/desktop/src-tauri/src/bin/csswitch-helper.rs new file mode 100644 index 0000000..f3e84fd --- /dev/null +++ b/desktop/src-tauri/src/bin/csswitch-helper.rs @@ -0,0 +1,71 @@ +//! csswitch-helper — CSSwitch 远程服务器管理 Helper CLI。 +//! +//! 一个独立 Rust 二进制(零外部运行时依赖),部署在远程 Linux 服务器上。 +//! 通过 JSON-line 协议与桌面端通信,管理本地代理进程、配置文件和沙箱。 +//! +//! 用法: +//! csswitch-helper --json status # 健康/能力报告 +//! csswitch-helper --json proxy start ... # 启代理 +//! csswitch-helper --json serve # 持久 JSON-line 会话模式 +//! +//! 编译(无 Tauri 依赖): +//! cargo build --bin csswitch-helper --no-default-features --release + +// 通过 #[path] 引入共享模块(helper 不依赖 Tauri,无法用 crate:: 引用整个 lib)。 +#[path = "../cli/mod.rs"] +mod cli; +#[path = "../config.rs"] +mod config; +#[path = "../config_legacy.rs"] +mod config_legacy; +#[path = "../fs_ext.rs"] +mod fs_ext; +#[path = "../oauth_forge.rs"] +mod oauth_forge; +#[path = "../proc.rs"] +mod proc; +#[path = "../templates.rs"] +mod templates; + +fn main() { + // 初始化操作日志(Plan V2 §3.7)。 + let _ = cli::logger::init(); + let args: Vec = std::env::args().skip(1).collect(); + + // --json 标志:控制输出格式(JSON 信封 vs 人类可读文本) + let use_json = args.first().map_or(false, |a| a == "--json"); + let args: Vec = args.into_iter().filter(|a| a != "--json").collect(); + + if args.first().map_or(false, |a| a == "serve") { + // 持久会话模式:stdin/stdout JSON-line 循环 + cli::serve::run_stdio(); + } else { + // 单次命令模式 + let response = cli::dispatch(&args); + if use_json { + // JSON 输出供桌面端解析 + println!( + "{}", + serde_json::to_string(&response).unwrap_or_else(|_| { + r#"{"ok":false,"error":{"code":"serialize_error","message":"序列化响应失败"}}"# + .to_string() + }) + ); + } else { + // 人类可读输出(无 --json 标志时的默认行为) + if response.ok { + if let Some(data) = &response.data { + println!("{}", serde_json::to_string_pretty(data).unwrap_or_default()); + } else { + println!("OK"); + } + } else if let Some(err) = &response.error { + eprintln!("错误 [{}]: {}", err.code, err.message); + if let Some(suggestion) = &err.suggestion { + eprintln!("建议: {suggestion}"); + } + std::process::exit(1); + } + } + } +} diff --git a/desktop/src-tauri/src/bin/csswitch-ssh-askpass.rs b/desktop/src-tauri/src/bin/csswitch-ssh-askpass.rs new file mode 100644 index 0000000..3232d6b --- /dev/null +++ b/desktop/src-tauri/src/bin/csswitch-ssh-askpass.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(desktop_lib::remote::askpass::run_cli()); +} diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs new file mode 100644 index 0000000..21af0b5 --- /dev/null +++ b/desktop/src-tauri/src/cli/commands.rs @@ -0,0 +1,1302 @@ +//! Helper CLI 的命令实现。 +//! +//! 每个命令返回 `CliEnvelope`,由 `mod.rs` 中的 `dispatch()` 函数调用。 +//! 管理远程服务器上的 `csswitch_proxy.py` 代理进程、`~/.csswitch/config.json` 配置、 +//! Claude Science 沙箱和日志文件。 + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +use super::types::CliEnvelope; + +const BUNDLED_PROXY: &str = include_str!(concat!( + env!("CSSWITCH_BUNDLED_PROXY_DIR"), + "/csswitch_proxy.py" +)); +const BUNDLED_DSML_SHIM: &str = + include_str!(concat!(env!("CSSWITCH_BUNDLED_PROXY_DIR"), "/dsml_shim.py")); +const BUNDLED_PROVIDER_POLICY: &str = include_str!(concat!( + env!("CSSWITCH_BUNDLED_PROXY_DIR"), + "/provider_policy.py" +)); +const BUNDLED_ANTHROPIC_COMPAT: &str = include_str!(concat!( + env!("CSSWITCH_BUNDLED_PROXY_DIR"), + "/anthropic_compat.py" +)); +const MANAGED_PROXY_HINT: &str = "~/.csswitch/proxy/csswitch_proxy.py"; +const REAL_SCIENCE_PORT: u16 = 8765; + +fn validate_managed_port(port: u16) -> Result<(), CliEnvelope> { + if port == 0 { + return Err(CliEnvelope::err("invalid_port", "端口不能为 0。")); + } + if port == REAL_SCIENCE_PORT { + return Err(CliEnvelope::err( + "reserved_port", + "端口 8765 是真实 Science 实例保留端口,不能用。", + )); + } + Ok(()) +} + +// ============================================================================ +// 路径工具 +// ============================================================================ + +/// Helper 操作日志。 +use super::logger; + +/// 获取 `~/.csswitch` 目录路径(供 proc_manager 等外部模块使用,故 pub)。 +pub fn config_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".csswitch") +} + +/// 获取 `~/.csswitch/config.json` 路径。 +fn config_path() -> PathBuf { + config_dir().join("config.json") +} + +fn managed_proxy_path() -> PathBuf { + config_dir().join("proxy").join("csswitch_proxy.py") +} + +fn managed_proxy_file(name: &str) -> PathBuf { + config_dir().join("proxy").join(name) +} + +fn write_managed_proxy_file(path: &Path, desired: &[u8]) -> Result<(), String> { + let needs_write = match fs::read(path) { + Ok(existing) => existing != desired, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => true, + Err(e) => return Err(format!("读取 {} 失败:{e}", path.display())), + }; + if needs_write { + fs::write(path, desired).map_err(|e| format!("写入 {} 失败:{e}", path.display()))?; + } + Ok(()) +} + +fn ensure_managed_proxy_script() -> Result { + let main = managed_proxy_path(); + let parent = main + .parent() + .ok_or_else(|| format!("代理脚本路径无父目录:{MANAGED_PROXY_HINT}"))?; + fs::create_dir_all(parent).map_err(|e| format!("创建 ~/.csswitch/proxy 失败:{e}"))?; + + let shim = managed_proxy_file("dsml_shim.py"); + let provider_policy = managed_proxy_file("provider_policy.py"); + let anthropic_compat = managed_proxy_file("anthropic_compat.py"); + write_managed_proxy_file(&shim, BUNDLED_DSML_SHIM.as_bytes())?; + write_managed_proxy_file(&provider_policy, BUNDLED_PROVIDER_POLICY.as_bytes())?; + write_managed_proxy_file(&anthropic_compat, BUNDLED_ANTHROPIC_COMPAT.as_bytes())?; + write_managed_proxy_file(&main, BUNDLED_PROXY.as_bytes())?; + Ok(main) +} + +/// 获取 `~/.csswitch/logs/` 目录路径。 +pub fn logs_dir() -> PathBuf { + config_dir().join("logs") +} + +/// 定位 `proxy/csswitch_proxy.py`: +/// 1. `CSSWITCH_PROXY_DIR` 环境变量 +/// 2. `~/.csswitch/proxy/`(统一管理目录,缺失或过期时由 helper 内置副本自愈) +fn proxy_script_path() -> Result { + if let Ok(dir) = std::env::var("CSSWITCH_PROXY_DIR") { + let p = PathBuf::from(&dir).join("csswitch_proxy.py"); + if p.is_file() { + return Ok(p); + } + } + ensure_managed_proxy_script() +} + +// ============================================================================ +// 辅助函数 +// ============================================================================ + +struct ProxyLaunch { + adapter: String, + key: String, + key_env: &'static str, + base_url: String, + model: String, + thinking_policy: &'static str, +} + +fn key_env_for_adapter(adapter: &str) -> &'static str { + match adapter { + "deepseek" => "DEEPSEEK_API_KEY", + "qwen" => "DASHSCOPE_API_KEY", + "openai-custom" | "openai-responses" => "CSSWITCH_OPENAI_KEY", + _ => "CSSWITCH_RELAY_KEY", + } +} + +fn is_native_adapter(adapter: &str) -> bool { + matches!(adapter, "deepseek" | "qwen") +} + +fn is_openai_adapter(adapter: &str) -> bool { + matches!(adapter, "openai-custom" | "openai-responses") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openai_adapters_use_openai_key_env() { + assert_eq!(key_env_for_adapter("openai-custom"), "CSSWITCH_OPENAI_KEY"); + assert_eq!( + key_env_for_adapter("openai-responses"), + "CSSWITCH_OPENAI_KEY" + ); + } +} + +/// 从 `~/.csswitch/config.json` 读取 active Profile,并派生代理启动参数。 +fn proxy_launch_from_config(provider: &str) -> Result, String> { + let cfg = crate::config::load_from(&config_dir()).map_err(|e| format!("读配置失败:{e}"))?; + let Some(profile) = cfg.active_profile() else { + return Ok(None); + }; + let adapter = crate::templates::adapter_for(&profile.template_id).to_string(); + if provider != adapter && provider != profile.template_id { + return Err(format!( + "当前生效 Profile 是 {},不能作为 {provider} 启动。", + profile.template_id + )); + } + if profile.api_key.trim().is_empty() { + return Ok(None); + } + if !is_native_adapter(&adapter) { + if profile.base_url.trim().is_empty() + || !(profile.base_url.starts_with("http://") + || profile.base_url.starts_with("https://")) + { + return Err("当前配置需要 http(s):// 开头的 base_url。".to_string()); + } + if profile.model.trim().is_empty() { + return Err("当前配置需要选择或填写模型。".to_string()); + } + } + + Ok(Some(ProxyLaunch { + key_env: key_env_for_adapter(&adapter), + adapter, + key: profile.api_key.trim().to_string(), + base_url: profile.base_url.clone(), + model: profile.model.clone(), + thinking_policy: crate::templates::thinking_policy_for(&profile.template_id), + })) +} + +/// 通过 HTTP GET /health 探活本地代理。 +fn proxy_health(port: u16, secret: &str) -> bool { + use std::io::{Read, Write}; + use std::net::TcpStream; + + let addr = format!("127.0.0.1:{port}"); + let Ok(mut stream) = TcpStream::connect_timeout( + &addr.parse().unwrap(), + std::time::Duration::from_millis(500), + ) else { + return false; + }; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500))); + let req = + format!("GET /{secret}/health HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + if stream.write_all(req.as_bytes()).is_err() { + return false; + } + let mut buf = [0u8; 256]; + let Ok(n) = stream.read(&mut buf) else { + return false; + }; + let head = String::from_utf8_lossy(&buf[..n]); + // 严格解析 HTTP 状态码(审核 P2-7):精确匹配第二段 "200",避免 reason phrase 中的误判。 + head.lines() + .next() + .map_or(false, |line| line.split_whitespace().nth(1) == Some("200")) +} + +// ============================================================================ +// 命令实现 +// ============================================================================ + +/// `status` — 返回 Helper 版本、能力列表、代理/沙箱运行状态。 +/// 无状态实现:通过 TCP 端口探活检测实际运行状态。 +pub fn cmd_status() -> CliEnvelope { + let capabilities: Vec<&str> = vec![ + "proxy", + "sandbox", + "config", + "logs", + "doctor", + "verify", + "proxy-bundle-v2", + ]; + // 从配置读端口然后 TCP 探活,不依赖内存中的 PID + let port = get_configured_port(); + let proxy_running = is_port_open(port); + CliEnvelope::ok(json!({ + "version": env!("CARGO_PKG_VERSION"), + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "capabilities": capabilities, + "proxy_running": proxy_running, + "sandbox_running": sandbox_is_running(), + })) +} + +/// `config get` — 读取 `~/.csswitch/config.json` 并返回(key 已掩码)。 +pub fn cmd_config_get() -> CliEnvelope { + let path = config_path(); + if !path.exists() { + return CliEnvelope::ok(json!({ + "provider": "deepseek", + "proxy_port": 18991, + "sandbox_port": 8990, + "mode": "proxy", + "keys": {} + })); + } + match fs::read_to_string(&path) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(mut cfg) => { + // 掩码所有 provider key(只保留末 4 位) + if let Some(providers) = cfg.get_mut("providers").and_then(|v| v.as_object_mut()) { + for (_name, prov) in providers.iter_mut() { + if let Some(key) = prov.get("key").and_then(|k| k.as_str()) { + let masked = if key.len() > 4 { + format!("{}{}", "•".repeat(key.len() - 4), &key[key.len() - 4..]) + } else { + "••••".to_string() + }; + prov["key"] = json!(masked); + } + } + } + CliEnvelope::ok(cfg) + } + Err(e) => CliEnvelope::err("config_parse_error", &format!("配置文件格式错误:{e}")), + }, + Err(e) => CliEnvelope::err("config_read_error", &format!("无法读取配置文件:{e}")), + } +} + +/// `config set ` — 写入 `~/.csswitch/config.json`。 +/// 审查 C1 修复:使用 `config.rs` 的安全写入路径(symlink 拒绝 + 0600 + 原子写)。 +pub fn cmd_config_set(json_str: &str) -> CliEnvelope { + let v: Value = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(e) => return CliEnvelope::err("config_parse_error", &format!("JSON 解析失败:{e}")), + }; + // 构建 Config 对象并走安全写入路径(复用 config.rs 的 save_to 函数) + let cfg: crate::config::Config = match serde_json::from_value(v) { + Ok(c) => c, + Err(e) => return CliEnvelope::err("config_parse_error", &format!("配置格式错误:{e}")), + }; + let dir = config_dir(); + if let Err(e) = crate::config::save_to(&dir, &cfg) { + return CliEnvelope::err("config_write_error", &format!("写入配置失败:{e}")); + } + CliEnvelope::ok_empty() +} + +/// `config save-key ` — 保存 provider key。 +/// 审查 C1 修复:使用 `config.rs` 的 update 函数走安全读写路径。 +pub fn cmd_config_save_key(provider: &str, key: &str) -> CliEnvelope { + let dir = config_dir(); + let result = crate::config::update(&dir, |cfg| { + if let Some(p) = cfg.active_profile_mut() { + if p.template_id == provider + || crate::templates::adapter_for(&p.template_id) == provider + { + p.api_key = key.to_string(); + } + } + }); + if let Err(e) = result { + return CliEnvelope::err("config_write_error", &format!("保存 key 失败:{e}")); + } + // 返回掩码后的 key + let masked = if key.len() > 4 { + format!("{}{}", "•".repeat(key.len() - 4), &key[key.len() - 4..]) + } else { + "••••".to_string() + }; + CliEnvelope::ok(json!({"masked": masked})) +} + +/// `proxy start ` — 启动代理进程。 +pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { + if let Err(err) = validate_managed_port(port) { + return err; + } + + // 检查是否已在运行(通过 TCP 端口探活) + if is_port_open(port) { + if proxy_health(port, secret) { + return CliEnvelope::err( + "proxy_already_running", + &format!("代理已在端口 {} 上运行", port), + ); + } + let _ = stop_recorded_proxy(port); + if !clear_unhealthy_proxy_port(port) { + return CliEnvelope::err_with_hint( + "port_in_use", + &format!("端口 {port} 上已有进程,但不是当前可用的 CSSwitch 代理。"), + "请先停止占用该端口的进程,或在高级设置中换一个代理端口。", + ); + } + } + + // 获取需要注入的 active Profile 连接信息 + let launch = match proxy_launch_from_config(provider) { + Ok(Some(v)) => v, + Ok(None) => { + return CliEnvelope::err_with_hint( + "key_not_found", + &format!("配置中未找到 {provider} 的 API key"), + "请先在客户端面板填写并保存 API Key。", + ) + } + Err(e) => return CliEnvelope::err("config_read_error", &e), + }; + + // 定位 python3 + let python = match find_cmd("python3") { + Some(p) => p, + None => { + // 尝试 python + match find_cmd("python") { + Some(p) => p, + None => return CliEnvelope::err_with_hint( + "python_not_found", + "远程服务器上未找到 Python 3。", + "请在服务器上安装 Python 3.8+(apt install python3 或 yum install python3)。", + ), + } + } + }; + + let script = match proxy_script_path() { + Ok(p) => p, + Err(e) => return CliEnvelope::err("proxy_script_not_found", &e), + }; + + // 启代理子进程 + let proxy_log = logs_dir().join("proxy.log"); + if let Some(parent) = proxy_log.parent() { + if let Err(e) = fs::create_dir_all(parent) { + return CliEnvelope::err("proxy_log_error", &format!("创建代理日志目录失败:{e}")); + } + } + let log_file = match fs::OpenOptions::new() + .create(true) + .append(true) + .open(&proxy_log) + { + Ok(file) => file, + Err(e) => return CliEnvelope::err("proxy_log_error", &format!("打开 proxy.log 失败:{e}")), + }; + let log_file_for_stderr = match log_file.try_clone() { + Ok(file) => file, + Err(e) => { + return CliEnvelope::err("proxy_log_error", &format!("复制 proxy.log 句柄失败:{e}")) + } + }; + + let mut cmd = Command::new(&python); + cmd.arg(&script) + .arg("--provider") + .arg(&launch.adapter) + .arg("--port") + .arg(port.to_string()) + .arg("--auth-token") + .arg(secret) + .env(launch.key_env, &launch.key) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_for_stderr)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + if !is_native_adapter(&launch.adapter) { + if is_openai_adapter(&launch.adapter) { + cmd.env("CSSWITCH_OPENAI_BASE_URL", &launch.base_url); + if !launch.model.is_empty() { + cmd.env("CSSWITCH_OPENAI_MODEL", &launch.model); + } + } else { + cmd.env("CSSWITCH_RELAY_BASE_URL", &launch.base_url); + if !launch.model.is_empty() { + cmd.env("CSSWITCH_RELAY_MODEL", &launch.model); + } + if !launch.thinking_policy.is_empty() { + cmd.env("CSSWITCH_RELAY_THINKING", launch.thinking_policy); + } + } + } + + match cmd.spawn() { + Ok(mut child) => { + let pid = child.id(); + for _ in 0..20 { + if proxy_health(port, secret) { + let _ = save_proxy_secret(secret); + super::proc_manager::record_proxy_start(pid, port, secret); + super::logger::info(&format!("proxy started pid={pid} port={port}")); + return CliEnvelope::ok(json!({ + "port": port, + "pid": pid, + "message": "代理已启动", + })); + } + match child.try_wait() { + Ok(Some(status)) => { + let detail = fs::read_to_string(&proxy_log) + .ok() + .and_then(|content| { + let tail = content + .lines() + .rev() + .take(20) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + (!tail.trim().is_empty()).then_some(tail) + }) + .unwrap_or_else(|| format!("代理进程退出码 {:?}", status.code())); + return CliEnvelope::err_with_hint( + "proxy_start_failed", + &format!("代理启动后立即退出:{detail}"), + "请查看 helper 的 proxy.log,确认 Python、端口和 provider 配置是否正常。", + ); + } + Ok(None) => {} + Err(e) => { + return CliEnvelope::err( + "proxy_start_failed", + &format!("检查代理进程状态失败:{e}"), + ) + } + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let _ = terminate_pid(pid); + CliEnvelope::err_with_hint( + "proxy_start_timeout", + &format!("代理启动后未通过健康检查,端口 {port} 未就绪。"), + "请查看 helper 的 proxy.log,确认代理是否能监听端口。", + ) + } + Err(e) => { + let hint = if e.to_string().contains("AddrInUse") + || e.to_string().contains("address in use") + { + format!("端口 {port} 已被占用。请更改端口或停止占用程序。") + } else { + format!("启动代理失败:{e}") + }; + CliEnvelope::err_with_hint("proxy_start_failed", &format!("启动代理失败:{e}"), &hint) + } + } +} + +/// `proxy status` — 返回代理运行状态。 +/// 无状态实现:通过 TCP 端口探活检测代理是否在运行(不依赖内存中的 PID)。 +pub fn cmd_proxy_status() -> CliEnvelope { + // 从配置读取端口(默认 18991),然后 TCP 探活。 + let port = get_configured_port(); + let running = is_port_open(port); + + if running { + // 通过 /health 端点进一步确认是代理服务(使用持久化的随机 secret) + let healthy = load_proxy_secret() + .map(|s| proxy_health(port, &s)) + .unwrap_or(false); + CliEnvelope::ok(json!({ + "running": true, + "port": port, + "healthy": healthy, + })) + } else { + CliEnvelope::ok(json!({ + "running": false, + "healthy": false, + "message": "代理未在运行。请使用 `proxy start` 启动。", + })) + } +} + +/// `proxy stop` — 停止代理进程。 +/// 无状态实现:通过 `fuser` / `lsof` 找到占用端口的进程并 kill。 +pub fn cmd_proxy_stop() -> CliEnvelope { + let port = get_configured_port(); + let stopped_recorded = stop_recorded_proxy(port); + + // 先检查端口是否有进程 + if !is_port_open(port) { + return CliEnvelope::ok(json!({ + "message": if stopped_recorded { "已停止记录中的代理进程。" } else { "端口上没有运行中的代理。" }, + "port": port, + "stopped": true, + })); + } + + let stopped = clear_unhealthy_proxy_port(port) || stopped_recorded; + if stopped { + super::proc_manager::record_proxy_stop(); + super::logger::info(&format!("proxy stopped on port {port}")); + } else { + return CliEnvelope::err_with_hint( + "proxy_stop_failed", + &format!("端口 {port} 可能未被完全停止。"), + "请手动检查该端口上的进程,确认旧代理已停止后再重试。", + ); + } + CliEnvelope::ok(json!({ + "message": format!("端口 {port} 上的代理已停止"), + "port": port, + "stopped": stopped, + })) +} + +// ============================================================================ +// 内部工具函数 +// ============================================================================ + +fn pid_running(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn wait_pid_exit(pid: u32) -> bool { + for _ in 0..10 { + if !pid_running(pid) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + false +} + +fn terminate_pid(pid: u32) -> bool { + if !pid_running(pid) { + return true; + } + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .output(); + if wait_pid_exit(pid) { + return true; + } + let _ = Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .output(); + wait_pid_exit(pid) +} + +fn pid_looks_like_recorded_proxy(pid: u32) -> bool { + fs::read_to_string(format!("/proc/{pid}/cmdline")) + .map(|cmdline| cmdline.contains("csswitch_proxy.py") || cmdline.contains("csswitch_proxy")) + .unwrap_or(false) +} + +fn stop_recorded_proxy(port: u16) -> bool { + let manager = super::proc_manager::ProcessManager::new("proxy"); + let Some(record) = manager.read_pid() else { + return false; + }; + if record.port != port && !record.command.contains("csswitch_proxy") { + return false; + } + if !pid_looks_like_recorded_proxy(record.pid) { + manager.cleanup(); + return false; + } + let stopped = terminate_pid(record.pid); + if stopped { + manager.cleanup(); + } + stopped +} + +fn clear_unhealthy_proxy_port(port: u16) -> bool { + let _term = Command::new("fuser") + .args(["-TERM", &format!("{port}/tcp")]) + .output(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + if is_port_open(port) { + let _kill = Command::new("fuser") + .args(["-k", &format!("{port}/tcp")]) + .output(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + if is_port_open(port) { + let _ = Command::new("sh") + .arg("-c") + .arg(format!("lsof -ti:{port} | xargs -r kill 2>/dev/null; true")) + .output(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + !is_port_open(port) +} + +/// 获取持久化 proxy secret 的文件路径。 +fn secret_file() -> PathBuf { + config_dir().join("proxy.secret") +} + +/// 从 `~/.csswitch/proxy.secret` 加载上次代理启动时保存的 secret。 +fn load_proxy_secret() -> Result { + let p = secret_file(); + if p.exists() { + std::fs::read_to_string(&p) + .map(|s| s.trim().to_string()) + .map_err(|e| format!("读 secret 文件失败:{e}")) + } else { + Err("secret 文件不存在".to_string()) + } +} + +/// 将代理 secret 持久化到文件以便后续 `proxy status` 检测健康状态。 +/// 审核 P0-1 修复:不再硬编码弱 secret,每次启动由调用方传入随机生成的 secret。 +fn save_proxy_secret(secret: &str) -> Result<(), String> { + let _ = std::fs::create_dir_all(&config_dir()); + std::fs::write(secret_file(), secret).map_err(|e| format!("写 secret 文件失败:{e}")) +} + +/// 从配置文件读取代理端口,无配置时返回默认值 18991。 +fn get_configured_u16(key: &str, default: u16) -> u16 { + let cfg = config_path(); + if cfg.exists() { + if let Ok(raw) = std::fs::read_to_string(&cfg) { + if let Ok(v) = serde_json::from_str::(&raw) { + if let Some(value) = v[key].as_u64() { + return value as u16; + } + } + } + } + default +} + +/// 从配置文件读取代理端口,无配置时返回默认值 18991。 +fn get_configured_port() -> u16 { + get_configured_u16("proxy_port", 18991) +} + +/// 从配置文件读取沙箱端口,无配置时返回默认值 8990。 +fn get_configured_sandbox_port() -> u16 { + get_configured_u16("sandbox_port", 8990) +} + +/// 检查 TCP 端口是否有进程在监听。 +fn is_port_open(port: u16) -> bool { + use std::net::TcpStream; + TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + std::time::Duration::from_millis(300), + ) + .is_ok() +} + +fn sandbox_process_found_for(data_dir: &Path) -> bool { + let pattern = data_dir.to_string_lossy().to_string(); + Command::new("pgrep") + .args(["-f", &pattern]) + .output() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false) +} + +fn sandbox_process_found() -> bool { + let (_, data_dir) = sandbox_paths(); + sandbox_process_found_for(&data_dir) +} + +fn sandbox_paths() -> (PathBuf, PathBuf) { + let sandbox_home = config_dir().join("sandbox").join("home"); + let data_dir = sandbox_home.join(".claude-science"); + (sandbox_home, data_dir) +} + +fn command_output_error(command: &str, out: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if !stderr.is_empty() { + return stderr; + } + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + return stdout; + } + format!("{command} 退出码 {:?}", out.status.code()) +} + +fn tail_file(path: &Path, max_chars: usize) -> String { + let mut content = String::new(); + if let Ok(mut file) = fs::File::open(path) { + let _ = file.read_to_string(&mut content); + } + if content.chars().count() <= max_chars { + return content.trim().to_string(); + } + let mut tail = content.chars().rev().take(max_chars).collect::>(); + tail.reverse(); + tail.into_iter().collect::().trim().to_string() +} + +fn matching_sandbox_pids(data_dir: &Path) -> Vec { + let pattern = data_dir.to_string_lossy().to_string(); + let Ok(out) = Command::new("pgrep").args(["-f", &pattern]).output() else { + return Vec::new(); + }; + let current_pid = std::process::id(); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .filter(|pid| *pid != current_pid) + .filter(|pid| { + fs::read(format!("/proc/{pid}/cmdline")) + .map(|raw| { + let cmdline = String::from_utf8_lossy(&raw).replace('\0', " "); + cmdline.contains("claude-science") + && cmdline.contains("serve") + && cmdline.contains(pattern.as_str()) + }) + .unwrap_or(false) + }) + .collect() +} + +fn wait_sandbox_pids_exit(data_dir: &Path, attempts: usize) -> bool { + for _ in 0..attempts { + if matching_sandbox_pids(data_dir).is_empty() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + matching_sandbox_pids(data_dir).is_empty() +} + +fn terminate_sandbox_processes(data_dir: &Path) -> bool { + let pids = matching_sandbox_pids(data_dir); + if pids.is_empty() { + return false; + } + for pid in &pids { + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .output(); + } + if wait_sandbox_pids_exit(data_dir, 12) { + return true; + } + for pid in matching_sandbox_pids(data_dir) { + let _ = Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .output(); + } + wait_sandbox_pids_exit(data_dir, 8) +} + +fn sandbox_daemon_running(bin: &str, sandbox_home: &Path, data_dir: &Path) -> Result { + match Command::new(bin) + .args(["status", "--data-dir"]) + .arg(data_dir) + .env("HOME", sandbox_home) + .output() + { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + serde_json::from_str::(&stdout) + .map(|v| v.get("running").and_then(|r| r.as_bool()).unwrap_or(false)) + .map_err(|e| { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + format!("claude-science status 返回空输出:{e}") + } else { + format!("claude-science status 返回无法解析的输出:{trimmed}") + } + }) + } + Ok(out) => Err(command_output_error("claude-science status", &out)), + Err(e) => Err(format!("无法执行 claude-science status:{e}")), + } +} + +fn wait_for_sandbox_ready( + bin: &str, + sandbox_home: &Path, + data_dir: &Path, + port: u16, + log_path: &Path, +) -> Result { + let url = sandbox_fresh_url(bin, sandbox_home, data_dir)?; + for _ in 0..60 { + if crate::proc::http_health(port, None, 400) { + return Ok(url); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + let tail = tail_file(log_path, 2000); + if tail.is_empty() { + Err(format!( + "已拿到 Science 登录链接,但端口 {port} 的 /health 一直未就绪" + )) + } else { + Err(format!( + "已拿到 Science 登录链接,但端口 {port} 的 /health 一直未就绪\n--- sandbox.log ---\n{tail}" + )) + } +} + +fn sandbox_is_running() -> bool { + let port = get_configured_sandbox_port(); + let (_, data_dir) = sandbox_paths(); + crate::proc::http_health(port, None, 400) && sandbox_process_found_for(&data_dir) +} + +/// `sandbox status` — 检查 Claude Science 沙箱是否在运行。 +/// 通过轮询 `claude-science status` 和端口探活双重确认。 +pub fn cmd_sandbox_status() -> CliEnvelope { + let port = get_configured_sandbox_port(); + let port_healthy = crate::proc::http_health(port, None, 400); + let process_found = sandbox_process_found(); + let (sandbox_home, data_dir) = sandbox_paths(); + let (daemon_running, status_error) = match find_cmd("claude-science") { + Some(bin) => match sandbox_daemon_running(&bin, &sandbox_home, &data_dir) { + Ok(running) => (running, None), + Err(e) => (false, Some(e)), + }, + None => (false, Some("未找到 claude-science 命令".to_string())), + }; + let running = port_healthy && process_found; + + if running { + CliEnvelope::ok(json!({ + "running": true, + "port": port, + "daemon_running": daemon_running, + "port_healthy": port_healthy, + "process_found": process_found, + "message": format!("Science 沙箱正在端口 {} 上运行", port), + })) + } else { + CliEnvelope::ok(json!({ + "running": false, + "port": port, + "daemon_running": daemon_running, + "port_healthy": port_healthy, + "process_found": process_found, + "status_error": status_error, + "message": "沙箱未运行。请使用 `claude-science serve --port ` 或在客户端配置后通过一键开始启动。", + })) + } +} + +/// `sandbox start ` — 启动 Claude Science 沙箱。 +/// 用 `ANTHROPIC_BASE_URL` 环境变量指向代理,以独立 data-dir 运行。 +/// 注入虚拟 OAuth 凭证使 Science 认为已登录,仅监听回环地址,外部访问走 SSH 端口转发。 +pub fn cmd_sandbox_start(port: u16, proxy_url: &str) -> CliEnvelope { + if let Err(err) = validate_managed_port(port) { + return err; + } + + let bin = match find_cmd("claude-science") { + Some(b) => b, + None => { + return CliEnvelope::err_with_hint( + "science_not_found", + "未找到 claude-science 命令", + "请在服务器上安装 Claude Science 并确保其在 PATH 中。", + ) + } + }; + + // 使用独立 data-dir 避免与已有实例冲突 + let (sandbox_home, data_dir) = sandbox_paths(); + + // 确保运行时目录存在 + let _ = std::fs::create_dir_all(&data_dir); + + if is_port_open(port) || sandbox_process_found_for(&data_dir) { + let _ = Command::new(&bin) + .args(["stop", "--data-dir"]) + .arg(&data_dir) + .env("HOME", &sandbox_home) + .output(); + terminate_sandbox_processes(&data_dir); + for _ in 0..20 { + if !is_port_open(port) && !sandbox_process_found_for(&data_dir) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + if is_port_open(port) { + return CliEnvelope::err_with_hint( + "port_in_use", + &format!("端口 {port} 已被占用,无法启动 Science 沙箱。"), + "请先停止占用该端口的进程,或在高级设置里更换沙箱端口。", + ); + } + } + + // 注入虚拟 OAuth 凭证,让 Science 认为已登录(否则启动后会因找不到登录态报错) + if let Err(e) = crate::oauth_forge::ensure_virtual_login( + &data_dir, + "virtual@localhost.invalid", + &sandbox_home, + ) { + super::logger::warn(&format!("OAuth 虚拟登录失败(沙箱启动后可能无凭证): {e}")); + } + + // https_proxy 只保留 host:port(剥掉 /secret 路径)。 + // 对齐 launch-virtual-sandbox.sh:CONNECT 隧道不经过 path 路由, + // 代理的 do_CONNECT 对 Anthropic 域名秒回 403,operon 秒判 logged-out。 + let proxy_hostport = match proxy_url.find("://") { + Some(i) => { + let after = &proxy_url[i + 3..]; + match after.find('/') { + Some(j) => format!("http://{}", &after[..j]), + None => proxy_url.to_string(), + } + } + None => proxy_url.to_string(), + }; + + let sandbox_log = logs_dir().join("sandbox.log"); + if let Some(parent) = sandbox_log.parent() { + if let Err(e) = fs::create_dir_all(parent) { + return CliEnvelope::err("sandbox_log_error", &format!("创建沙箱日志目录失败:{e}")); + } + } + let log_file = match fs::OpenOptions::new() + .create(true) + .append(true) + .open(&sandbox_log) + { + Ok(file) => file, + Err(e) => { + return CliEnvelope::err("sandbox_log_error", &format!("打开 sandbox.log 失败:{e}")) + } + }; + let log_file_for_stderr = match log_file.try_clone() { + Ok(file) => file, + Err(e) => { + return CliEnvelope::err( + "sandbox_log_error", + &format!("复制 sandbox.log 句柄失败:{e}"), + ) + } + }; + + match std::process::Command::new(&bin) + .args(["serve", "--data-dir"]) + .arg(&data_dir) + .arg("--port") + .arg(port.to_string()) + .arg("--host") + .arg("127.0.0.1") + .arg("--no-browser") + .arg("--no-auto-update") + .arg("--detached") + .env("HOME", &sandbox_home) + .env("ANTHROPIC_BASE_URL", proxy_url) + .env("https_proxy", &proxy_hostport) + .env("HTTPS_PROXY", &proxy_hostport) + .env("no_proxy", "127.0.0.1,localhost,::1") + .env("NO_PROXY", "127.0.0.1,localhost,::1") + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_for_stderr)) + .spawn() + { + Ok(_child) => { + match wait_for_sandbox_ready(&bin, &sandbox_home, &data_dir, port, &sandbox_log) { + Ok(url) => CliEnvelope::ok(json!({ + "message": format!("沙箱已启动,端口 {}", port), + "port": port, + "url": url, + })), + Err(e) => { + let _ = Command::new(&bin) + .args(["stop", "--data-dir"]) + .arg(&data_dir) + .env("HOME", &sandbox_home) + .output(); + terminate_sandbox_processes(&data_dir); + CliEnvelope::err_with_hint( + "sandbox_start_timeout", + &format!("沙箱启动后未就绪:{e}"), + "请查看 helper 的 sandbox.log,确认 claude-science 是否启动成功。", + ) + } + } + } + Err(e) => CliEnvelope::err_with_hint( + "sandbox_start_failed", + &format!("启动沙箱失败:{e}"), + &format!("请检查端口 {} 是否被占用。", port), + ), + } +} + +fn sandbox_fresh_url(bin: &str, sandbox_home: &Path, data_dir: &Path) -> Result { + let mut last_error = String::new(); + for _ in 0..20 { + match Command::new(bin) + .args(["url", "--data-dir"]) + .arg(data_dir) + .env("HOME", sandbox_home) + .output() + { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(url) = stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with("http")) + { + return Ok(url.to_string()); + } + last_error = "claude-science url 未返回可用 URL".to_string(); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + last_error = if stderr.is_empty() { + format!("claude-science url 退出码 {:?}", out.status.code()) + } else { + stderr + }; + } + Err(e) => { + last_error = format!("无法执行 claude-science url:{e}"); + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Err(last_error) +} + +/// `sandbox stop` — 停止 Claude Science 沙箱。 +pub fn cmd_sandbox_stop() -> CliEnvelope { + let (sandbox_home, data_dir) = sandbox_paths(); + if !sandbox_is_running() && !sandbox_process_found_for(&data_dir) { + return CliEnvelope::ok(json!({ + "message": "沙箱未运行。", + "stopped": false, + })); + } + + let bin = match find_cmd("claude-science") { + Some(b) => b, + None => return CliEnvelope::err("science_not_found", "未找到 claude-science 命令"), + }; + + match std::process::Command::new(&bin) + .args(["stop", "--data-dir"]) + .arg(&data_dir) + .env("HOME", &sandbox_home) + .output() + { + Ok(out) if out.status.success() => { + terminate_sandbox_processes(&data_dir); + CliEnvelope::ok_empty() + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + if terminate_sandbox_processes(&data_dir) { + CliEnvelope::ok(json!({ + "message": format!("claude-science stop 未能通过控制 socket 停止沙箱,已清理残留进程:{stderr}"), + "stopped": true, + })) + } else { + CliEnvelope::err("sandbox_stop_failed", &format!("停止沙箱失败:{stderr}")) + } + } + Err(e) => { + if terminate_sandbox_processes(&data_dir) { + CliEnvelope::ok(json!({ + "message": format!("无法执行停止命令,已清理残留进程:{e}"), + "stopped": true, + })) + } else { + CliEnvelope::err("sandbox_stop_failed", &format!("无法执行停止命令:{e}")) + } + } + } +} + +/// `logs [lines]` — 返回日志。 +pub fn cmd_logs(name: &str, lines: Option) -> CliEnvelope { + let log_path = logs_dir().join(format!("{name}.log")); + if !log_path.exists() { + return CliEnvelope::ok(json!({"content": "", "exists": false})); + } + match fs::read_to_string(&log_path) { + Ok(content) => { + let lines_count = lines.unwrap_or(100); + let tail: String = content + .lines() + .rev() + .take(lines_count) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + CliEnvelope::ok(json!({"content": tail, "exists": true})) + } + Err(e) => CliEnvelope::err("log_read_error", &format!("无法读取日志:{e}")), + } +} + +/// `doctor` — 诊断命令。 +pub fn cmd_doctor() -> CliEnvelope { + let mut checks: Vec = Vec::new(); + + // 检查 python3 + let python = find_cmd("python3").or_else(|| find_cmd("python")); + checks.push(json!({ + "name": "Python 3", + "ok": python.is_some(), + "detail": python.as_deref().unwrap_or("未找到"), + })); + + // 检查代理脚本 + let script = proxy_script_path(); + checks.push(json!({ + "name": "代理脚本 csswitch_proxy.py", + "ok": script.is_ok(), + "detail": script.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|e| e.clone()), + })); + + // 检查配置目录 + let cfg = config_path(); + checks.push(json!({ + "name": "配置文件 config.json", + "ok": cfg.exists(), + "detail": cfg.display().to_string(), + })); + + // 检查代理运行状态(通过端口探活) + let port = get_configured_port(); + let proxy_running = is_port_open(port); + checks.push(json!({ + "name": "代理运行状态", + "ok": proxy_running, + "detail": if proxy_running { format!("端口 {}", port) } else { "未运行".to_string() }, + })); + + CliEnvelope::ok(json!({"checks": checks})) +} + +/// `verify ` — 通过代理发送最小请求验证 key 有效性。 +pub fn cmd_verify(port: u16, secret: &str) -> CliEnvelope { + use std::io::{Read, Write}; + use std::net::TcpStream; + + let addr = format!("127.0.0.1:{port}"); + let Ok(mut stream) = + TcpStream::connect_timeout(&addr.parse().unwrap(), std::time::Duration::from_secs(5)) + else { + return CliEnvelope::err("proxy_not_reachable", &format!("无法连接到代理端口 {port}")); + }; + + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); + let body = json!({ + "model": "claude-opus-4-8", + "max_tokens": 1, + "messages": [{"role": "user", "content": "ping"}] + }); + let body_str = serde_json::to_string(&body).unwrap(); + let req = format!( + "POST /{secret}/v1/messages HTTP/1.0\r\n\ + Host: 127.0.0.1\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n{body_str}", + body_str.len() + ); + + if stream.write_all(req.as_bytes()).is_err() { + return CliEnvelope::err("proxy_io_error", "发送验证请求失败"); + } + + let mut buf = vec![0u8; 4096]; + let Ok(n) = stream.read(&mut buf) else { + return CliEnvelope::err("proxy_no_response", "代理未响应验证请求"); + }; + + let head = String::from_utf8_lossy(&buf[..n]); + let status_line = head.lines().next().unwrap_or(""); + let code = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()); + + match code { + Some(200) => CliEnvelope::ok(json!({"ok": true, "hint": "key 有效,上游已接受。"})), + Some(c @ (401 | 403)) => CliEnvelope::ok( + json!({"ok": false, "hint": format!("上游拒绝({c}),key 可能无效或无权限。")}), + ), + Some(c) => CliEnvelope::ok( + json!({"ok": false, "hint": format!("上游返回 {c},可能是 key 无效或上游异常。")}), + ), + None => CliEnvelope::err("proxy_invalid_response", "代理返回了无效的 HTTP 响应"), + } +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/// 简易 which:在 PATH 中查找可执行文件。 +fn find_cmd(name: &str) -> Option { + let mut dirs_to_check: Vec = Vec::new(); + if let Ok(path) = std::env::var("PATH") { + for dir in path.split(':') { + if !dir.is_empty() { + dirs_to_check.push(PathBuf::from(dir)); + } + } + } + if let Some(home) = dirs::home_dir() { + dirs_to_check.push(home.join(".local").join("bin")); + dirs_to_check.push(home.join("bin")); + dirs_to_check.push(home.join("miniconda3").join("bin")); + dirs_to_check.push(home.join("anaconda3").join("bin")); + } + dirs_to_check.push(PathBuf::from("/opt/conda/bin")); + + for dir in dirs_to_check { + let full = dir.join(name); + if full.is_file() { + return Some(full.display().to_string()); + } + } + None +} diff --git a/desktop/src-tauri/src/cli/logger.rs b/desktop/src-tauri/src/cli/logger.rs new file mode 100644 index 0000000..dcdc1ac --- /dev/null +++ b/desktop/src-tauri/src/cli/logger.rs @@ -0,0 +1,141 @@ +//! Helper 自身操作日志(记录命令执行、进程启停等操作审计信息)。 +//! +//! Plan V2 §3.7 实现。日志写入 `~/.csswitch/logs/helper.log`。 +//! 格式:`[ISO8601] [LEVEL] message` + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 日志级别。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + /// 正常操作记录。 + Info, + /// 可恢复的异常。 + Warn, + /// 失败操作。 + Error, +} + +impl LogLevel { + fn as_str(&self) -> &'static str { + match self { + LogLevel::Info => "INFO", + LogLevel::Warn => "WARN", + LogLevel::Error => "ERROR", + } + } +} + +/// Helper 操作日志器(全局单例,通过 Mutex 保护)。 +static LOGGER: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(None)); + +struct HelperLogger { + file: File, +} + +/// 获取日志文件路径:`~/.csswitch/logs/helper.log`。 +fn log_path() -> PathBuf { + let dir = super::commands::config_dir().join("logs"); + dir.join("helper.log") +} + +/// 初始化日志系统(创建目录和文件)。 +pub fn init() -> Result<(), String> { + let path = log_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建日志目录失败:{e}"))?; + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| format!("打开日志文件失败:{e}"))?; + let mut logger = LOGGER.lock().unwrap(); + *logger = Some(HelperLogger { file }); + Ok(()) +} + +/// 写一条日志记录。 +/// 格式:`[2026-07-04T15:30:00Z] [INFO] 消息内容` +pub fn log(level: LogLevel, msg: &str) { + // 获取当前 UTC 时间 + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + // 简易 ISO8601 格式(不使用 chrono 以保持零依赖) + let days_since_epoch = now / 86400; + let time_of_day = now % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Howard Hinnant civil-from-days 算法(与 oauth_forge.rs 中一致) + let z = (days_since_epoch as i64) + 719468; + let era = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + + let line = format!( + "[{year:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z] [{}] {msg}\n", + level.as_str() + ); + + if let Ok(mut logger) = LOGGER.lock() { + if let Some(ref mut l) = *logger { + let _ = l.file.write_all(line.as_bytes()); + let _ = l.file.flush(); + } + } +} + +/// 便捷函数:记录 Info 级别日志。 +pub fn info(msg: &str) { + log(LogLevel::Info, msg); +} + +/// 便捷函数:记录 Warn 级别日志。 +pub fn warn(msg: &str) { + log(LogLevel::Warn, msg); +} + +/// 便捷函数:记录 Error 级别日志。 +pub fn error(msg: &str) { + log(LogLevel::Error, msg); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_level_as_str() { + assert_eq!(LogLevel::Info.as_str(), "INFO"); + assert_eq!(LogLevel::Warn.as_str(), "WARN"); + assert_eq!(LogLevel::Error.as_str(), "ERROR"); + } + + #[test] + fn test_init_creates_log_file() { + // 初始化日志(在生产环境中由 main() 调用) + match init() { + Ok(()) => { + info("test: logger initialized"); + } + Err(_) => { + // 测试环境可能无权限,静默跳过 + } + } + } +} diff --git a/desktop/src-tauri/src/cli/mod.rs b/desktop/src-tauri/src/cli/mod.rs new file mode 100644 index 0000000..7e6f540 --- /dev/null +++ b/desktop/src-tauri/src/cli/mod.rs @@ -0,0 +1,106 @@ +//! Helper CLI 的命令路由与分发。 +//! +//! `dispatch()` 函数解析命令行参数并路由到 `commands` 模块的对应实现。 +//! 模式匹配风格参考 cc-switch-remote 的 `cli/mod.rs`。 + +pub mod commands; +pub mod logger; +pub mod proc_manager; +pub mod serve; +pub mod types; + +use types::CliEnvelope; + +/// 根据参数列表分发命令。格式:`[group] [action] [args...]` +pub fn dispatch(args: &[String]) -> CliEnvelope { + let group = args.first().map(|s| s.as_str()).unwrap_or(""); + let action = args.get(1).map(|s| s.as_str()).unwrap_or(""); + let rest = args.get(2..).unwrap_or(&[]); + + match (group, action) { + // ---- 状态 ---- + ("status", _) => commands::cmd_status(), + + // ---- 配置 ---- + ("config", "get") => commands::cmd_config_get(), + ("config", "set") => { + if let Some(json_str) = rest.first() { + commands::cmd_config_set(json_str) + } else { + CliEnvelope::err("missing_argument", "config set 需要 JSON 参数") + } + } + ("config", "save-key") => { + if rest.len() >= 2 { + commands::cmd_config_save_key(&rest[0], &rest[1]) + } else { + CliEnvelope::err( + "missing_argument", + "config save-key 需要 参数", + ) + } + } + + // ---- 代理 ---- + ("proxy", "start") => { + if rest.len() >= 3 { + let port: u16 = match rest[1].parse() { + Ok(p) => p, + Err(_) => return CliEnvelope::err("invalid_port", "端口号无效"), + }; + commands::cmd_proxy_start(&rest[0], port, &rest[2]) + } else { + CliEnvelope::err( + "missing_argument", + "proxy start 需要 参数", + ) + } + } + ("proxy", "stop") => commands::cmd_proxy_stop(), + ("proxy", "status") => commands::cmd_proxy_status(), + + // ---- 沙箱 ---- + // ---- 沙箱 ---- + ("sandbox", "status") => commands::cmd_sandbox_status(), + ("sandbox", "start") => { + if rest.len() >= 2 { + let port: u16 = match rest[0].parse() { + Ok(p) => p, + Err(_) => return CliEnvelope::err("invalid_port", "端口号无效"), + }; + commands::cmd_sandbox_start(port, &rest[1]) + } else { + CliEnvelope::err( + "missing_argument", + "sandbox start 需要 参数", + ) + } + } + ("sandbox", "stop") => commands::cmd_sandbox_stop(), + + // ---- 日志 ---- + ("logs", name) => { + let lines: Option = rest.first().and_then(|s| s.parse().ok()); + commands::cmd_logs(name, lines) + } + + // ---- 诊断 ---- + ("doctor", _) => commands::cmd_doctor(), + + // ---- Key 验证 ---- + ("verify", _) => { + if rest.len() >= 2 { + let port: u16 = match rest[0].parse() { + Ok(p) => p, + Err(_) => return CliEnvelope::err("invalid_port", "端口号无效"), + }; + commands::cmd_verify(port, &rest[1]) + } else { + CliEnvelope::err("missing_argument", "verify 需要 参数") + } + } + + // ---- 未知命令 ---- + _ => CliEnvelope::err("unknown_command", &format!("未知命令:{group} {action}")), + } +} diff --git a/desktop/src-tauri/src/cli/proc_manager.rs b/desktop/src-tauri/src/cli/proc_manager.rs new file mode 100644 index 0000000..2b56c38 --- /dev/null +++ b/desktop/src-tauri/src/cli/proc_manager.rs @@ -0,0 +1,222 @@ +//! 代理与沙箱进程生命周期管理(PID 文件、状态检测、日志轮转)。 +//! +//! Plan V2 §3.5 实现。通过 PID 文件跟踪进程状态,避免内存状态在 CLI 调用间丢失。 + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// PID 文件存储的进程信息(JSON 格式)。 +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ProcessRecord { + /// 进程 PID。 + pub pid: u32, + /// 启动时间戳(Unix 秒)。 + pub started_at: i64, + /// 启动命令(仅用于诊断)。 + pub command: String, + /// 绑定的端口。 + pub port: u16, + /// 代理鉴权 secret(用于 health check)。 + pub secret: Option, +} + +/// 进程运行状态。 +#[derive(Debug)] +pub enum ProcessStatus { + /// 进程正在运行。 + Running(u32), + /// 进程已停止。 + Stopped, + /// 无法确定(PID 文件存在但进程不可达)。 + Unknown, +} + +/// 进程管理器,封装 PID 文件读写、进程探活和日志轮转。 +pub struct ProcessManager { + /// PID 文件路径(如 `~/.csswitch/proxy.pid`)。 + pid_file: PathBuf, +} + +impl ProcessManager { + /// 创建指定名称的进程管理器(name 为 "proxy" 或 "sandbox")。 + pub fn new(name: &str) -> Self { + let pid_file = super::commands::config_dir().join(format!("{name}.pid")); + Self { pid_file } + } + + /// 写入 PID 文件记录进程信息。 + pub fn write_pid(&self, pid: u32, port: u16, command: &str, secret: Option<&str>) { + let _ = fs::create_dir_all(self.pid_file.parent().unwrap()); + let record = ProcessRecord { + pid, + started_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + command: command.to_string(), + port, + secret: secret.map(|s| s.to_string()), + }; + if let Ok(json) = serde_json::to_string_pretty(&record) { + let _ = fs::write(&self.pid_file, json); + } + } + + /// 读取 PID 文件。 + pub fn read_pid(&self) -> Option { + fs::read_to_string(&self.pid_file) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + } + + /// 获取进程状态(通过 `kill -0` 探活)。 + #[cfg(unix)] + pub fn status(&self) -> ProcessStatus { + match self.read_pid() { + Some(record) => { + let exists = Command::new("kill") + .args(["-0", &record.pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if exists { + // 进一步验证 PID 匹配(避免 PID 复用导致的误判) + if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", record.pid)) + { + // cmdline 用 \0 分隔,取第一个 token 作为命令名 + let cmd_name = cmdline.split('\0').next().unwrap_or(""); + if cmd_name.contains("python") || cmd_name.contains("claude-science") { + return ProcessStatus::Running(record.pid); + } + } + ProcessStatus::Running(record.pid) + } else { + // 进程不存在 → 清理过期 PID 文件 + let _ = fs::remove_file(&self.pid_file); + ProcessStatus::Stopped + } + } + None => ProcessStatus::Stopped, + } + } + + /// 非 Unix 平台的进程状态(简化版,仅检查 PID 文件)。 + #[cfg(not(unix))] + pub fn status(&self) -> ProcessStatus { + if self.pid_file.exists() { + ProcessStatus::Unknown + } else { + ProcessStatus::Stopped + } + } + + /// 清理 PID 文件和僵尸 PID。 + pub fn cleanup(&self) { + // 先检查当前 PID 是否还在运行 + match self.status() { + ProcessStatus::Running(_) => {} // 仍在运行,保留 PID 文件 + ProcessStatus::Stopped | ProcessStatus::Unknown => { + let _ = fs::remove_file(&self.pid_file); + } + } + } + + /// 获取关联的日志文件路径。 + pub fn log_path(&self) -> PathBuf { + let name = self + .pid_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + super::commands::logs_dir().join(format!("{name}.log")) + } + + /// 读取日志最近 N 行。 + pub fn tail_logs(&self, lines: usize) -> Vec { + let path = self.log_path(); + match fs::read_to_string(&path) { + Ok(content) => { + let all: Vec<&str> = content.lines().collect(); + let start = all.len().saturating_sub(lines); + all[start..].iter().map(|s| s.to_string()).collect() + } + Err(_) => vec![], + } + } + + /// 对日志进行轮转:超过 max_bytes 时将原文件重命名为 .log.1,保留最近 3 个。 + pub fn rotate_logs(&self, max_bytes: u64) { + let path = self.log_path(); + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > max_bytes { + // 轮转 3 个备份 + let _ = fs::remove_file(path.with_extension("log.3")); + for i in (1..=2).rev() { + let src = path.with_extension(format!("log.{i}")); + let dst = path.with_extension(format!("log.{}", i + 1)); + if src.exists() { + let _ = fs::rename(&src, &dst); + } + } + let _ = fs::rename(&path, path.with_extension("log.1")); + } + } + } +} + +/// 便捷函数:为代理进程生成 PID 文件记录。 +pub fn record_proxy_start(pid: u32, port: u16, secret: &str) { + let pm = ProcessManager::new("proxy"); + pm.write_pid(pid, port, "python3 csswitch_proxy.py", Some(secret)); +} + +/// 便捷函数:清理代理 PID 文件。 +pub fn record_proxy_stop() { + let pm = ProcessManager::new("proxy"); + pm.cleanup(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmp_dir() -> PathBuf { + let d = std::env::temp_dir().join(format!("csswitch-proc-test-{}", std::process::id())); + let _ = fs::create_dir_all(&d); + d + } + + #[test] + fn test_write_and_read_pid() { + let dir = tmp_dir(); + // 我们不能直接注入 pid 文件路径,但可以测试 record serde + let record = ProcessRecord { + pid: 12345, + started_at: 1700000000, + command: "test".to_string(), + port: 18991, + secret: Some("abc123".to_string()), + }; + let json = serde_json::to_string(&record).unwrap(); + let back: ProcessRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(back.pid, 12345); + assert_eq!(back.port, 18991); + assert_eq!(back.secret.unwrap(), "abc123"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_log_rotation_logic() { + let dir = tmp_dir(); + // 创建模拟 log 文件并测试轮转 + let pm = ProcessManager::new("proxy"); + // 不能直接测试实际路径,验证函数不 panic 即可 + pm.rotate_logs(10); + pm.cleanup(); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/desktop/src-tauri/src/cli/serve.rs b/desktop/src-tauri/src/cli/serve.rs new file mode 100644 index 0000000..d4318c3 --- /dev/null +++ b/desktop/src-tauri/src/cli/serve.rs @@ -0,0 +1,72 @@ +//! Helper 的持久 JSON-line 会话模式。 +//! +//! 从 stdin 逐行读取 JSON 请求、执行命令、向 stdout 逐行写回 JSON 响应。 +//! 协议:每行一个 JSON `{"id":"...","command":[...]}` → `{"id":"...","ok":true,"data":...}`。 +//! +//! 此模式避免每次操作都重新建立 SSH 连接,适用于频繁操作的场景。 + +use std::io::{self, BufRead, Write}; + +use super::types::{CliServeRequest, CliServeResponse}; + +/// 以 JSON-line 协议在 stdin/stdout 上循环服务,直到 stdin EOF。 +pub fn run_stdio() { + let stdin = io::stdin(); + let mut stdout = io::stdout().lock(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, // I/O 错误,退出 + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // 解析请求 + let request: CliServeRequest = match serde_json::from_str(trimmed) { + Ok(req) => req, + Err(e) => { + // 无法解析请求时返回错误但不退出 + let resp = CliServeResponse { + id: "unknown".to_string(), + ok: false, + data: None, + error: Some(super::types::CliError { + code: "parse_error".to_string(), + message: format!("无法解析请求 JSON:{e}"), + details: None, + suggestion: None, + }), + }; + let _ = serde_json::to_writer(&mut stdout, &resp); + let _ = writeln!(stdout); + let _ = stdout.flush(); + continue; + } + }; + + // 执行命令 + let result = super::dispatch(&request.command); + + // 构建响应 + let response = CliServeResponse { + id: request.id, + ok: result.ok, + data: result.data, + error: result.error, + }; + + // 写回响应 + if serde_json::to_writer(&mut stdout, &response).is_err() { + break; + } + if writeln!(stdout).is_err() { + break; + } + if stdout.flush().is_err() { + break; + } + } +} diff --git a/desktop/src-tauri/src/cli/types.rs b/desktop/src-tauri/src/cli/types.rs new file mode 100644 index 0000000..203e66f --- /dev/null +++ b/desktop/src-tauri/src/cli/types.rs @@ -0,0 +1,98 @@ +//! Helper CLI 的类型定义。 +//! +//! 这是 csswitch-helper 的命令响应信封,与桌面端 `remote/types.rs` 中的 +//! `RemoteRequest`/`RemoteResponse` 结构保持一致。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 单次命令的 JSON 响应信封。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliEnvelope { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// serve 模式下的请求行格式。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliServeRequest { + pub id: String, + pub command: Vec, +} + +/// serve 模式下的响应行格式。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliServeResponse { + pub id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// 错误信息。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +impl CliEnvelope { + /// 成功响应。 + pub fn ok(data: Value) -> Self { + Self { + ok: true, + data: Some(data), + error: None, + } + } + + /// 无数据的成功响应(如 stop、delete 等)。 + pub fn ok_empty() -> Self { + Self { + ok: true, + data: None, + error: None, + } + } + + /// 错误响应。 + pub fn err(code: &str, message: &str) -> Self { + Self { + ok: false, + data: None, + error: Some(CliError { + code: code.to_string(), + message: message.to_string(), + details: None, + suggestion: None, + }), + } + } + + /// 带建议的错误响应。 + pub fn err_with_hint(code: &str, message: &str, suggestion: &str) -> Self { + Self { + ok: false, + data: None, + error: Some(CliError { + code: code.to_string(), + message: message.to_string(), + details: None, + suggestion: Some(suggestion.to_string()), + }), + } + } +} diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index a5cf4e5..fb607a7 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -15,9 +15,10 @@ use std::fs; use std::io::{self, Write}; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; +use crate::fs_ext::set_file_permissions; + use serde::{Deserialize, Serialize}; pub(crate) fn default_proxy_port() -> u16 { @@ -115,6 +116,14 @@ impl Config { } self.profile_by_id(&self.active_id) } + + pub fn active_profile_mut(&mut self) -> Option<&mut Profile> { + if self.active_id.is_empty() { + return None; + } + let id = self.active_id.clone(); + self.profile_by_id_mut(&id) + } pub fn profile_by_id(&self, id: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.id == id) } @@ -235,12 +244,11 @@ pub fn migrate_v1_to_v2(mut legacy: crate::config_legacy::ConfigV1) -> Config { } } -/// 生产环境配置目录:`$HOME/.csswitch`。 +/// 生产环境配置目录:通过 `dirs` crate 跨平台获取 home 目录下 `.csswitch`。 pub fn default_dir() -> PathBuf { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - home.join(".csswitch") + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".csswitch") } fn config_path(dir: &Path) -> PathBuf { @@ -273,13 +281,14 @@ fn ensure_dir(dir: &Path) -> io::Result<()> { format!("配置目录不是目录:{}", dir.display()), )); } - fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + set_file_permissions(dir, 0o700)?; Ok(()) } // ---------- 备份 ---------- /// 原子拷贝 src → dst(拒符号链接、0600、O_EXCL 临时文件 + rename)。src 不存在 → Err。 fn atomic_copy(src: &Path, dst: &Path) -> io::Result<()> { + use crate::fs_ext::{OpenOptionsExt, PermissionsExt}; assert_not_symlink(dst)?; let data = fs::read(src)?; // src 不存在 → Err(迁移备份据此中止) let tmp = dst.with_extension(format!( @@ -338,7 +347,7 @@ pub fn load_from(dir: &Path) -> io::Result { Err(e) => return Err(e), }; // 存在即复位权限,抵御外部把它改宽。 - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + let _ = set_file_permissions(&path, 0o600); match detect_version(&data)? { VersionKind::TooNew(v) => Err(io::Error::new( io::ErrorKind::InvalidData, @@ -444,11 +453,14 @@ pub fn save_to(dir: &Path, cfg: &Config) -> io::Result<()> { )); // O_CREAT|O_EXCL + 0600:拒绝复用已有临时文件,创建即定权限。 let write_res = (|| -> io::Result<()> { - let mut f = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&tmp)?; + let mut f = { + use crate::fs_ext::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)? + }; f.write_all(&json)?; f.sync_all()?; Ok(()) @@ -462,7 +474,7 @@ pub fn save_to(dir: &Path, cfg: &Config) -> io::Result<()> { let _ = fs::remove_file(&tmp); return Err(e); } - fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + set_file_permissions(&path, 0o600)?; Ok(()) } @@ -495,7 +507,11 @@ pub fn mask(key: &str) -> String { #[cfg(test)] mod tests { use super::*; + // 符号链接创建仅 Unix 平台可用;使用 #[cfg(unix)] 守卫相关测试函数。 + #[cfg(unix)] use std::os::unix::fs::symlink; + // 引入跨平台 PermissionsExt trait 以使用 .mode() 方法。 + use crate::fs_ext::PermissionsExt; fn tmpdir() -> PathBuf { // 每个测试用「进程 id + 线程 id」独立子目录,避免并行测试相互踩。 @@ -506,6 +522,7 @@ mod tests { d } + /// 读取文件权限的 Unix mode 位(仅 Unix 平台有意义,Windows 返回 0)。 fn mode_of(p: &Path) -> u32 { fs::metadata(p).unwrap().permissions().mode() & 0o777 } @@ -763,6 +780,7 @@ mod tests { } // ---------- A5: 备份基础设施 ---------- + #[cfg(unix)] #[test] fn migration_backup_copies_and_is_0600() { let d = tmpdir().join(".csswitch"); @@ -793,6 +811,7 @@ mod tests { "净化后滚动备份应删除,清了的 key 不可从 .bak 恢复" ); } + #[cfg(unix)] #[test] fn backup_rejects_symlinked_target() { let base = tmpdir(); @@ -888,6 +907,8 @@ mod tests { assert_eq!(cfg.proxy_port, 18991); } + /// 测试 save_to 后目录和文件权限正确(仅 Unix)。 + #[cfg(unix)] #[test] fn save_sets_dir_0700_and_file_0600() { let d = tmpdir().join(".csswitch"); @@ -896,16 +917,21 @@ mod tests { assert_eq!(mode_of(&config_path(&d)), 0o600, "file must be 0600"); } + /// load 时把被放宽的权限重新夹回 0600(仅 Unix)。 + #[cfg(unix)] #[test] fn load_resets_widened_perms_to_0600() { let d = tmpdir().join(".csswitch"); save_to(&d, &Config::default()).unwrap(); let p = config_path(&d); - fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap(); + // 先用 set_file_permissions 放宽权限模拟被外部修改的场景。 + set_file_permissions(&p, 0o644).unwrap(); load_from(&d).unwrap(); assert_eq!(mode_of(&p), 0o600, "load must reset perms to 0600"); } + /// 保存到符号链接目标应被拒绝且目标文件零改动(仅 Unix)。 + #[cfg(unix)] #[test] fn save_rejects_symlinked_file_and_leaves_target_untouched() { let base = tmpdir(); @@ -919,6 +945,8 @@ mod tests { assert_eq!(fs::read(&target).unwrap(), b"ORIGINAL"); } + /// 从符号链接文件读取应被拒绝(仅 Unix)。 + #[cfg(unix)] #[test] fn load_rejects_symlinked_file() { let base = tmpdir(); @@ -931,6 +959,8 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } + /// ~/.csswitch 目录本身是符号链接时 load 应拒绝(仅 Unix)。 + #[cfg(unix)] #[test] fn load_rejects_symlinked_dir() { let base = tmpdir(); @@ -943,6 +973,8 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } + /// 确保目录函数应拒绝符号链接目录(仅 Unix)。 + #[cfg(unix)] #[test] fn ensure_dir_rejects_symlinked_dir() { let base = tmpdir(); diff --git a/desktop/src-tauri/src/fs_ext.rs b/desktop/src-tauri/src/fs_ext.rs new file mode 100644 index 0000000..e807914 --- /dev/null +++ b/desktop/src-tauri/src/fs_ext.rs @@ -0,0 +1,119 @@ +//! 跨平台文件权限抽象。 +//! +//! Unix: re-export 标准库的 OpenOptionsExt / PermissionsExt,提供真实的 0600/0700 权限。 +//! Windows: 提供同名 trait 的 no-op 实现,权限操作为空操作。 +//! +//! 所有文件使用 `use crate::fs_ext::...` 替代 `use std::os::unix::fs::...`。 + +// ---------- 平台条件编译 ---------- + +#[cfg(unix)] +mod imp { + use std::fs; + pub use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + pub fn set_file_permissions(path: &std::path::Path, mode: u32) -> std::io::Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + } + + pub fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.is_file() && (metadata.permissions().mode() & 0o111 != 0) + } + + /// 打开(truncate)日志文件,带 O_NOFOLLOW 防护。 + /// macOS/BSD=0x0100,Linux=0x20000。 + pub fn open_log_file(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + const O_NOFOLLOW: i32 = if cfg!(target_os = "linux") { + 0x2_0000 + } else { + 0x0100 + }; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .custom_flags(O_NOFOLLOW) + .open(path) + } +} + +#[cfg(windows)] +mod imp { + use std::fs; + use std::io; + use std::path::Path; + + /// Windows: OpenOptions 没有 mode 概念。 + pub trait OpenOptionsExt { + fn mode(&mut self, _mode: u32) -> &mut Self; + } + impl OpenOptionsExt for fs::OpenOptions { + fn mode(&mut self, _mode: u32) -> &mut Self { + self + } + } + + /// Windows: Permissions 只有只读位,mode 操作无意义。 + /// 此 trait 在其他 crate 模块中被导入使用(config/oauth_forge 等), + /// 在 fs_ext 模块内部未直接调用,因此标记 allow(dead_code)。 + #[allow(dead_code)] + pub trait PermissionsExt { + fn from_mode(_mode: u32) -> fs::Permissions; + fn mode(&self) -> u32; + } + impl PermissionsExt for fs::Permissions { + fn from_mode(mode: u32) -> fs::Permissions { + // Windows: `Permissions` 没有公开构造函数,通过当前目录 metadata 获取默认权限。 + // 跨平台兼容性:此函数的结果在 Windows 上不会被实际使用 + // (`set_file_permissions` on Windows 是 no-op),只需编译通过。 + let mut p = std::fs::metadata(".") + .map(|m| m.permissions()) + .unwrap_or_else(|_| { + // 最终回退:获取 Cargo 工作目录权限 + std::fs::metadata(std::env::current_dir().unwrap_or_default()) + .map(|m| m.permissions()) + .unwrap() + }); + // 没有写权限位 (0o444) → readonly + if mode & 0o222 == 0 { + p.set_readonly(true); + } + p + } + fn mode(&self) -> u32 { + if self.readonly() { + 0o444 + } else { + 0o666 + } + } + } + + pub fn set_file_permissions(_path: &Path, _mode: u32) -> io::Result<()> { + Ok(()) + } + + pub fn is_executable(metadata: &fs::Metadata) -> bool { + // Windows: 检查扩展名是否为 .exe/.bat/.cmd/.ps1(简易判断) + metadata.is_file() + } + + /// Windows: 没有 O_NOFOLLOW,用普通 OpenOptions。 + pub fn open_log_file(path: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + } +} + +// ---------- 公开导出 ---------- + +// PermissionsExt 在 Unix 上被 config/oauth_forge 测试的 .mode() 调用使用, +// 在 Windows 上无外部调用方(仅 trait 定义存在)。标记 allow 以免 unused 警告。 +#[allow(unused_imports)] +pub use imp::{is_executable, open_log_file, set_file_permissions, OpenOptionsExt, PermissionsExt}; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ffd8171..e6f9adf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,12 +1,16 @@ -//! CSSwitch 桌面 app 后端(进程管家)。 +//! CSSwitch 桌面 app 后端(进程管家 + 远程服务器管理)。 //! -//! 职责:管理「翻译代理」与「沙箱 Science」两个子进程的生命周期;读写 +//! 职责:管理「翻译代理」与「沙箱 Science」两个子进程的生命周期(本地 macOS 模式), +//! 或通过 SSH 管理远程 Linux 服务器上的同名服务(远程模式);读写 //! `~/.csswitch/config.json`(多 profile 形态);把第三方 key 以【环境变量】注入代理子进程 //! (绝不进 argv);探活;把沙箱 URL 交系统浏览器打开。已验证的越权/翻译逻辑仍留在 //! Python/Node/shell 里被当作子进程调用,以保住铁律护栏与已验证行为。 //! //! 运行行为由生效 profile 的 `template_id` 经 [`templates`] 注册表派生出 adapter -//! (deepseek | qwen | relay | openai-custom | openai-responses),再传给 python 代理 `--provider`。 +//! (deepseek | qwen | relay),再传给 python 代理 `--provider`。 +//! +//! 跨平台适配:macOS 代码用 `#[cfg(target_os = "macos")]` 守卫;Windows 不支持本地模式 +//! (缺少 Claude Science.app / zsh / pkill 等),本地操作返回明确错误。 //! //! 铁律相关:key 只在内存与 0600 的 config.json;回显前端只给掩码;沙箱端口/目录护栏 //! 由被调脚本负责(对 8765 与真实目录失败关闭);退 app 默认停代理、保留沙箱。 @@ -14,2648 +18,22 @@ mod config; mod config_legacy; mod lifecycle; +// 虚拟 OAuth 伪造器仅 macOS + desktop feature 需要。 +#[cfg(all(target_os = "macos", feature = "desktop"))] mod oauth_forge; mod proc; mod scratch; mod templates; - -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; -use std::time::Duration; - -use serde::Deserialize; -use serde_json::json; -use tauri::{Manager, State}; - -const SCIENCE_BIN: &str = "/Applications/Claude Science.app/Contents/Resources/bin/claude-science"; - -#[derive(Default)] -struct AppState { - proxy: Option, - proxy_port: u16, - secret: String, - /// 当前代理进程所用 adapter 名(deepseek | qwen | relay | openai-custom | openai-responses);用于健康复用判定。 - provider: String, - /// 当前代理进程所用 key 的非加密指纹(仅内存、绝不落盘/打印)。 - /// 换 key/换上游后指纹变化 → 触发重启,避免复用带旧配置的代理。 - key_fp: u64, - sandbox: Option, - sandbox_port: u16, - sandbox_url: Option, -} - -/// key 的非加密指纹(SipHash),只用于判断「配置是否变了」。绝不打印、绝不落盘。 -fn key_fingerprint(s: &str) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - s.hash(&mut h); - h.finish() -} - -// ---------- adapter / profile 运行元信息 ---------- -/// adapter → 该 adapter 期望的 key 环境变量名(python 代理侧 PROVIDERS[...]["key_env"])。 -fn key_env_for_adapter(adapter: &str) -> &'static str { - match adapter { - "deepseek" => "DEEPSEEK_API_KEY", - "qwen" => "DASHSCOPE_API_KEY", - "openai-custom" | "openai-responses" => "CSSWITCH_OPENAI_KEY", - _ => "CSSWITCH_RELAY_KEY", // relay / 兜底 - } -} - -/// 从一条 profile 派生出起代理需要的全部参数(纯函数,便于测试)。 -struct ProxyLaunch { - adapter: String, - base_url: String, - model: String, - key: String, - key_env: &'static str, - thinking_policy: &'static str, -} - -fn proxy_args_for(p: &config::Profile) -> ProxyLaunch { - let adapter = templates::adapter_for(&p.template_id).to_string(); - let key_env = key_env_for_adapter(&adapter); - ProxyLaunch { - adapter, - base_url: p.base_url.clone(), - model: p.model.clone(), - key: p.api_key.clone(), - key_env, - thinking_policy: templates::thinking_policy_for(&p.template_id), - } -} - -fn proxy_fingerprint(p: &config::Profile, launch: &ProxyLaunch) -> u64 { - key_fingerprint(&format!( - "{}\n{}\n{}\n{}\n{}\n{}\n{}", - p.template_id, - p.api_format, - launch.adapter, - launch.base_url, - launch.model, - launch.thinking_policy, - launch.key - )) -} - -/// 本轨支持 anthropic / openai_chat / openai_responses;其余进 schema 但激活拒绝(待轨道 2:Rust 代理)。 -fn assert_format_supported(p: &config::Profile) -> Result<(), String> { - match p.api_format.as_str() { - "anthropic" | "openai_chat" | "openai_responses" => Ok(()), - other => Err(format!( - "api_format `{other}` 暂不支持(待 Rust 代理),请选 anthropic、openai_chat 或 openai_responses。" - )), - } -} - -fn looks_like_anthropic_endpoint(base_url: &str) -> bool { - let u = base_url.trim().trim_end_matches('/').to_ascii_lowercase(); - u.contains("/anthropic") -} - -fn reject_openai_custom_anthropic_base(template_id: &str, base_url: &str) -> Result<(), String> { - if matches!(template_id, "custom-openai" | "custom-openai-responses") - && looks_like_anthropic_endpoint(base_url) - { - Err("这个地址看起来是 Anthropic 兼容端点。请改选「自定义 Anthropic」,或使用 OpenAI 兼容 base root(如 https://api.moonshot.cn/v1)。".to_string()) - } else { - Ok(()) - } -} - -/// deepseek/qwen 走各自固定官方端点(python 侧硬编码);其余 = relay 家族,需带 base_url。 -fn is_native_adapter(adapter: &str) -> bool { - adapter == "deepseek" || adapter == "qwen" -} - -fn is_openai_adapter(adapter: &str) -> bool { - matches!(adapter, "openai-custom" | "openai-responses") -} - -/// 上游主机名(供 status 上游灯做 TCP 可达性探测)。relay 家族从其 base_url 解析。 -fn upstream_host(adapter: &str, base_url: &str) -> String { - match adapter { - "deepseek" => "api.deepseek.com".to_string(), - "qwen" => "dashscope.aliyuncs.com".to_string(), - _ => parse_host(base_url).unwrap_or_default(), - } -} - -/// 从 `http(s)://host[:port]/path` 里抽出 host。解析不出返回 None(不引 url crate)。 -fn parse_host(url: &str) -> Option { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - let host = rest - .split(['/', ':', '?', '#']) - .next() - .unwrap_or("") - .to_string(); - if host.is_empty() { - None - } else { - Some(host) - } -} - -/// 判断模型 id 是否会平铺进 Science 选择器主列表(claude-{opus|sonnet|haiku}-<数字…>)。 -/// 仅用于「获取模型」结果排序(主列表项排前),非鉴权路径。 -fn is_main_list_model(id: &str) -> bool { - for fam in ["claude-opus-", "claude-sonnet-", "claude-haiku-"] { - if let Some(rest) = id.strip_prefix(fam) { - return rest - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false); - } - } - false -} - -// ---------- 路径与日志 ---------- -/// 定位 CSSwitch 仓库根(含 proxy/csswitch_proxy.py)。优先 CSSWITCH_REPO, -/// 否则从可执行文件逐级上溯。找不到返回 None。 -fn repo_root() -> Option { - let marker = Path::new("proxy/csswitch_proxy.py"); - if let Some(r) = std::env::var_os("CSSWITCH_REPO") { - if let Ok(p) = std::fs::canonicalize(PathBuf::from(r)) { - if p.join(marker).is_file() { - return Some(p); - } - } - } - // 只从【可执行文件位置】上溯。刻意不看 current_dir:启动目录可被影响, - // 若据此找到别处的 csswitch_proxy.py,会把带 key 的环境交给来路不明的脚本。 - if let Ok(exe) = std::env::current_exe() { - let mut dir: Option<&Path> = exe.parent(); - while let Some(d) = dir { - if d.join(marker).is_file() { - return Some(d.to_path_buf()); - } - dir = d.parent(); - } - } - None -} - -/// 定位「资源根」(含 proxy/、scripts/)。打包成 .app 后 bundle 进 `Contents/Resources`; -/// 开发态则回退到仓库根。找不到返回 None。 -fn asset_root(app: &tauri::AppHandle) -> Option { - let marker = Path::new("proxy/csswitch_proxy.py"); - if let Ok(res) = app.path().resource_dir() { - if res.join(marker).is_file() { - return Some(res); - } - } - repo_root() -} - -/// 沙箱可写工作目录(独立 HOME):`~/.csswitch/sandbox/home`。 -fn sandbox_home() -> PathBuf { - config::default_dir().join("sandbox").join("home") -} - -fn log_path(name: &str) -> PathBuf { - config::default_dir().join("logs").join(name) -} - -/// `O_NOFOLLOW` 的平台常量(本项目不引 libc)。macOS/BSD=0x0100,Linux=0x20000。 -const fn libc_o_nofollow() -> i32 { - if cfg!(target_os = "linux") { - 0x2_0000 - } else { - 0x0100 - } -} - -/// 打开(truncate)一个子进程日志文件,父目录 0700、文件 0600(防同机其它用户读到 secret 尾巴)。 -fn open_log(name: &str) -> std::io::Result { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let p = log_path(name); - if let Some(parent) = p.parent() { - config::assert_not_symlink(parent)?; - std::fs::create_dir_all(parent)?; - let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); - } - config::assert_not_symlink(&p)?; - let f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .custom_flags(libc_o_nofollow()) - .open(&p)?; - let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)); - Ok(f) -} - -/// 把字符串里的 secret 明文替换成 ****,用于任何要回显给前端的错误尾巴。 -fn redact(s: &str, secret: &str) -> String { - if secret.is_empty() { - s.to_string() - } else { - s.replace(secret, "****") - } -} - -fn tail_file(path: &Path, max: usize) -> String { - match std::fs::read(path) { - Ok(b) => { - let start = b.len().saturating_sub(max); - String::from_utf8_lossy(&b[start..]).trim().to_string() - } - Err(_) => String::new(), - } -} - -fn kill_child(slot: &mut Option) { - if let Some(mut c) = slot.take() { - let _ = c.kill(); - let _ = c.wait(); - } -} - -/// 取锁并从 poison 中恢复:某线程持锁时 panic 不应把整个 app 卡死。 -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, AppState> { - m.lock().unwrap_or_else(|e| e.into_inner()) -} - -/// 用系统浏览器打开 URL(macOS `open`)。校验退出码:非零视为失败(P2c)。 -fn open_in_browser(url: &str) -> Result<(), String> { - let st = Command::new("open") - .arg(url) - .status() - .map_err(|e| format!("打开浏览器失败:{e}"))?; - if !st.success() { - return Err(format!("open 非零退出({:?})", st.code())); - } - Ok(()) -} - -// ---------- 代理生命周期核心 ---------- -/// 转义 ERE 元字符,让路径按字面参与 `pkill -f` 匹配。 -fn ere_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 8); - for c in s.chars() { - if "\\.^$*+?()[]{}|".contains(c) { - out.push('\\'); - } - out.push(c); - } - out -} - -/// 本次 ensure_proxy 对代理做了什么(供一键据实提示)。 -#[derive(Clone, Copy, PartialEq)] -enum ProxyAction { - Reused, // 端口+adapter+key 指纹一致且健康,原样复用 - Restarted, // 首次起 / 换 key / 换 profile / 不健康,重起了代理 -} - -/// 切换事务的提交/回滚决策(纯函数,spec §7)。live 路径难做确定性单测,故把决策抽出单独测。 -#[derive(Debug, PartialEq)] -enum SwitchOutcome { - Commit, // scratch 校验过 + 正式代理探活健康 → 提交 active_id - RollbackToOld, // scratch 过但正式代理起/探活失败 → 杀候选、恢复旧代理、不提交 - AbortBeforeStart, // scratch 校验失败 → 根本没起正式代理、旧态零改动 -} - -/// 给定「候选 scratch 校验结果」与「正式代理探活结果」,决定切换事务走向。 -fn decide_switch(scratch_ok: bool, real_healthy: bool) -> SwitchOutcome { - if !scratch_ok { - return SwitchOutcome::AbortBeforeStart; - } - if real_healthy { - SwitchOutcome::Commit - } else { - SwitchOutcome::RollbackToOld - } -} - -/// 探活结束回锁后是否可写回 `st.proxy`:generation 未被取代【且】secret 仍是本次启动的。 -/// 抽成纯函数便于确定性单测(gen 同/异 × secret 同/异 4 组合)。 -/// secret 合取防「冷启动双起、两个不同 secret、generation 却相等」的窄窗:另起若用不同 secret -/// 重置了槽位,本次就不该拿旧 child 覆盖它(起代理前会把 `st.secret` 预置成本次 secret,故合法启动上恒真)。 -fn should_write_back(gen_captured: u64, gen_now: u64, st_secret: &str, my_secret: &str) -> bool { - gen_captured == gen_now && st_secret == my_secret -} - -/// 确保代理在跑且健康;返回 (端口, secret, 本次动作)。幂等:已健康则复用。 -/// 读【生效 profile】派生 adapter/base_url/model/key,委托 [`start_proxy_for`]。 -fn ensure_proxy( - app: &tauri::AppHandle, - state: &State<'_, Mutex>, - lifecycle: &lifecycle::Lifecycle, -) -> Result<(u16, String, ProxyAction), String> { - let cfg = config::load_from(&config::default_dir()).map_err(|e| e.to_string())?; - let profile = cfg - .active_profile() - .cloned() - .ok_or("未配置生效 profile,请先在面板选择或新建一条配置。")?; - start_proxy_for(app, state, lifecycle, &profile) -} - -/// 探活超时的原因措辞(纯函数,修真机 P2):本地 `/health` 不验上游 key,故探活超时与 key 有效性 -/// 无关。日志出现绑定失败(Address already in use / EADDRINUSE)→ 明确报端口占用;否则报「探活超时」 -/// (多为 python 依赖缺失 / 脚本异常),绝不再含糊说「或 key 无效」。 -fn health_timeout_reason(port: u16, tail: &str) -> String { - let occupied = tail.contains("Address already in use") - || tail.contains("EADDRINUSE") - || tail.contains("Errno 48") // macOS EADDRINUSE - || tail.contains("Errno 98"); // Linux EADDRINUSE - if occupied { - format!("端口 {port} 已被占用,换个端口或先停掉占用进程后重试。") - } else { - format!( - "代理起后探活超时(端口 {port}):多为 python 依赖缺失或代理脚本异常,请查看代理日志。" - ) - } -} - -/// 用【给定 profile】(不读 active)起代理并探活;返回 (端口, secret, 动作)。 -/// -/// 并发正确性(spec §8.1): -/// - **读-spawn 原子**:复用判定 / 清残留 / spawn 都在同一把 AppState 锁内;新 child 先握本地。 -/// - **探活锁外**:探活刻意在 AppState 锁外做,不阻塞 status 等命令。 -/// - **generation token**:spawn 前抓 `gen`;探活健康后回锁校验 `current_generation()==gen`, -/// 若期间被清 key/停/切 bump 过 → 杀掉自己刚起的 child、**不写回 st.proxy**(不拿旧配置复活)。 -/// -/// 本函数**绝不取串行器锁**(调用方命令才取),故与命令层的 `with_serialized` 不会自死锁。 -fn start_proxy_for( - app: &tauri::AppHandle, - state: &State<'_, Mutex>, - lifecycle: &lifecycle::Lifecycle, - profile: &config::Profile, -) -> Result<(u16, String, ProxyAction), String> { - assert_format_supported(profile)?; - let launch = proxy_args_for(profile); - if launch.key.is_empty() { - return Err(format!( - "「{}」还没填 API key,请先在面板填写并保存。", - profile.name - )); - } - let native = is_native_adapter(&launch.adapter); - if !native && launch.base_url.is_empty() { - return Err( - "该配置需要填 base_url(如 https://your-relay/claude),请先在面板填写并保存。".into(), - ); - } - // 换任一协议语义或上游字段都触发代理重启,避免不同配置切换时复用旧进程。 - let key_fp = proxy_fingerprint(profile, &launch); - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - let port = cfg.proxy_port; - let root = asset_root(app) - .ok_or("找不到代理脚本 proxy/csswitch_proxy.py(打包资源或仓库根均未命中)。开发态可设 CSSWITCH_REPO。")?; - let py = proc::find_exe("python3") - .ok_or("缺少依赖 python3(起翻译代理需要)。已查 PATH、常见目录与登录 shell 仍未找到;macOS 一般自带 /usr/bin/python3(装 Xcode 命令行工具:xcode-select --install)。")?; - - // path-secret:**持久化复用**(已在跑的沙箱把该 secret 嵌进了 ANTHROPIC_BASE_URL, - // 若每次起代理都换 secret,代理一重启沙箱就会拿旧 secret 打到新代理 → 全部 403)。 - let secret = if !cfg.secret.is_empty() { - cfg.secret.clone() - } else { - let s = proc::gen_secret().map_err(|e| format!("无法生成安全 secret:{e}"))?; - let s2 = s.clone(); - config::update(&dir, move |c| c.secret = s2).map_err(|e| e.to_string())?; - s - }; - - // generation token:**spawn 前**抓当前号;探活后回锁比对,防被更晚操作取代还写回。 - let gen = lifecycle.current_generation(); - - // 「检查复用 → 清残留 → 起进程」在同一把 AppState 锁内完成(读-spawn 原子)。 - // 但新 child 只握在本地,**探活健康 + generation 未变**才写回 st.proxy。 - let child = { - let mut st = lock(state); - // 幂等:已在跑且健康、且【端口 + adapter + key 指纹】都一致才复用。 - if st.proxy.is_some() - && st.proxy_port == port - && st.provider == launch.adapter - && st.key_fp == key_fp - && proc::http_health(port, Some(&st.secret), 500) - { - return Ok((port, st.secret.clone(), ProxyAction::Reused)); - } - // 端口要让给新进程 → 先杀掉旧占用者(st.proxy)与同端口孤儿;期间 st.proxy=None。 - kill_child(&mut st.proxy); - st.provider.clear(); - st.key_fp = 0; - // 预置 st.secret = 本次 secret(persistent path-secret):使探活后写回门的 secret 合取 - // 在合法启动上恒真;只有并发另起用「不同 secret」重置了它,才会挡下写回(冷启动双起窄窗防御)。 - st.secret = secret.clone(); - let script = root.join("proxy/csswitch_proxy.py"); - // 再清掉上次会话遗留、绑在同端口上的孤儿代理(匹配本安装的绝对脚本路径 + 端口)。 - let pat = format!("{}.*--port {port}", ere_escape(&script.to_string_lossy())); - let _ = Command::new("pkill").arg("-f").arg(&pat).status(); - - let logf = open_log("proxy.log").map_err(|e| format!("建日志失败:{e}"))?; - let logf2 = logf.try_clone().map_err(|e| e.to_string())?; - let mut cmd = Command::new(&py); - cmd.arg(&script) - .arg("--provider") - .arg(&launch.adapter) - .arg("--port") - .arg(port.to_string()) - .arg("--auth-token") - .arg(&secret) - // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 - .env(launch.key_env, &launch.key); - // 非 native 家族:base_url + 选中模型经环境变量交给代理(均非密钥,但与 key 一致走 env)。 - if !native { - if is_openai_adapter(&launch.adapter) { - cmd.env("CSSWITCH_OPENAI_BASE_URL", &launch.base_url); - if !launch.model.is_empty() { - cmd.env("CSSWITCH_OPENAI_MODEL", &launch.model); - } - } else { - cmd.env("CSSWITCH_RELAY_BASE_URL", &launch.base_url); - if !launch.model.is_empty() { - cmd.env("CSSWITCH_RELAY_MODEL", &launch.model); - } - if !launch.thinking_policy.is_empty() { - cmd.env("CSSWITCH_RELAY_THINKING", launch.thinking_policy); - } - } - } - cmd.stdout(Stdio::from(logf)) - .stderr(Stdio::from(logf2)) - .spawn() - .map_err(|e| format!("启动代理失败:{e}"))? - // 注意:child 未写入 st.proxy——探活通过且 generation 未变时才回锁写回。 - }; - - // 探活最多 ~4s(AppState 锁外,不阻塞 status 等命令)。 - let mut ok = false; - for _ in 0..40 { - std::thread::sleep(Duration::from_millis(100)); - if proc::http_health(port, Some(&secret), 400) { - ok = true; - break; - } - } - if !ok { - // 探活失败:杀掉自己刚起的 child(它从未写入 st.proxy,绝不留孤儿)。 - let mut c = child; - let _ = c.kill(); - let _ = c.wait(); - let tail = redact(&tail_file(&log_path("proxy.log"), 500), &secret); - // 本地 /health 不验上游 key,故探活超时与 key 有效性无关:按日志区分端口占用 vs 依赖/脚本异常 - // (修真机 P2:旧措辞含糊说「或 key 无效」会误导用户去查 key)。 - return Err(format!("{}\n{tail}", health_timeout_reason(port, &tail))); - } - - // 健康 → 回 AppState 锁,校验 generation 未被 bump 且 secret 仍是本次的(未被清 key/停/切/并发另起取代)才写回。 - { - let mut st = lock(state); - if !should_write_back(gen, lifecycle.current_generation(), &st.secret, &secret) { - // 被更晚的操作取代(generation 变)或被并发另起用不同 secret 占了槽: - // 杀掉自己刚起的 child、不写回 st.proxy(不拿旧配置复活、不覆盖他人的槽)。 - let mut c = child; - let _ = c.kill(); - let _ = c.wait(); - return Err("代理启动期间配置已变更(被更晚的操作取代),本次启动未生效。".into()); - } - st.proxy = Some(child); - st.proxy_port = port; - st.secret = secret.clone(); - st.provider = launch.adapter.clone(); - st.key_fp = key_fp; - } - Ok((port, secret, ProxyAction::Restarted)) -} - -/// 停沙箱。返回 Err 表示 stop 脚本非零退出(Science 可能没停干净),调用方据此如实报告。 -fn stop_sandbox_inner(app: &tauri::AppHandle, st: &mut AppState) -> Result<(), String> { - let mut err = None; - match asset_root(app) { - Some(root) => { - let stop = root.join("scripts/stop-science-sandbox.sh"); - if stop.is_file() { - match Command::new("zsh") - .arg(&stop) - .env("SANDBOX_HOME", sandbox_home()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Ok(s) if s.success() => {} - Ok(s) => err = Some(format!("停止沙箱脚本非零退出({:?})。", s.code())), - Err(e) => err = Some(format!("调用停止沙箱脚本失败:{e}")), - } - } else { - err = Some(format!( - "找不到停止脚本 {},无法确认沙箱已停止(沙箱可能仍在运行)。", - stop.display() - )); - } - } - None => { - err = Some( - "定位不到资源根,取不到停止脚本,无法确认沙箱已停止(沙箱可能仍在运行)。" - .to_string(), - ); - } - } - kill_child(&mut st.sandbox); - st.sandbox_url = None; - match err { - Some(e) => Err(e), - None => Ok(()), - } -} - -// ---------- 返回体组装(纯函数,便于测试) ---------- -/// 组装 get_config 返回体:profiles 的 key 只回掩码,全 key 绝不出后端。 -fn build_get_config(dir: &Path) -> Result { - let cfg = config::load_from(dir).map_err(|e| e.to_string())?; - // 一次性迁移提示(#9 甲):读出后立即清盘,避免每次 get_config 重复提示。 - let notice = cfg.pending_notice.clone(); - if notice.is_some() { - config::update(dir, |c| c.pending_notice = None).map_err(|e| e.to_string())?; - } - let profiles: Vec = cfg - .profiles - .iter() - .map(|p| { - json!({ - "id": p.id, "name": p.name, "template_id": p.template_id, "category": p.category, - "api_format": p.api_format, "base_url": p.base_url, "model": p.model, - "key": config::mask(&p.api_key), "icon": p.icon, "icon_color": p.icon_color, - "website_url": p.website_url, "sort_index": p.sort_index, "notes": p.notes, - }) - }) - .collect(); - Ok(json!({ - "schema_version": cfg.schema_version, "active_id": cfg.active_id, "profiles": profiles, - "templates": build_list_templates(), "proxy_port": cfg.proxy_port, - "sandbox_port": cfg.sandbox_port, "mode": cfg.mode, "pending_notice": notice, - })) -} - -/// 模板注册表交前端铺 UI(单一来源,前端不复制常量)。 -fn build_list_templates() -> Vec { - templates::all() - .iter() - .map(|t| { - json!({ - "id": t.id, "name": t.name, "category": t.category, "api_format": t.api_format, - "adapter": t.adapter, "base_url": t.base_url, "base_url_editable": t.base_url_editable, - "requires_model_override": t.requires_model_override, - "builtin_models": t.builtin_models, "icon": t.icon, "icon_color": t.icon_color, - "website_url": t.website_url, - }) - }) - .collect() -} - -// ---------- profile CRUD 纯实现(*_inner,便于用临时 dir 单测) ---------- -fn create_profile_inner( - dir: &Path, - template_id: &str, - name: &str, - key: Option<&str>, - base_url_override: Option<&str>, - model: Option<&str>, -) -> Result { - let tpl = templates::by_id(template_id).ok_or_else(|| format!("未知模板:{template_id}"))?; - let id = config::new_id(); - let base_url = base_url_override - .map(str::to_string) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| tpl.base_url.to_string()); - reject_openai_custom_anthropic_base(template_id, &base_url)?; - let p = config::Profile { - id: id.clone(), - name: name.to_string(), - template_id: template_id.to_string(), - category: tpl.category.to_string(), - api_format: tpl.api_format.to_string(), - base_url, - api_key: key.unwrap_or("").to_string(), - model: model.unwrap_or("").to_string(), - website_url: Some(tpl.website_url.to_string()), - icon: Some(tpl.icon.to_string()), - icon_color: Some(tpl.icon_color.to_string()), - sort_index: Some(config::now_ms()), - created_at: Some(config::now_ms()), - notes: None, - }; - assert_format_supported(&p)?; // custom 选了不支持格式则拒 - // 守卫(修 #9 P1-a):relay/自定义端点必须带 model(force 前提)。 - if relay_missing_model(tpl.adapter, &p.model) { - return Err("中转 / 自定义端点必须选择或填写一个模型,未创建。".to_string()); - } - config::update(dir, |c| c.profiles.push(p)).map_err(|e| e.to_string())?; - Ok(id) -} - -fn update_profile_metadata_inner( - dir: &Path, - id: &str, - name: &str, - notes: Option<&str>, -) -> Result<(), String> { - // 未命中 id → Err(不静默 Ok,修 MP-1 Minor [4])。 - if config::load_from(dir) - .map_err(|e| e.to_string())? - .profile_by_id(id) - .is_none() - { - return Err(format!("找不到 profile:{id}")); - } - config::update(dir, |c| { - if let Some(p) = c.profile_by_id_mut(id) { - p.name = name.to_string(); - p.notes = notes.map(str::to_string); - } - }) - .map_err(|e| e.to_string())?; - Ok(()) -} - -fn clear_profile_key_inner(dir: &Path, id: &str) -> Result<(), String> { - config::update(dir, |c| { - if let Some(p) = c.profile_by_id_mut(id) { - p.api_key.clear(); - } - }) - .map_err(|e| e.to_string())?; - config::drop_rolling_backup(dir); // 清 key 后净化滚动备份,旧明文不可从 .bak 恢复 - Ok(()) -} - -fn delete_profile_inner(dir: &Path, id: &str) -> Result<(), String> { - config::update(dir, |c| { - c.profiles.retain(|p| p.id != id); - if c.active_id == id { - c.active_id.clear(); // 删 active → 置空 - } - }) - .map_err(|e| e.to_string())?; - config::drop_rolling_backup(dir); - Ok(()) -} - -fn update_profile_connection_inner( - dir: &Path, - id: &str, - base_url: Option<&str>, - api_format: Option<&str>, - model: Option<&str>, - key: Option<&str>, -) -> Result<(), String> { - if let Some(fmt) = api_format { - let probe = config::Profile { - api_format: fmt.to_string(), - ..Default::default() - }; - assert_format_supported(&probe)?; - } - // 未命中 id → Err(不静默 Ok,修 MP-1 Minor [4])。 - if config::load_from(dir) - .map_err(|e| e.to_string())? - .profile_by_id(id) - .is_none() - { - return Err(format!("找不到 profile:{id}")); - } - config::write_rolling_backup(dir).ok(); // 覆盖前留底 - config::update(dir, |c| { - if let Some(p) = c.profile_by_id_mut(id) { - if let Some(u) = base_url { - p.base_url = u.to_string(); - } - if let Some(f) = api_format { - p.api_format = f.to_string(); - } - if let Some(m) = model { - p.model = m.to_string(); - } - if let Some(k) = key { - if !k.is_empty() { - p.api_key = k.to_string(); // 空=不改(留占位不覆盖已存 key) - } - } - } - }) - .map_err(|e| e.to_string())?; - Ok(()) -} - -// ---------- Tauri commands ---------- -#[tauri::command] -fn get_config() -> Result { - build_get_config(&config::default_dir()) -} - -/// 模板注册表交前端铺 UI(新建向导用)。 -#[tauri::command] -fn list_templates() -> Vec { - build_list_templates() -} - -/// 切换运行模式("proxy" 第三方 / "official" 官方)。切官方要先拆第三方链路成功再落盘。 -#[tauri::command] -fn set_mode( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - mode: String, -) -> Result<(), String> { - if mode != "proxy" && mode != "official" { - return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); - } - // 经串行器(修 P1-b):切官方的「拆链路 + 落盘」必须与「一键开始」等互斥,否则一键起到一半时 - // 切官方会先停链路、一键随后又把沙箱/OAuth 起起来 → 显示官方却有第三方沙箱在跑。bump_generation - // 作废任何在途启动,防被停后又拿旧配置写回运行态。 - lifecycle.with_serialized(|| { - let dir = config::default_dir(); - if mode == "official" { - lifecycle.bump_generation(); - let mut st = lock(&state); - stop_sandbox_inner(&app, &mut st).map_err(|e| { - format!("停止沙箱失败,未切换到官方模式:{e}(真实实例 8765 未受影响)") - })?; - kill_child(&mut st.proxy); - st.secret.clear(); - st.provider.clear(); - st.key_fp = 0; - } - config::update(&dir, { - let mode = mode.clone(); - move |c| c.mode = mode - }) - .map_err(|e| e.to_string())?; - Ok(()) - }) -} - -/// 官方模式:干净地打开用户【真实】的 Claude Science(不碰/复制真实凭证,抹掉 ANTHROPIC_*)。 -#[tauri::command] -fn open_official() -> Result<(), String> { - let app_path = "/Applications/Claude Science.app"; - let mut cmd = Command::new("open"); - if Path::new(app_path).is_dir() { - cmd.arg(app_path); - } else { - cmd.arg("-a").arg("Claude Science"); - } - cmd.env_remove("ANTHROPIC_BASE_URL") - .env_remove("ANTHROPIC_API_KEY") - .env_remove("ANTHROPIC_AUTH_TOKEN"); - match cmd.status() { - Ok(s) if s.success() => Ok(()), - Ok(_) => Err("未能打开 Claude Science。请确认已安装官方 Claude Science。".into()), - Err(e) => Err(format!("打开官方 Claude Science 失败:{e}")), - } -} - -#[derive(Deserialize)] -struct UiSettings { - proxy_port: u16, - sandbox_port: u16, -} - -/// 端口变更是否需要拆掉现有链路(纯函数,P1-c)。代理/沙箱任一端口变了,正在跑的代理就绑在 -/// 旧端口、正在跑的沙箱又把旧代理 URL 烘死了,二者与新配置不一致 → 拆掉逼下次「一键开始」按新端口重建。 -fn settings_change_needs_teardown( - old_proxy: u16, - new_proxy: u16, - old_sandbox: u16, - new_sandbox: u16, -) -> bool { - old_proxy != new_proxy || old_sandbox != new_sandbox -} - -/// 端口设置(provider/连接改走 profile CRUD + set_active_profile)。 -/// 经串行器(修 P1-c):端口一旦变化,正在跑的代理绑在旧端口、正在跑的沙箱又烘死了旧代理 URL, -/// 与新端口不一致;此处把这条陈旧链路拆掉(只停我们的沙箱、绝不碰 8765),逼下次「一键开始」按新端口重建, -/// 杜绝「复用旧沙箱指向死端口、UI 却报沿用不变」。 -#[tauri::command] -fn set_settings( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - cfg: UiSettings, -) -> Result<(), String> { - if cfg.proxy_port == 8765 || cfg.sandbox_port == 8765 { - return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); - } - if cfg.proxy_port == 0 || cfg.sandbox_port == 0 { - return Err("端口不能为 0。".into()); - } - if cfg.proxy_port == cfg.sandbox_port { - return Err("代理端口与沙箱端口不能相同。".into()); - } - lifecycle.with_serialized(|| { - let dir = config::default_dir(); - let old = config::load_from(&dir).map_err(|e| e.to_string())?; - let teardown = settings_change_needs_teardown( - old.proxy_port, - cfg.proxy_port, - old.sandbox_port, - cfg.sandbox_port, - ); - // 拆链路【先】于落盘,且停沙箱结果必须据实处理(修增量 P1):停不掉就【不改端口】—— - // 否则会留下「config 已是新端口、旧沙箱仍在旧端口指向旧代理」的不一致态,下次一键还会复用这条死链路。 - // 保持端口不变则一切仍自洽(旧沙箱指旧代理端口、下次一键在旧端口重建代理,链路照通)。 - if teardown { - let mut st = lock(&state); - stop_sandbox_inner(&app, &mut st).map_err(|e| { - format!( - "端口未更改:无法停止指向旧端口的沙箱({e}),为避免留下失效链路,端口保持不变。请手动停止沙箱或重启 app 后重试。(真实实例 8765 未受影响)" - ) - })?; - lifecycle.bump_generation(); // 停成功后作废在途启动 - kill_child(&mut st.proxy); - st.secret.clear(); - st.provider.clear(); - st.key_fp = 0; - } - // 拆链路成功(或无需拆)→ 才落盘新端口,保证 config 与运行态一致。 - config::update(&dir, move |c| { - c.proxy_port = cfg.proxy_port; - c.sandbox_port = cfg.sandbox_port; - }) - .map_err(|e| e.to_string())?; - Ok(()) - }) -} - -// ---------- profile CRUD 命令(薄包装 *_inner,统一经串行器) ---------- -#[tauri::command] -fn create_profile( - lifecycle: State<'_, lifecycle::Lifecycle>, - template_id: String, - name: String, - key: Option, - base_url: Option, - model: Option, -) -> Result { - lifecycle.with_serialized(|| { - create_profile_inner( - &config::default_dir(), - &template_id, - &name, - key.as_deref(), - base_url.as_deref(), - model.as_deref(), - ) - }) -} - -#[tauri::command] -fn update_profile_metadata( - lifecycle: State<'_, lifecycle::Lifecycle>, - id: String, - name: String, - notes: Option, -) -> Result<(), String> { - lifecycle.with_serialized(|| { - update_profile_metadata_inner(&config::default_dir(), &id, &name, notes.as_deref()) - }) -} - -/// 清 key:经串行器;若清的是【生效】profile → bump_generation 作废在途启动 + 停运行中代理 -/// (不再拿旧 key 服务,比照 spec §8.2 运行态撤销)。 -#[tauri::command] -fn clear_profile_key( - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - id: String, -) -> Result<(), String> { - lifecycle.with_serialized(|| { - let dir = config::default_dir(); - let was_active = config::load_from(&dir) - .map(|c| c.active_id == id) - .unwrap_or(false); - clear_profile_key_inner(&dir, &id)?; - if was_active { - lifecycle.bump_generation(); - let mut st = lock(&state); - kill_child(&mut st.proxy); - st.provider.clear(); - st.key_fp = 0; - } - Ok(()) - }) -} - -/// 删 profile:经串行器;删的是【生效】profile → active 置空(inner 内)+ bump + 停代理。 -#[tauri::command] -fn delete_profile( - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - id: String, -) -> Result<(), String> { - lifecycle.with_serialized(|| { - let dir = config::default_dir(); - let was_active = config::load_from(&dir) - .map(|c| c.active_id == id) - .unwrap_or(false); - delete_profile_inner(&dir, &id)?; - if was_active { - lifecycle.bump_generation(); - let mut st = lock(&state); - kill_child(&mut st.proxy); - st.provider.clear(); - st.key_fp = 0; - } - Ok(()) - }) -} - -/// 非 active 连接编辑的上游校验裁决(纯函数,P2-d):只有上游【明确】拒绝(Auth 401/403、 -/// ModelError 400/404/422)才 Some(hint) 拦下不落盘;Ok / 含糊(429/5xx) / 无响应 → None 照常落盘 -/// (best-effort:非 active 没有正在服务的链路可保护,卡在网络抖动上比放行更糟)。 -/// 非 active 连接编辑的上游校验裁决(纯函数,P2-d): -/// - `Ok(true)` 上游明确接受(200),已校验; -/// - `Ok(false)` 无法确认(429/5xx/无响应),best-effort 落盘、标记「未校验」(激活时会再验); -/// - `Err(hint)` 上游明确拒绝(401/403/400/404/422),拦下不落盘。 -/// -/// 选「如实标记后保存」:不因网络抖动/上游繁忙挡住保存,但也绝不假称已校验。 -fn nonactive_probe_verdict(outcome: &scratch::ProbeOutcome) -> Result { - match outcome { - scratch::ProbeOutcome::Ok => Ok(true), - scratch::ProbeOutcome::Auth(code) => { - Err(format!("上游拒绝({code}),key/权限有误,连接未保存。")) - } - scratch::ProbeOutcome::ModelError(code) => Err(format!( - "上游拒绝该模型({code}),连接未保存。请换一个模型或核对 base_url。" - )), - // 无法确认(405/429/5xx/无响应):落盘但标记未校验,激活时再验。 - // Unsupported(405) 并入此类:save 走 Message 探测,405 罕见(端点/base_url 异常),保守标未校验(与旧行为一致)。 - scratch::ProbeOutcome::Ambiguous(_) - | scratch::ProbeOutcome::NoResponse - | scratch::ProbeOutcome::Unsupported(_) => Ok(false), - } -} - -/// 是否对候选连接跑上游 scratch 校验(纯函数,修真机 P1):空 key → 免(无从验);非原生且空 -/// base_url → 免(relay 必须带 base_url);原生(deepseek/qwen)即便 base_url 为空也【要】验 -/// (用各自硬编码官方端点,坏 key 才能在保存时被拦,不再顺延到激活)。 -fn should_scratch_candidate(adapter: &str, key: &str, base_url: &str) -> bool { - if key.is_empty() { - return false; // 无 key → 无从验,如实标记未校验。 - } - if !is_native_adapter(adapter) && base_url.is_empty() { - return false; // relay 家族缺 base_url → 无从验。 - } - true -} - -/// 保存前守卫(纯函数,修 P2):relay 家族(非 native)空 base_url 的候选连接不可用—— -/// 激活必失败(relay 无硬编码端点可回退)。0.3.1 起内置预设 base_url 可编辑,用户清空后 -/// 旧路径会跳过校验、静默落盘并谎报「已保存」。此处在保存时就拦下,绝不落盘。 -/// native(deepseek/qwen) 走各自硬编码官方端点,空 base_url 无妨 → 不拦。 -fn relay_missing_base_url(adapter: &str, base_url: &str) -> bool { - !is_native_adapter(adapter) && base_url.trim().is_empty() -} - -/// 保存/激活前守卫(纯函数,修 #9 P1-a):relay 家族(非 native)空(含纯空白)model 不可用—— -/// 无 model → launcher 不注入 CSSWITCH_RELAY_MODEL → 无 force → 退回 passthrough → Science 显示 claude。 -/// native(deepseek/qwen) 走内置映射/硬编码端点,model 可空 → 不拦。 -fn relay_missing_model(adapter: &str, model: &str) -> bool { - !is_native_adapter(adapter) && model.trim().is_empty() -} - -/// 对候选连接做一次上游 scratch 校验(非 active 编辑用,P2-d)。起临时代理探完即杀, -/// **绝不碰 config / AppState / 正在服务的正式代理**。返回是否【已通过上游校验】(供调用方据实措辞): -/// 空 key / relay 家族空 base_url → `Ok(false)`(无从预检,标记未校验); -/// native(deepseek/qwen) 即便 base_url 空也【会】走各自官方端点探测(修真机 P1); -/// 明确接受(200) → `Ok(true)`;明确拒绝 → `Err(hint)`;无法确认 → `Ok(false)`(见 [`nonactive_probe_verdict`])。 -fn scratch_validate_candidate( - app: &tauri::AppHandle, - candidate: &config::Profile, -) -> Result { - let launch = proxy_args_for(candidate); - if !should_scratch_candidate(&launch.adapter, &launch.key, &launch.base_url) { - return Ok(false); // 跳过 = 未校验(如实标记) - } - let root = asset_root(app).ok_or("找不到代理脚本 proxy/csswitch_proxy.py。")?; - let py = proc::find_exe("python3").ok_or("缺少依赖 python3(起临时代理需要)。")?; - let script = root.join("proxy/csswitch_proxy.py"); - let res = scratch::scratch_probe( - &py, - &script, - &scratch::ScratchTarget { - provider: &launch.adapter, - key_env: launch.key_env, - base_url: &launch.base_url, - key: &launch.key, - model: Some(&launch.model), - relay_thinking: launch.thinking_policy, - }, - probe_kind_for(&launch.adapter, &launch.model), - ); - nonactive_probe_verdict(&scratch::classify(res.status)) -} - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -fn update_profile_connection( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - id: String, - base_url: Option, - api_format: Option, - model: Option, - key: Option, -) -> Result { - lifecycle.with_serialized(|| { - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - // 未命中 id → Err(不静默 Ok)。 - let mut candidate = cfg - .profile_by_id(&id) - .cloned() - .ok_or_else(|| format!("找不到 profile:{id}"))?; - // 生效【后】的候选连接(None=不改则沿用旧值),active/非 active 共用一份。 - let edit = ConnectionEdit { - base_url: base_url.clone(), - api_format: api_format.clone(), - model: model.clone(), - key: key.clone(), - }; - edit.apply(&mut candidate); - reject_openai_custom_anthropic_base(&candidate.template_id, &candidate.base_url)?; - // 保存前守卫(修 P2):relay/自定义端点清空 base_url → 不可用连接(激活必失败)。 - // 校验生效后的 base_url,空则拒绝落盘、绝不谎报「已保存」;native 走硬编码端点,空无妨。 - if relay_missing_base_url( - templates::adapter_for(&candidate.template_id), - &candidate.base_url, - ) { - return Err("中转 / 自定义端点必须填写连接地址(base_url),连接未保存。".to_string()); - } - // 保存前守卫(修 #9 P1-a):relay/自定义端点空 model → 无 force → 退回 passthrough(显示 claude)。 - if relay_missing_model( - templates::adapter_for(&candidate.template_id), - &candidate.model, - ) { - return Err("中转 / 自定义端点必须选择或填写一个模型,连接未保存。".to_string()); - } - if cfg.active_id == id { - // active(有正在服务的代理):validate-before-persist —— 新连接作【内存候选】喂进 - // 切换事务(校验→起正式→健康),探活健康【才】连同落盘;失败则磁盘连接零改动、 - // 仍跑旧连接(杜绝「盘新运行旧」,修 P1-4)。 - let v = - set_active_profile_txn(&app, &state, lifecycle.inner(), &id, false, Some(&edit))?; - // 连接编辑:committed:false(scratch 分类失败)也如实作为错误上抛(磁盘未改、代理仍跑旧的)。 - if v.get("committed").and_then(|b| b.as_bool()) == Some(false) { - let hint = v - .get("hint") - .and_then(|h| h.as_str()) - .unwrap_or("连接校验未通过,连接未保存。") - .to_string(); - return Err(hint); - } - // active:已连同起正式代理探活并落盘,视为已校验。 - Ok(json!({ "validated": true })) - } else { - // 非 active:无正在服务的代理。先对候选做上游 scratch 校验(仅明确拒绝才拦,其余 - // best-effort 落盘并如实标记「未校验」,修 P2-d:贴合设计「校验候选后提交」+ 如实报告), - // 再落盘(inner 内含格式门 + 覆盖前留底)。 - let validated = scratch_validate_candidate(&app, &candidate)?; - update_profile_connection_inner( - &dir, - &id, - base_url.as_deref(), - api_format.as_deref(), - model.as_deref(), - key.as_deref(), - )?; - Ok(json!({ "validated": validated })) - } - }) -} - -/// 一键切生效 profile:经串行器走 [`set_active_profile_txn`] 切换事务。 -#[tauri::command] -fn set_active_profile( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, - id: String, - skip_verify: bool, -) -> Result { - lifecycle.with_serialized(|| { - set_active_profile_txn(&app, &state, lifecycle.inner(), &id, skip_verify, None) - }) -} - -/// active 连接编辑的内存候选值(validate-before-persist 用):不改的字段为 None。 -/// 校验时把它套到旧 profile 的克隆上做 scratch/起正式;提交成功时用**同一套** [`ConnectionEdit::apply`] -/// 逻辑连同 active_id 一起落盘,杜绝「先落盘后校验」导致的「盘新运行旧」(P1-4)。 -#[derive(Default)] -struct ConnectionEdit { - base_url: Option, - api_format: Option, - model: Option, - key: Option, -} - -impl ConnectionEdit { - /// 把非空编辑值套到目标 profile(内存候选与落盘共用同一逻辑)。 - /// 语义与 `update_profile_connection_inner` 一致:None=不改;key 为空串=不改(留占位不覆盖已存 key)。 - fn apply(&self, p: &mut config::Profile) { - if let Some(u) = &self.base_url { - p.base_url = u.clone(); - } - if let Some(f) = &self.api_format { - p.api_format = f.clone(); - } - if let Some(m) = &self.model { - p.model = m.clone(); - } - if let Some(k) = &self.key { - if !k.is_empty() { - p.api_key = k.clone(); - } - } - } -} - -/// 激活/切换是否跳过 scratch 上游校验(纯函数,修真机 P1):只有用户显式 `skip_verify` 才跳; -/// 原生 adapter 不再豁免(旧行为 `native || skip_verify` 会让原生无效 key 提交为 active 并谎报「已切到」, -/// 首个真实推理才 401)。`native` 参数刻意保留:记录它曾是豁免条件、现已作废。 -fn skip_scratch_verify(native: bool, skip_verify: bool) -> bool { - let _ = native; // native 曾是豁免条件,现已作废(保留参数以固化回归防线)。 - skip_verify -} - -/// 切换事务本体(spec §7):scratch 校验候选 → 起正式代理探活 → 探活健康【才】提交 active_id; -/// 任一步失败杀候选 + 恢复旧代理,`active_id` 不动,**不停沙箱**(path-secret 持久,端口+secret -/// 不变,沙箱链路不断,停沙箱只会扩大失败面)。**本函数不取串行器锁**(调用方命令已持有)。 -fn set_active_profile_txn( - app: &tauri::AppHandle, - state: &State<'_, Mutex>, - lifecycle: &lifecycle::Lifecycle, - id: &str, - skip_verify: bool, - conn_edit: Option<&ConnectionEdit>, -) -> Result { - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - let mut candidate = cfg - .profile_by_id(id) - .cloned() - .ok_or_else(|| format!("找不到 profile:{id}"))?; - // active 连接编辑:把新连接字段套到【内存候选】做校验(validate-before-persist)—— - // 磁盘此刻仍是旧连接;只有探活健康提交时才落盘(见下方 Commit 分支)。 - if let Some(edit) = conn_edit { - edit.apply(&mut candidate); - } - reject_openai_custom_anthropic_base(&candidate.template_id, &candidate.base_url)?; - let is_edit = conn_edit.is_some(); - // 失败措辞:连接编辑说「未保存/仍在用原配置运行」,普通切换说「未切换/当前配置不变」。 - let (verb, tail): (&str, &str) = if is_edit { - ("未保存", "仍在用原配置运行") - } else { - ("未切换", "当前配置不变") - }; - assert_format_supported(&candidate)?; - let launch = proxy_args_for(&candidate); - if launch.key.is_empty() { - return Err(format!("「{}」还没填 API key,请先填写。", candidate.name)); - } - let native = is_native_adapter(&launch.adapter); - if !native && launch.base_url.is_empty() { - return Err("该配置需要填 base_url(http:// 或 https:// 开头)。".into()); - } - // 守卫(修 #9 P1-a):relay/自定义端点空 model 无法激活(无 force → 退回 passthrough 显示 claude)。 - if relay_missing_model(&launch.adapter, &candidate.model) { - return Err( - "该配置需要选择或填写一个模型(中转/自定义端点必填),请在连接编辑里补上。".into(), - ); - } - // 快照旧 active(回滚锚点):旧 profile 仍在盘上未动、active_id 未改,恢复据它重起旧代理。 - let old_active = cfg.active_id.clone(); - - // 1) scratch 校验候选(临时端口+secret+候选 key,避开 8765;绝不碰正式链路)。 - // 所有 adapter 都预检:native(deepseek/qwen) 用各自官方端点 + Message 探测(其 /v1/models 静态, - // 探不出坏 key);只有用户显式 skip_verify 才跳过(修真机 P1:原生免校验会让无效 key 提交为 - // active 并谎报「已切到」,首个真实推理才 401)。分类失败保留结构化提示(committed:false/can_skip)。 - let scratch_ok = if skip_scratch_verify(native, skip_verify) { - true - } else { - let root = asset_root(app).ok_or("找不到代理脚本 proxy/csswitch_proxy.py。")?; - let py = proc::find_exe("python3").ok_or("缺少依赖 python3(起临时代理需要)。")?; - let script = root.join("proxy/csswitch_proxy.py"); - let res = scratch::scratch_probe( - &py, - &script, - &scratch::ScratchTarget { - provider: &launch.adapter, - key_env: launch.key_env, - base_url: &launch.base_url, - key: &launch.key, - model: Some(&launch.model), - relay_thinking: launch.thinking_policy, - }, - probe_kind_for(&launch.adapter, &launch.model), - ); - match scratch::classify(res.status) { - scratch::ProbeOutcome::Ok => true, - scratch::ProbeOutcome::Auth(code) => { - return Ok(json!({ "committed": false, - "hint": format!("上游拒绝({code}),key/权限有误,{verb}({tail})。") })); - } - scratch::ProbeOutcome::ModelError(code) => { - return Ok(json!({ "committed": false, - "hint": format!("上游拒绝该模型({code}),{verb}。请换一个模型或核对 base_url。") })); - } - scratch::ProbeOutcome::Ambiguous(_) - | scratch::ProbeOutcome::NoResponse - | scratch::ProbeOutcome::Unsupported(_) => { - return Ok(json!({ "committed": false, "can_skip": true, - "hint": format!("无法确认(网络/上游繁忙),{verb}。可重试,或用「跳过验证」。") })); - } - } - }; - - // 2/3) 用候选起【正式代理】并探活。bump_generation 使并发中的旧启动(如同时的 verify_key)作废。 - lifecycle.bump_generation(); - let real_healthy = scratch_ok && start_proxy_for(app, state, lifecycle, &candidate).is_ok(); - - match decide_switch(scratch_ok, real_healthy) { - SwitchOutcome::Commit => { - // 探活健康【才】落盘:连接编辑连同 active_id 一起提交(validate-before-persist), - // 盘上与运行态一致,杜绝「盘新运行旧」。 - if is_edit { - config::write_rolling_backup(&dir).ok(); // 覆盖连接前留底(仅编辑路径需要) - } - if let Err(e) = config::update(&dir, |c| { - c.active_id = id.to_string(); - if let Some(edit) = conn_edit { - if let Some(p) = c.profile_by_id_mut(id) { - edit.apply(p); - } - } - }) { - // spec §7 步 5:config 提交失败也要回滚进程——正式代理已起,若不回滚就成「运行新/盘旧」。 - // 恢复旧 active 代理,active_id 仍为旧值,用户可重试。 - let restored = restore_proxy_for_active(app, state, lifecycle, &cfg, &old_active); - return Err(format!( - "校验通过、代理已起,但写盘失败({e}),{}。请检查磁盘空间/权限后重试。", - rollback_status_clause(restored) - )); - } - let hint = if is_edit { - format!("已保存并应用「{}」的新连接。", candidate.name) - } else { - format!("已切到「{}」。", candidate.name) - }; - Ok(json!({ "committed": true, "active_id": id, "hint": hint })) - } - SwitchOutcome::RollbackToOld => { - // 候选正式代理起/探活失败:恢复旧代理,active_id 不动,连接不落盘,不停沙箱。 - let restored = restore_proxy_for_active(app, state, lifecycle, &cfg, &old_active); - let clause = rollback_status_clause(restored); - if is_edit { - Err(format!( - "连接已校验通过,但正式代理启动/探活失败,连接未保存,{clause}。" - )) - } else { - Err(format!( - "候选配置校验通过,但正式代理启动/探活失败,{clause}。" - )) - } - } - SwitchOutcome::AbortBeforeStart => { - // scratch 校验未过;旧态零改动、连接不落盘。(明确拒绝/含糊态在上面已 committed:false 早返, - // 此分支是 scratch_ok=false 的兜底措辞。) - if is_edit { - Err("连接上游校验失败(key/base_url/网络?),连接未保存。".into()) - } else { - Err("候选上游校验失败(key/base_url/网络?),未切换。".into()) - } - } - } -} - -/// 回滚结果措辞(纯函数,P2-e):restored=true 才说「已回滚到原配置」;恢复失败必须如实说明代理已停, -/// 绝不谎称回滚成功(比照本项目「如实报告」铁律,掩盖代理已停会误导用户)。 -fn rollback_status_clause(restored: bool) -> &'static str { - if restored { - "已回滚到原配置(沙箱未受影响)" - } else { - "回滚未成功:代理当前已停,请重试或手动「一键开始」(沙箱未受影响)" - } -} - -/// 切换失败回滚:按【旧 active】重起旧代理(旧 profile 仍在盘上);best-effort,失败则代理暂停、 -/// active_id 仍为旧值,用户可重试。旧 active 为空(此前未配置生效)→ 不复活,保持代理停着。 -/// 返回是否已把旧代理恢复到位(供调用方据实措辞,修 P2-e)。 -fn restore_proxy_for_active( - app: &tauri::AppHandle, - state: &State<'_, Mutex>, - lifecycle: &lifecycle::Lifecycle, - cfg: &config::Config, - old_active: &str, -) -> bool { - if old_active.is_empty() { - return true; // 此前无生效配置 → 本就无代理可恢复,状态与切换前一致 - } - match cfg.profile_by_id(old_active) { - Some(old) => { - lifecycle.bump_generation(); - start_proxy_for(app, state, lifecycle, old).is_ok() - } - None => false, // 旧 active 指向已不存在的 profile(罕见)→ 无法恢复,代理已停 - } -} - -#[tauri::command] -fn start_proxy( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, -) -> Result { - // 经串行器:与切换/连接编辑/清 key/删/停等 ensure_proxy 竞争串行化,防陈旧读起旧配置代理 - // 又写回运行态(修 P1-a,比照 spec §8.1「ensure_proxy 都经一把 app 级 mutex」)。 - lifecycle.with_serialized(|| { - let (port, _secret, _action) = ensure_proxy(&app, &state, lifecycle.inner())?; - Ok(json!({ "port": port })) - }) -} - -/// 「存 key 即验证」:确保代理在跑,再经代理向上游发一个最小请求,据状态码判断 key 是否可用。 -#[tauri::command] -fn verify_key( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, -) -> Result { - // 经串行器(修 P1-a):ensure_proxy 与其它生命周期操作不并发交叠。 - lifecycle.with_serialized(|| { - let (port, secret, _action) = ensure_proxy(&app, &state, lifecycle.inner())?; - let body = br#"{"model":"claude-opus-4-8","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}"#; - match proc::http_post_status(port, Some(&secret), "/v1/messages", body, 15000) { - Some(200) => Ok(json!({ "ok": true, "hint": "key 有效,上游已接受。" })), - Some(code @ (401 | 403)) => Ok( - json!({ "ok": false, "hint": format!("上游拒绝({code}),key 可能无效或无权限。") }), - ), - Some(code) => Ok(json!({ - "ok": false, - "hint": format!("上游返回 {code},可能是 key 无效、额度不足或上游异常。") - })), - None => Err("验证请求无响应(多为网络或上游不通)。".to_string()), - } - }) -} - -#[derive(Deserialize)] -struct FetchModelsReq { - /// 模板 id(决定 builtin / base_url 可编辑性 / 默认 base_url)。 - template_id: String, - /// 自定义模板时用户填的 base_url(不可编辑模板忽略)。 - #[serde(default)] - base_url: String, - /// 用户新填的 key;为空表示沿用 profile_id 已存的 key(后端不回传完整 key)。 - #[serde(default)] - key: String, - /// 编辑已存 profile 时传其 id(用于沿用已存 key)。 - #[serde(default)] - profile_id: Option, -} - -/// live 探测结果(id + 能力)∪ builtin,去重(按 id)+ 排序(true>null>false,主列表 id 微调靠前)。 -fn merge_and_sort_models( - live: Vec<(String, Option)>, - builtin: &[&str], -) -> Vec { - let mut seen = std::collections::BTreeSet::new(); - let mut merged: Vec<(String, Option)> = Vec::new(); - for (id, st) in live { - if seen.insert(id.clone()) { - merged.push((id, st)); - } - } - for b in builtin { - if seen.insert(b.to_string()) { - merged.push((b.to_string(), None)); - } - } - merged.sort_by_key(|(id, st)| { - let cap = match st { - Some(true) => 0u8, - None => 1, - Some(false) => 2, - }; - let main = if is_main_list_model(id) { 0u8 } else { 1 }; - (cap, main) - }); - merged - .into_iter() - .map(|(id, st)| json!({ "id": id, "supports_tools": st })) - .collect() -} - -/// 解析探测用 key:新填的优先,否则沿用 profile_id 已存的(后端内部用,绝不回传前端)。 -fn resolve_probe_key(profile_id: Option<&str>, candidate: &str) -> Result { - let c = candidate.trim(); - if !c.is_empty() { - return Ok(c.to_string()); - } - let pid = profile_id.ok_or("请先填写 API Key / Token。")?; - let cfg = config::load_from(&config::default_dir()).map_err(|e| e.to_string())?; - cfg.profile_by_id(pid) - .map(|p| p.api_key.clone()) - .filter(|k| !k.is_empty()) - .ok_or_else(|| "请先填写 API Key / Token。".to_string()) -} - -/// 「获取可用模型」——纯 scratch 探测:只用临时代理探候选 base_url/key 的 /v1/models, -/// 绝不写 config、不改 AppState、不碰正在服务 Science 的正式代理。 -#[tauri::command] -fn fetch_models(app: tauri::AppHandle, req: FetchModelsReq) -> Result { - let tid = req.template_id.trim(); - let tpl = templates::by_id(tid).ok_or_else(|| format!("未知模板:{tid}"))?; - let base_url = if tpl.base_url_editable { - req.base_url.trim().to_string() - } else { - tpl.base_url.to_string() - }; - if base_url.is_empty() || !(base_url.starts_with("http://") || base_url.starts_with("https://")) - { - return Err("请先填写 base_url(http:// 或 https:// 开头)。".into()); - } - reject_openai_custom_anthropic_base(tid, &base_url)?; - let key = resolve_probe_key(req.profile_id.as_deref(), &req.key)?; - let root = asset_root(&app).ok_or("找不到代理脚本 proxy/csswitch_proxy.py。")?; - let py = proc::find_exe("python3").ok_or("缺少依赖 python3(起临时代理需要)。")?; - let script = root.join("proxy/csswitch_proxy.py"); - let adapter = templates::adapter_for(tid); - - let res = scratch::scratch_probe( - &py, - &script, - &scratch::ScratchTarget { - provider: adapter, - key_env: key_env_for_adapter(adapter), - base_url: &base_url, - key: &key, - model: None, - relay_thinking: tpl.thinking_policy, - }, - scratch::ProbeKind::Models, - ); - let builtin = tpl.builtin_models; - match scratch::classify(res.status) { - scratch::ProbeOutcome::Ok => { - let v: serde_json::Value = - serde_json::from_str(&res.body).map_err(|e| format!("解析模型列表失败:{e}"))?; - let live: Vec<(String, Option)> = v - .get("data") - .and_then(|d| d.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|m| { - let id = m.get("id")?.as_str()?.to_string(); - let st = m.get("supports_tools").and_then(|b| b.as_bool()); - Some((id, st)) - }) - .collect() - }) - .unwrap_or_default(); - if live.is_empty() { - return Ok(json!({ - "models": merge_and_sort_models(vec![], builtin), - "source": "builtin", "error_kind": null, "upstream_status": 200 - })); - } - Ok(json!({ - "models": merge_and_sort_models(live, builtin), - "source": "live", "error_kind": null, "upstream_status": 200 - })) - } - scratch::ProbeOutcome::Auth(code) => { - Err(format!("上游拒绝({code}),key 或权限可能有误。")) - } - // 非 200 且非 Auth:一律 builtin 兜底,但按语义分「发现不支持」(4xx) 与「网络/上游临时」(5xx/429/无响应), - // 供前端区分提示(spec v3 §3.4.3)。绝不把 Auth 混进来掩盖坏 key。 - other => { - let source = scratch::discovery_fallback_source(&other); - let error_kind = if source == "network" { - json!("network") - } else { - json!(null) - }; - Ok(json!({ - "models": merge_and_sort_models(vec![], builtin), - "source": source, - "error_kind": error_kind, - "upstream_status": res.status - })) - } - } -} - -/// 探测类型选择(纯函数,修真机 P1): -/// - 原生 adapter(deepseek/qwen)的 `/v1/models` 是【静态列表、不回源】,探不出坏 key,故一律用 -/// Message 探测(打 `/v1/messages` 会真发上游,坏 key → 401)。 -/// - relay:留空用 Models(`/v1/models` 回源即可验端点+鉴权);选了具体模型用 Message 验该模型。 -fn probe_kind_for(adapter: &str, model: &str) -> scratch::ProbeKind { - if is_native_adapter(adapter) { - return scratch::ProbeKind::Message; // native /v1/models 静态,只有 Message 打上游能验 key。 - } - probe_kind_for_model(model) -} - -/// 选了模型 → 验具体模型(POST /v1/messages);留空 → 验端点+鉴权(GET /v1/models)。 -fn probe_kind_for_model(model: &str) -> scratch::ProbeKind { - if model.trim().is_empty() { - scratch::ProbeKind::Models - } else { - scratch::ProbeKind::Message - } -} - -#[tauri::command] -fn stop_all( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, -) -> Result<(), String> { - lifecycle.with_serialized(|| { - lifecycle.bump_generation(); // 作废任何在途启动(防被停后又拿旧 key 复活) - let mut st = lock(&state); - let sandbox_res = stop_sandbox_inner(&app, &mut st); - kill_child(&mut st.proxy); - st.secret.clear(); - st.provider.clear(); - st.key_fp = 0; - sandbox_res.map_err(|e| format!("代理已停;但{e}真实实例 8765 未受影响。")) - }) -} - -#[tauri::command] -fn one_click_login( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: State<'_, lifecycle::Lifecycle>, -) -> Result { - lifecycle.with_serialized(|| one_click_login_inner(app, state, lifecycle.inner())) -} - -/// 一键开始本体(经串行器):确保代理在跑且健康 → 幂等虚拟登录 → 起沙箱 → 打开 UI。 -fn one_click_login_inner( - app: tauri::AppHandle, - state: State<'_, Mutex>, - lifecycle: &lifecycle::Lifecycle, -) -> Result { - // 1~3. 确保代理在跑且健康(内部已查生效 profile、key、探活)。带回本次是复用还是重启。 - let (pport, secret, proxy_action) = ensure_proxy(&app, &state, lifecycle)?; - - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - let sport = cfg.sandbox_port; - - let sbx_home = sandbox_home(); - let auth_dir = sbx_home.join(".claude-science"); - - // 沙箱已健康 → 但「daemon 活着」≠「登录态可用」:先只读校验虚拟登录是否自洽。 - if sandbox_running_ours(sport) { - if oauth_forge::login_intact(&auth_dir, "virtual@localhost.invalid", &sbx_home) { - let url = sandbox_url(sport); - { - let mut st = lock(&state); - st.sandbox_port = sport; - st.sandbox_url = Some(url.clone()); - } - let base = match proxy_action { - ProxyAction::Reused => "已在运行", - ProxyAction::Restarted => "已用新配置重启代理,Science 沿用不变", - }; - let msg = match open_in_browser(&url) { - Ok(()) => format!("{base},已重新打开 Science。"), - Err(_) => format!("{base},服务已就绪,请手动打开:{url}"), - }; - return Ok(json!({ "url": url, "msg": msg, "action": "reopened" })); - } - { - let mut st = lock(&state); - let _ = stop_sandbox_inner(&app, &mut st); - } - } - - // 沙箱没起 / 挂了 / 登录失效已停 → 需要 launch 资源,此时才定位。确保虚拟登录(幂等)+ launch。 - let root = asset_root(&app) - .ok_or("找不到 scripts/launch-virtual-sandbox.sh(打包资源或仓库根均未命中)。")?; - - let (forged, login_action) = - oauth_forge::ensure_virtual_login(&auth_dir, "virtual@localhost.invalid", &sbx_home) - .map_err(|e| format!("写虚拟登录失败:{e}"))?; - - let launch = root.join("scripts/launch-virtual-sandbox.sh"); - if !launch.is_file() { - return Err("找不到 scripts/launch-virtual-sandbox.sh。".into()); - } - - // 4. 起沙箱:脚本以 --detached 起 Science,然后返回。 - let proxy_url = format!("http://127.0.0.1:{pport}/{secret}"); - let logf = open_log("sandbox.log").map_err(|e| format!("建日志失败:{e}"))?; - { - use std::io::Write; - let mut lw = &logf; - let _ = writeln!( - lw, - "[oauth] 虚拟登录已就绪(Rust,零 node;action={:?}):auth_dir={} account={} org={} enc={}", - login_action, - forged.auth_dir.display(), - forged.account_uuid, - forged.org_uuid, - forged.enc_file.display() - ); - } - let logf2 = logf.try_clone().map_err(|e| e.to_string())?; - let status = Command::new("zsh") - .arg(&launch) - .arg("--port") - .arg(sport.to_string()) - .arg("--proxy-url") - .arg(&proxy_url) - .arg("--skip-oauth-forge") - .env("SANDBOX_HOME", sandbox_home()) - .stdout(Stdio::from(logf)) - .stderr(Stdio::from(logf2)) - .status() - .map_err(|e| format!("起沙箱失败:{e}"))?; - if !status.success() { - let tail = redact(&tail_file(&log_path("sandbox.log"), 600), &secret); - return Err(format!("起沙箱脚本失败。\n{tail}")); - } - - // 5. 轮询沙箱 /health 直到就绪或超时(~8s)。 - let mut ok = false; - for _ in 0..80 { - std::thread::sleep(Duration::from_millis(100)); - if proc::http_health(sport, None, 400) { - ok = true; - break; - } - } - if !ok { - let tail = redact(&tail_file(&log_path("sandbox.log"), 600), &secret); - { - let mut st = lock(&state); - let _ = stop_sandbox_inner(&app, &mut st); - } - return Err(format!( - "沙箱起后探活超时(端口 {sport})。已尝试停掉刚起的沙箱。\n{tail}" - )); - } - - // 5b. 身份确认:/health 200 只证明端口在服务,用 data-dir 强身份再确认一次。 - if !sandbox_running_ours(sport) { - { - let mut st = lock(&state); - let _ = stop_sandbox_inner(&app, &mut st); - } - return Err(format!( - "端口 {sport} 有服务响应,但按 data-dir 确认不是本沙箱 Science(疑似被其它服务占用)。已尝试停掉刚起的沙箱。" - )); - } - - // 6. 取 UI URL(登录态),交系统浏览器打开。 - let url = sandbox_url(sport); - { - let mut st = lock(&state); - st.sandbox_port = sport; - st.sandbox_url = Some(url.clone()); - } - let started = match login_action { - oauth_forge::LoginAction::Created => "已启动", - _ => "沙箱已重新启动,沿用原有对话", - }; - let msg = match open_in_browser(&url) { - Ok(()) => format!("{started}。"), - Err(_) => format!("{started},服务已就绪,请手动打开:{url}"), - }; - Ok(json!({ "url": url, "msg": msg, "action": "started" })) -} - -/// 从 `claude-science url` 的 stdout 里取**第一条**合法 http(s) URL。 -fn first_http_url(stdout: &str) -> Option { - for line in stdout.lines() { - let t = line.trim(); - if t.starts_with("http://") || t.starts_with("https://") { - let url = t.split_whitespace().next().unwrap_or(t); - return Some(url.to_string()); - } - } - None -} - -/// 取沙箱 UI 链接:` url --data-dir /.claude-science`,HOME 指向沙箱 HOME。 -fn sandbox_url(port: u16) -> String { - let home = sandbox_home(); - let data_dir = home.join(".claude-science"); - if Path::new(SCIENCE_BIN).is_file() { - if let Ok(out) = Command::new(SCIENCE_BIN) - .arg("url") - .arg("--data-dir") - .arg(&data_dir) - .env("HOME", &home) - .output() - { - let s = String::from_utf8_lossy(&out.stdout); - if let Some(url) = first_http_url(&s) { - return url; - } - } - } - format!("http://127.0.0.1:{port}") -} - -/// 判断「我们自己的」沙箱 Science 是否在跑(供一键健康分派)。优先用 Science 二进制按 -/// 【我们的 data-dir】查 `{"running":true}`(强身份);再叠加端口 /health 确认。 -fn sandbox_running_ours(port: u16) -> bool { - let home = sandbox_home(); - let data_dir = home.join(".claude-science"); - if Path::new(SCIENCE_BIN).is_file() { - match Command::new(SCIENCE_BIN) - .arg("status") - .arg("--data-dir") - .arg(&data_dir) - .env("HOME", &home) - .output() - { - Ok(out) => { - let s = String::from_utf8_lossy(&out.stdout); - let running = s.contains("\"running\":true") || s.contains("\"running\": true"); - return running && proc::http_health(port, None, 400); - } - Err(_) => return proc::http_health(port, None, 400), - } - } - proc::http_health(port, None, 400) -} - -#[tauri::command] -fn status(state: State<'_, Mutex>) -> serde_json::Value { - // 只在锁内取值,锁外做阻塞探活。 - let (pport, secret, sport, adapter, base_url) = { - let st = lock(&state); - let cfg = config::load_from(&config::default_dir()).unwrap_or_default(); - let pport = if st.proxy_port != 0 { - st.proxy_port - } else { - cfg.proxy_port - }; - let sport = if st.sandbox_port != 0 { - st.sandbox_port - } else { - cfg.sandbox_port - }; - // 上游灯读生效 profile 的 adapter/base_url;无生效配置 → 空(灯显黄,不误探)。 - let (adapter, base_url) = match cfg.active_profile() { - Some(p) => ( - templates::adapter_for(&p.template_id).to_string(), - p.base_url.clone(), - ), - None => (String::new(), String::new()), - }; - (pport, st.secret.clone(), sport, adapter, base_url) - }; - let proxy = if !secret.is_empty() && proc::http_health(pport, Some(&secret), 300) { - "green" - } else { - "amber" - }; - let sandbox = if sandbox_running_ours(sport) { - "green" - } else { - "amber" - }; - let uhost = upstream_host(&adapter, &base_url); - let upstream = if !uhost.is_empty() && proc::tcp_reachable(&uhost, 443, 500) { - "green" - } else { - "amber" - }; - json!({ "proxy": proxy, "sandbox": sandbox, "upstream": upstream }) -} - -#[tauri::command] -fn open_url(state: State<'_, Mutex>) -> Result<(), String> { - let url = { lock(&state).sandbox_url.clone() }; - let url = url.ok_or("还没有沙箱 URL,请先「一键开始」。")?; - open_in_browser(&url) -} - -#[tauri::command] -fn run_doctor(app: tauri::AppHandle) -> Result { - let root = asset_root(&app).ok_or("找不到 scripts/doctor.sh(打包资源或仓库根均未命中)。")?; - let cfg = config::load_from(&config::default_dir()).unwrap_or_default(); - let doctor = root.join("scripts/doctor.sh"); - // 生效 profile 的展示名(template_id)+ adapter + 有无 key;无生效配置则留空。 - let (provider_label, adapter, has_key) = match cfg.active_profile() { - Some(p) => ( - p.template_id.clone(), - templates::adapter_for(&p.template_id), - !p.api_key.is_empty(), - ), - None => (String::new(), "", false), - }; - let mut cmd = Command::new("bash"); - // 多 profile:传 template_id + adapter + key 有无(布尔)。doctor 不再按 provider 名写死、 - // 不再去 shell 环境找 key(key 存 config.json)。绝不把真实 key 值传进其环境。 - cmd.arg(&doctor) - .env("CSSWITCH_PROVIDER", &provider_label) - .env("CSSWITCH_ADAPTER", adapter) - .env("CSSWITCH_KEY_PRESENT", if has_key { "1" } else { "0" }) - .env("CSSWITCH_PROXY_PORT", cfg.proxy_port.to_string()) - .env("CSSWITCH_SANDBOX_PORT", cfg.sandbox_port.to_string()); - let out = cmd.output().map_err(|e| e.to_string())?; - let mut text = String::from_utf8_lossy(&out.stdout).to_string(); - let err = String::from_utf8_lossy(&out.stderr); - if !err.trim().is_empty() { - text.push_str("\n[stderr] "); - text.push_str(err.trim()); - } - Ok(text) -} - -/// 当前 app 版本(供前端「检查更新」与页脚版本号用)。 -#[tauri::command] -fn app_version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -/// 打开 GitHub Releases 页(检查更新时用系统浏览器打开,浏览器走用户自己的代理)。 -#[tauri::command] -fn open_release_page() -> Result<(), String> { - open_in_browser("https://github.com/SuperJJ007/CSSwitch/releases/latest") -} - -/// 打开「报 bug」页(预填 bug 模板);用系统浏览器,走用户自己的代理。 -#[tauri::command] -fn report_bug() -> Result<(), String> { - open_in_browser("https://github.com/SuperJJ007/CSSwitch/issues/new?template=bug_report.yml") -} - -/// 在访达里打开日志目录 `~/.csswitch/logs`,方便用户附到 bug 反馈里(先自查有无密钥)。 -#[tauri::command] -fn open_logs() -> Result<(), String> { - let dir = config::default_dir().join("logs"); - let _ = std::fs::create_dir_all(&dir); - Command::new("open") - .arg(&dir) - .status() - .map_err(|e| format!("打开日志目录失败:{e}"))?; - Ok(()) -} - -#[tauri::command] -fn quit_app(app: tauri::AppHandle, state: State<'_, Mutex>) -> Result<(), String> { - // 默认:退 app 停代理、保留沙箱运行(spec §5.1)。 - { - let mut st = lock(&state); - kill_child(&mut st.proxy); - st.secret.clear(); - } - app.exit(0); - Ok(()) -} - -// ---------- 入口 ---------- -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .manage(Mutex::new(AppState::default())) - .manage(lifecycle::Lifecycle::new()) - .invoke_handler(tauri::generate_handler![ - get_config, - list_templates, - set_settings, - set_mode, - open_official, - create_profile, - update_profile_metadata, - update_profile_connection, - clear_profile_key, - delete_profile, - set_active_profile, - start_proxy, - verify_key, - fetch_models, - stop_all, - one_click_login, - status, - open_url, - run_doctor, - app_version, - open_release_page, - report_bug, - open_logs, - quit_app - ]) - .setup(|app| { - // 正常桌面应用:进 Dock、走常规应用生命周期。窗口在 tauri.conf.json 里配了 - // decorations + visible + center,启动即居中弹出、可拖动。托盘图标已移除。 - - // 启动即触发一次 load:若是旧 v1 固定槽文件,这里完成 v1→v2 迁移 + 落盘 + 留 .v1.bak; - // 悬空 active 归一化为空。迁移逻辑并入 config::load_from(不再单独跑 relay_presets)。 - let _ = config::load_from(&config::default_dir()); - - // 关窗即退出:与「退出」按钮一致 —— 停代理、清 secret,保留沙箱运行(spec §5.1)。 - if let Some(win) = app.get_webview_window("main") { - let handle = app.handle().clone(); - win.on_window_event(move |ev| { - if let tauri::WindowEvent::CloseRequested { .. } = ev { - let state = handle.state::>(); - let mut st = lock(&state); - kill_child(&mut st.proxy); - st.secret.clear(); - } - }); - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} - -#[cfg(test)] -mod tests { - use super::{ - assert_format_supported, build_get_config, build_list_templates, clear_profile_key_inner, - create_profile_inner, decide_switch, delete_profile_inner, first_http_url, - health_timeout_reason, is_main_list_model, key_env_for_adapter, key_fingerprint, - merge_and_sort_models, nonactive_probe_verdict, parse_host, probe_kind_for, - probe_kind_for_model, proxy_args_for, proxy_fingerprint, redact, - reject_openai_custom_anthropic_base, relay_missing_base_url, relay_missing_model, - rollback_status_clause, sandbox_home, settings_change_needs_teardown, - should_scratch_candidate, should_write_back, skip_scratch_verify, - update_profile_connection_inner, update_profile_metadata_inner, upstream_host, - ConnectionEdit, SwitchOutcome, - }; - use crate::config; - - /// 每个测试用独立临时 `.csswitch` 目录(进程 id + 线程 id + 随机后缀),互不干扰。 - fn tmpdir_lib() -> std::path::PathBuf { - let base = std::env::temp_dir().join(format!("csswitch-lib-test-{}", std::process::id())); - let d = base.join(format!( - "{:?}-{}", - std::thread::current().id(), - config::new_id() - )); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d.join(".csswitch") - } - - // ---------- B2: proxy_args_for / assert_format_supported ---------- - #[test] - fn proxy_args_derive_adapter_and_key_env() { - use crate::config::Profile; - let ds = Profile { - template_id: "deepseek".into(), - api_format: "anthropic".into(), - base_url: "https://api.deepseek.com/anthropic".into(), - api_key: "sk-ds".into(), - ..Default::default() - }; - let a = proxy_args_for(&ds); - assert_eq!(a.adapter, "deepseek"); - assert_eq!(a.key_env, "DEEPSEEK_API_KEY"); - - let glm = Profile { - template_id: "glm".into(), - api_format: "anthropic".into(), - base_url: "https://open.bigmodel.cn/api/anthropic".into(), - api_key: "gk".into(), - model: "glm-5".into(), - ..Default::default() - }; - let b = proxy_args_for(&glm); - assert_eq!(b.adapter, "relay"); - assert_eq!(b.key_env, "CSSWITCH_RELAY_KEY"); - assert_eq!(b.base_url, "https://open.bigmodel.cn/api/anthropic"); - assert_eq!(b.model, "glm-5"); - - let custom_openai = Profile { - template_id: "custom-openai".into(), - api_format: "openai_chat".into(), - base_url: "https://open.bigmodel.cn/api/paas/v4".into(), - api_key: "ok".into(), - model: "glm-4.5".into(), - ..Default::default() - }; - let c = proxy_args_for(&custom_openai); - assert_eq!(c.adapter, "openai-custom"); - assert_eq!(c.key_env, "CSSWITCH_OPENAI_KEY"); - assert_eq!(c.base_url, "https://open.bigmodel.cn/api/paas/v4"); - assert_eq!(c.model, "glm-4.5"); - - let custom_responses = Profile { - template_id: "custom-openai-responses".into(), - api_format: "openai_responses".into(), - base_url: "https://api.openai.com/v1".into(), - api_key: "ok".into(), - model: "gpt-5.2".into(), - ..Default::default() - }; - let d = proxy_args_for(&custom_responses); - assert_eq!(d.adapter, "openai-responses"); - assert_eq!(d.key_env, "CSSWITCH_OPENAI_KEY"); - assert_eq!(d.base_url, "https://api.openai.com/v1"); - assert_eq!(d.model, "gpt-5.2"); - } - - #[test] - fn unsupported_api_format_is_rejected() { - use crate::config::Profile; - let p = Profile { - template_id: "custom".into(), - api_format: "gemini_native".into(), - base_url: "https://x/y".into(), - api_key: "k".into(), - ..Default::default() - }; - assert!(assert_format_supported(&p).is_err()); - let ok = Profile { - api_format: "anthropic".into(), - ..p.clone() - }; - assert!(assert_format_supported(&ok).is_ok()); - let ok2 = Profile { - api_format: "openai_chat".into(), - ..p - }; - assert!(assert_format_supported(&ok2).is_ok()); - let ok3 = Profile { - api_format: "openai_responses".into(), - ..ok2 - }; - assert!(assert_format_supported(&ok3).is_ok()); - } - - #[test] - fn custom_openai_rejects_anthropic_base_url() { - let err = reject_openai_custom_anthropic_base( - "custom-openai", - "https://api.moonshot.cn/anthropic", - ) - .unwrap_err(); - assert!(err.contains("自定义 Anthropic")); - assert!( - reject_openai_custom_anthropic_base("custom-openai", "https://api.moonshot.cn/v1",) - .is_ok() - ); - assert!(reject_openai_custom_anthropic_base( - "custom-openai-responses", - "https://api.moonshot.cn/anthropic", - ) - .is_err()); - assert!( - reject_openai_custom_anthropic_base("custom", "https://api.moonshot.cn/anthropic",) - .is_ok() - ); - } - - #[test] - fn key_env_for_adapter_maps_adapters() { - assert_eq!(key_env_for_adapter("deepseek"), "DEEPSEEK_API_KEY"); - assert_eq!(key_env_for_adapter("qwen"), "DASHSCOPE_API_KEY"); - assert_eq!(key_env_for_adapter("openai-custom"), "CSSWITCH_OPENAI_KEY"); - assert_eq!( - key_env_for_adapter("openai-responses"), - "CSSWITCH_OPENAI_KEY" - ); - assert_eq!(key_env_for_adapter("relay"), "CSSWITCH_RELAY_KEY"); - assert_eq!(key_env_for_adapter("anything-else"), "CSSWITCH_RELAY_KEY"); - } - - #[test] - fn proxy_fingerprint_includes_protocol_semantics() { - use crate::config::Profile; - let mut p = Profile { - template_id: "kimi".into(), - api_format: "anthropic".into(), - base_url: "https://same.example/anthropic".into(), - api_key: "same-key".into(), - model: "same-model".into(), - ..Default::default() - }; - let kimi_launch = proxy_args_for(&p); - let kimi_fp = proxy_fingerprint(&p, &kimi_launch); - - p.template_id = "custom".into(); - let custom_launch = proxy_args_for(&p); - let custom_fp = proxy_fingerprint(&p, &custom_launch); - assert_ne!( - kimi_fp, custom_fp, - "同 adapter/base/model/key 但模板语义不同,必须重启代理" - ); - } - - // ---------- P1-c: 端口变更是否需拆链路(纯函数,4 组合) ---------- - #[test] - fn settings_teardown_when_any_port_changes() { - assert!( - !settings_change_needs_teardown(18991, 18991, 8990, 8990), - "端口未变 → 不拆链路" - ); - assert!( - settings_change_needs_teardown(18991, 19000, 8990, 8990), - "代理端口变 → 拆(旧代理绑旧端口、沙箱烘旧 URL)" - ); - assert!( - settings_change_needs_teardown(18991, 18991, 8990, 9000), - "沙箱端口变 → 拆(旧沙箱在旧端口成孤儿)" - ); - assert!( - settings_change_needs_teardown(18991, 19000, 8990, 9000), - "都变 → 拆" - ); - } - - // ---------- P2-e: 回滚措辞如实(恢复失败不得谎称已回滚) ---------- - #[test] - fn rollback_clause_tells_truth_when_restore_failed() { - assert!( - rollback_status_clause(true).contains("已回滚"), - "恢复成功 → 说已回滚" - ); - let failed = rollback_status_clause(false); - assert!( - !failed.contains("已回滚到原配置"), - "恢复失败不得谎称已回滚到原配置" - ); - assert!(failed.contains("代理当前已停"), "如实说明代理已停"); - } - - // ---------- P2-d: 非 active「如实标记后保存」裁决(明确拒绝才拦;200=已校验;含糊/无响应=落盘但未校验) ---------- - #[test] - fn nonactive_probe_verdict_maps_outcomes() { - use crate::scratch::ProbeOutcome; - assert!( - nonactive_probe_verdict(&ProbeOutcome::Auth(401)) - .unwrap_err() - .contains("401"), - "401 明确鉴权失败 → 拦下不落盘" - ); - assert!( - nonactive_probe_verdict(&ProbeOutcome::ModelError(404)) - .unwrap_err() - .contains("404"), - "404 模型不被接受 → 拦下不落盘" - ); - assert_eq!( - nonactive_probe_verdict(&ProbeOutcome::Ok), - Ok(true), - "200 → 落盘且【已校验】" - ); - assert_eq!( - nonactive_probe_verdict(&ProbeOutcome::Ambiguous(Some(429))), - Ok(false), - "含糊(429) → best-effort 落盘但【未校验】" - ); - assert_eq!( - nonactive_probe_verdict(&ProbeOutcome::NoResponse), - Ok(false), - "无响应 → best-effort 落盘但【未校验】" - ); - } - - // ---------- B3: 切换事务决策(纯函数,3 分支) ---------- - #[test] - fn transaction_commits_only_when_healthy() { - // scratch ok + real ok → 提交 - assert_eq!(decide_switch(true, true), SwitchOutcome::Commit); - // scratch 校验失败 → 不起正式、不提交、旧态不动 - assert_eq!(decide_switch(false, false), SwitchOutcome::AbortBeforeStart); - assert_eq!(decide_switch(false, true), SwitchOutcome::AbortBeforeStart); - // scratch ok 但正式起/探活失败 → 杀候选、恢复旧、不提交 - assert_eq!(decide_switch(true, false), SwitchOutcome::RollbackToOld); - } - - // ---------- MP-2 fix [3]: 写回门纯函数(gen 同/异 × secret 同/异 4 组合) ---------- - #[test] - fn should_write_back_requires_both_gen_and_secret() { - // gen 同 + secret 同 → 写回(合法启动,未被取代) - assert!(should_write_back(5, 5, "sekret", "sekret")); - // gen 同 + secret 异 → 不写回(被并发另起用不同 secret 占了槽,冷启动双起窄窗) - assert!(!should_write_back(5, 5, "other", "sekret")); - // gen 异 + secret 同 → 不写回(被清 key/停/切 bump 取代) - assert!(!should_write_back(5, 6, "sekret", "sekret")); - // gen 异 + secret 异 → 不写回 - assert!(!should_write_back(5, 6, "other", "sekret")); - } - - // ---------- MP-2 fix [1]: 连接编辑 validate-before-persist 的字段应用逻辑(内存/落盘共用) ---------- - #[test] - fn connection_edit_apply_only_changes_provided_fields() { - use crate::config::Profile; - let mut p = Profile { - base_url: "old-url".into(), - api_format: "anthropic".into(), - model: "old-model".into(), - api_key: "old-key".into(), - ..Default::default() - }; - let edit = ConnectionEdit { - base_url: Some("new-url".into()), - api_format: None, // None = 不改 - model: Some("new-model".into()), - key: Some(String::new()), // 空 key = 不改(留占位不覆盖已存 key) - }; - edit.apply(&mut p); - assert_eq!(p.base_url, "new-url"); - assert_eq!(p.api_format, "anthropic", "None 字段不改"); - assert_eq!(p.model, "new-model"); - assert_eq!(p.api_key, "old-key", "空 key 不覆盖已存 key"); - - // 非空 key 覆盖;其余 None 不动。 - let edit2 = ConnectionEdit { - key: Some("new-key".into()), - ..Default::default() - }; - edit2.apply(&mut p); - assert_eq!(p.api_key, "new-key", "非空 key 覆盖"); - assert_eq!(p.base_url, "new-url", "None 字段不改"); - assert_eq!(p.model, "new-model", "None 字段不改"); - } - - // ---------- B4: profile CRUD *_inner ---------- - #[test] - fn create_profile_from_template_prefills() { - let d = tmpdir_lib(); - let id = - create_profile_inner(&d, "glm", "我的 GLM", Some("gk"), None, Some("glm-5.2")).unwrap(); - let cfg = config::load_from(&d).unwrap(); - let p = cfg.profile_by_id(&id).unwrap(); - assert_eq!(p.template_id, "glm"); - assert_eq!(p.name, "我的 GLM"); - assert_eq!(p.api_format, "anthropic"); - assert_eq!(p.base_url, "https://open.bigmodel.cn/api/anthropic"); - assert_eq!(p.api_key, "gk"); - assert_eq!(cfg.active_id, "", "新建不自动生效"); - } - - #[test] - fn create_relay_without_model_is_rejected() { - // 修 #9 P1-a:后端命令层直接创建 relay/自定义端点空 model 也被拦(不变量不可绕过)。 - let d = tmpdir_lib(); - let e = create_profile_inner(&d, "glm", "GLM", Some("gk"), None, None); - assert!(e.is_err(), "relay 空 model 应拒绝创建"); - assert!(e.unwrap_err().contains("模型")); - // native 不受约束(model 可空)。 - assert!(create_profile_inner(&d, "deepseek", "DS", Some("gk"), None, None).is_ok()); - } - - #[test] - fn update_metadata_does_not_touch_key() { - let d = tmpdir_lib(); - let id = - create_profile_inner(&d, "glm", "GLM", Some("secret9"), None, Some("glm-5.2")).unwrap(); - update_profile_metadata_inner(&d, &id, "改名", Some("备注")).unwrap(); - let cfg = config::load_from(&d).unwrap(); - let p = cfg.profile_by_id(&id).unwrap(); - assert_eq!(p.name, "改名"); - assert_eq!(p.notes.as_deref(), Some("备注")); - assert_eq!(p.api_key, "secret9", "元数据编辑不动 key"); - } - - #[test] - fn clear_key_empties_key_and_drops_backup() { - let d = tmpdir_lib(); - let id = create_profile_inner(&d, "glm", "GLM", Some("secretTAIL"), None, Some("glm-5.2")) - .unwrap(); - config::write_rolling_backup(&d).ok(); - clear_profile_key_inner(&d, &id).unwrap(); - let cfg = config::load_from(&d).unwrap(); - assert_eq!(cfg.profile_by_id(&id).unwrap().api_key, ""); - assert!(!d.join("config.json.bak").exists(), "清 key 后净化滚动备份"); - } - - #[test] - fn delete_active_clears_active() { - let d = tmpdir_lib(); - let id = create_profile_inner(&d, "glm", "GLM", Some("k"), None, Some("glm-5.2")).unwrap(); - config::update(&d, |c| c.active_id = id.clone()).unwrap(); - delete_profile_inner(&d, &id).unwrap(); - let cfg = config::load_from(&d).unwrap(); - assert!(cfg.profile_by_id(&id).is_none()); - assert_eq!(cfg.active_id, "", "删 active → 置空"); - } - - #[test] - fn update_connection_rejects_unsupported_format() { - let d = tmpdir_lib(); - let id = - create_profile_inner(&d, "custom", "C", None, Some("https://x/y"), Some("m")).unwrap(); - let e = update_profile_connection_inner( - &d, - &id, - Some("https://x/y"), - Some("gemini_native"), - None, - None, - ); - assert!(e.is_err()); - } - - // ---------- MP-2 Minor [4]: 未命中 id → Err(不静默 Ok) ---------- - #[test] - fn update_metadata_unknown_id_errors() { - let d = tmpdir_lib(); - create_profile_inner(&d, "glm", "GLM", Some("k"), None, Some("glm-5.2")).unwrap(); - let e = update_profile_metadata_inner(&d, "no-such-id", "改名", None); - assert!(e.is_err(), "未命中 id 应报错,而非静默成功"); - assert!(e.unwrap_err().contains("找不到 profile")); - } - - #[test] - fn update_connection_unknown_id_errors() { - let d = tmpdir_lib(); - create_profile_inner(&d, "glm", "GLM", Some("k"), None, Some("glm-5.2")).unwrap(); - let e = update_profile_connection_inner( - &d, - "no-such-id", - Some("https://x/y"), - None, - None, - None, - ); - assert!(e.is_err(), "未命中 id 应报错,而非静默成功"); - assert!(e.unwrap_err().contains("找不到 profile")); - } - - // ---------- B5: build_get_config / build_list_templates ---------- - #[test] - fn get_config_masks_keys_and_lists_profiles() { - let d = tmpdir_lib(); - let id = create_profile_inner( - &d, - "glm", - "GLM", - Some("sk-longsecret9999"), - None, - Some("glm-5.2"), - ) - .unwrap(); - let v = build_get_config(&d).unwrap(); - assert_eq!(v["schema_version"], 2); - let arr = v["profiles"].as_array().unwrap(); - let p = arr.iter().find(|p| p["id"] == id).unwrap(); - assert!(p["key"].as_str().unwrap().ends_with("9999")); - assert!( - !p["key"].as_str().unwrap().contains("longsecret"), - "只回掩码" - ); - assert!( - p.get("api_key").is_none() || p["api_key"].is_null(), - "全 key 不出后端" - ); - } - - #[test] - fn get_config_returns_notes_so_rename_does_not_wipe_them() { - // M1 回归:build_get_config 必须回传 notes,否则前端读到空、下次改名把备注静默清掉。 - let d = tmpdir_lib(); - let id = create_profile_inner(&d, "glm", "GLM", Some("k"), None, Some("glm-5.2")).unwrap(); - update_profile_metadata_inner(&d, &id, "GLM", Some("我的备注")).unwrap(); - let v = build_get_config(&d).unwrap(); - let p = v["profiles"] - .as_array() - .unwrap() - .iter() - .find(|p| p["id"] == id) - .unwrap(); - assert_eq!(p["notes"], "我的备注", "notes 必须随 get_config 回传"); - } - - #[test] - fn list_templates_has_eleven() { - let v = build_list_templates(); - assert_eq!(v.len(), 11); - assert!(v.iter().any(|t| t["id"] == "custom")); - assert!(v.iter().any(|t| t["id"] == "custom-openai")); - assert!(v.iter().any(|t| t["id"] == "custom-openai-responses")); - assert!(v.iter().any(|t| t["id"] == "kimi")); - assert!(v.iter().any(|t| t["id"] == "minimax")); - } - - // ---------- 既有纯逻辑不变量(保留) ---------- - #[test] - fn first_http_url_takes_only_first_valid_url() { - let multi = "http://127.0.0.1:8990/setup?nonce=abc123\n\ - This is a single-use link, expires in 60 seconds."; - assert_eq!( - first_http_url(multi).as_deref(), - Some("http://127.0.0.1:8990/setup?nonce=abc123"), - ); - let inline = "https://x.example/y?z=1 (single-use)"; - assert_eq!( - first_http_url(inline).as_deref(), - Some("https://x.example/y?z=1") - ); - let lead = "Open this link in your browser:\nhttp://127.0.0.1:8990/a"; - assert_eq!( - first_http_url(lead).as_deref(), - Some("http://127.0.0.1:8990/a") - ); - assert_eq!(first_http_url("no url here\nnor here"), None); - assert_eq!( - first_http_url("http://127.0.0.1:8990").as_deref(), - Some("http://127.0.0.1:8990") - ); - } - - #[test] - fn parse_host_extracts_host_from_relay_base_url() { - assert_eq!( - parse_host("https://byteswarm.ai/claude").as_deref(), - Some("byteswarm.ai") - ); - assert_eq!( - parse_host("http://127.0.0.1:8080/v1").as_deref(), - Some("127.0.0.1") - ); - assert_eq!( - parse_host("https://relay.example.com:8443").as_deref(), - Some("relay.example.com") - ); - assert_eq!(parse_host("byteswarm.ai/claude"), None); - assert_eq!(parse_host(""), None); - } - - #[test] - fn upstream_host_by_adapter() { - assert_eq!(upstream_host("deepseek", ""), "api.deepseek.com"); - assert_eq!(upstream_host("qwen", ""), "dashscope.aliyuncs.com"); - assert_eq!( - upstream_host("openai-custom", "https://open.bigmodel.cn/api/paas/v4"), - "open.bigmodel.cn" - ); - assert_eq!( - upstream_host("relay", "https://open.bigmodel.cn/api/anthropic"), - "open.bigmodel.cn" - ); - assert_eq!(upstream_host("", ""), "", "无生效配置 → 空(灯显黄)"); - } - - #[test] - fn main_list_model_matches_family_plus_digit() { - assert!(is_main_list_model("claude-opus-4-8")); - assert!(is_main_list_model("claude-sonnet-5")); - assert!(is_main_list_model("claude-haiku-4-5-20251001")); - assert!(!is_main_list_model("claude-3-5-sonnet-20241022")); - assert!(!is_main_list_model("claude-fable-5")); - assert!(!is_main_list_model("gpt-4o")); - } - - #[test] - fn redact_scrubs_secret_and_is_noop_when_empty() { - assert_eq!( - redact("推理指向 http://127.0.0.1:18991/abcd1234 尾巴", "abcd1234"), - "推理指向 http://127.0.0.1:18991/**** 尾巴" - ); - assert_eq!(redact("原样返回", ""), "原样返回"); - assert!(!redact("leak abcd1234 leak abcd1234", "abcd1234").contains("abcd1234")); - } - - #[test] - fn key_fingerprint_stable_and_distinct() { - assert_eq!(key_fingerprint("sk-aaaa"), key_fingerprint("sk-aaaa")); - assert_ne!(key_fingerprint("sk-aaaa"), key_fingerprint("sk-bbbb")); - assert_ne!(key_fingerprint(""), key_fingerprint("x")); - } - - #[test] - fn sandbox_home_is_writable_under_config_dir() { - let h = sandbox_home(); - assert!(h.ends_with("sandbox/home"), "应以 sandbox/home 结尾:{h:?}"); - assert!( - h.to_string_lossy().contains(".csswitch"), - "应在 .csswitch 下:{h:?}" - ); - } - - #[test] - fn merge_and_sort_prefers_tools_then_dedupes_builtin() { - let live = vec![ - ("m-notools".to_string(), Some(false)), - ("m-tools".to_string(), Some(true)), - ("m-unknown".to_string(), None), - ]; - let out = merge_and_sort_models(live, &["m-tools", "m-builtin-only"]); - let ids: Vec = out - .iter() - .map(|v| v.get("id").unwrap().as_str().unwrap().to_string()) - .collect(); - assert_eq!(ids[0], "m-tools"); - assert!(ids.contains(&"m-builtin-only".to_string())); - assert_eq!(ids.iter().filter(|i| *i == "m-tools").count(), 1, "去重"); - assert_eq!(ids.last().unwrap(), "m-notools"); - } - - #[test] - fn probe_kind_picks_message_when_model_set() { - assert!(matches!( - probe_kind_for_model("mimo-v2.5-pro"), - crate::scratch::ProbeKind::Message - )); - assert!(matches!( - probe_kind_for_model(""), - crate::scratch::ProbeKind::Models - )); - } - - // ---------- 修真机 P1:native adapter 上游校验(GPT 验收报告 RM-06) ---------- - - #[test] - fn native_probe_uses_message_since_native_models_is_static() { - // native 的 /v1/models 是静态列表、探不出坏 key,故一律用 Message(打上游 /v1/messages)。 - assert!(matches!( - probe_kind_for("deepseek", ""), - crate::scratch::ProbeKind::Message - )); - assert!(matches!( - probe_kind_for("qwen", ""), - crate::scratch::ProbeKind::Message - )); - // relay:空 model 用 Models(/v1/models 回源即验鉴权);选了 model 用 Message 验该模型。 - assert!(matches!( - probe_kind_for("relay", ""), - crate::scratch::ProbeKind::Models - )); - assert!(matches!( - probe_kind_for("relay", "m1"), - crate::scratch::ProbeKind::Message - )); - } - - #[test] - fn native_adapter_no_longer_bypasses_upstream_verify() { - // 只有显式 skip_verify 才跳过;native 不再是豁免条件(旧行为的核心漏洞)。 - assert!( - !skip_scratch_verify(true, false), - "native 不得再豁免上游校验" - ); - assert!(!skip_scratch_verify(false, false)); - assert!(skip_scratch_verify(false, true), "显式 skip_verify 才跳"); - assert!(skip_scratch_verify(true, true)); - } - - #[test] - fn native_candidate_is_upstream_validated_even_without_base_url() { - // 非 active 编辑:native 即便 base_url 空也要验(走硬编码官方端点)。 - assert!(should_scratch_candidate("deepseek", "sk-x", "")); - assert!(should_scratch_candidate("qwen", "sk-x", "")); - // relay 仍需 base_url;空 key 一律免验。 - assert!(!should_scratch_candidate("relay", "sk-x", "")); - assert!(should_scratch_candidate("relay", "sk-x", "https://r")); - assert!(!should_scratch_candidate("deepseek", "", "")); - } - - #[test] - fn relay_empty_base_url_is_rejected_before_save() { - // 修 P2:relay/自定义端点空(或纯空白)base_url → 拦下,不落盘。 - assert!(relay_missing_base_url("relay", "")); - assert!(relay_missing_base_url("glm", " ")); - assert!(relay_missing_base_url("custom", "")); - // 带地址的 relay 放行。 - assert!(!relay_missing_base_url("relay", "https://r")); - // native 走硬编码端点,空 base_url 无妨 → 不拦。 - assert!(!relay_missing_base_url("deepseek", "")); - assert!(!relay_missing_base_url("qwen", "")); - } - - #[test] - fn relay_empty_model_is_rejected() { - // 修 #9 P1-a:relay/自定义端点空(或纯空白)model → 拦下(无 model 则无 force → 退回 passthrough)。 - assert!(relay_missing_model("relay", "")); - assert!(relay_missing_model("glm", " ")); - assert!(relay_missing_model("custom", "")); - assert!(!relay_missing_model("relay", "glm-5.2")); - // native 走内置映射/硬编码端点,model 可空 → 不拦。 - assert!(!relay_missing_model("deepseek", "")); - assert!(!relay_missing_model("qwen", "")); - } - - #[test] - fn health_timeout_reason_flags_port_conflict_and_never_blames_key() { - // 端口占用:明确报占用、带端口号,绝不提「key 无效」。 - let occ = health_timeout_reason(18991, "OSError: [Errno 48] Address already in use"); - assert!(occ.contains("18991")); - assert!(occ.contains("占用"), "应明确报端口占用:{occ}"); - assert!(!occ.contains("key"), "端口占用不该扯上 key:{occ}"); - // 其它探活失败(依赖缺失等):本地探活与 key 有效性无关,不得说「key 无效」。 - let generic = health_timeout_reason(18991, "ModuleNotFoundError: No module named 'x'"); - assert!( - !generic.contains("key 无效"), - "本地探活超时与 key 有效性无关:{generic}" - ); - } -} +// 跨平台文件权限抽象:Unix 下提供真实的 0600/0700 权限,Windows 下为 no-op。 +mod fs_ext; +// 远程服务器管理:SSH 连接、Profile 存储、远程命令(跨平台,无 Tauri 依赖)。 +pub mod remote; +// 远程 Tauri commands — 仅 desktop feature 编译。 +#[cfg(feature = "desktop")] +mod remote_commands; + +// ---- desktop feature gate ---- +// tauri 相关代码在 lib_tauri.rs 中,仅 desktop feature 启用时编译。 +// csswitch-helper 编译 (`--no-default-features`) 时跳过此 include。 +#[cfg(feature = "desktop")] +include!("lib_tauri.rs"); diff --git a/desktop/src-tauri/src/lib_tauri.rs b/desktop/src-tauri/src/lib_tauri.rs new file mode 100644 index 0000000..603f308 --- /dev/null +++ b/desktop/src-tauri/src/lib_tauri.rs @@ -0,0 +1,1980 @@ +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +// 跨平台文件权限操作(Unix 设权限,Windows no-op)。 +use crate::fs_ext::{open_log_file, set_file_permissions}; +use serde::Deserialize; +use serde_json::json; +use tauri::{Manager, State}; + +/// Claude Science 二进制路径,仅 macOS 本地模式有效。 +#[cfg(target_os = "macos")] +const SCIENCE_BIN: &str = "/Applications/Claude Science.app/Contents/Resources/bin/claude-science"; + +#[derive(Default)] +struct AppState { + proxy: Option, + proxy_port: u16, + secret: String, + provider: String, + /// 当前代理进程所用 key 的非加密指纹(仅内存、绝不落盘/打印)。 + /// 换 key 后指纹变化 → 触发重启,避免复用带旧 key 的代理。 + key_fp: u64, + sandbox: Option, + sandbox_port: u16, + sandbox_url: Option, +} + +/// key 的非加密指纹(FNV-1a 64-bit),只用于判断「key 是否变了」。绝不打印、绝不落盘。 +/// 使用 FNV-1a 而非 std DefaultHasher,确保跨 Rust 版本哈希值稳定,避免工具链升级后误判 key 变化。 +fn key_fingerprint(s: &str) -> u64 { + let mut hash: u64 = 0xcbf29ce484222325; + for byte in s.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +// ---------- profile / adapter 元信息 ---------- +fn key_env_for_adapter(adapter: &str) -> &'static str { + match adapter { + "deepseek" => "DEEPSEEK_API_KEY", + "qwen" => "DASHSCOPE_API_KEY", + "openai-custom" | "openai-responses" => "CSSWITCH_OPENAI_KEY", + _ => "CSSWITCH_RELAY_KEY", + } +} + +fn is_native_adapter(adapter: &str) -> bool { + adapter == "deepseek" || adapter == "qwen" +} + +fn is_openai_adapter(adapter: &str) -> bool { + matches!(adapter, "openai-custom" | "openai-responses") +} + +fn looks_like_anthropic_endpoint(base_url: &str) -> bool { + base_url + .trim() + .trim_end_matches('/') + .to_ascii_lowercase() + .contains("/anthropic") +} + +fn reject_openai_custom_anthropic_base(template_id: &str, base_url: &str) -> Result<(), String> { + if matches!(template_id, "custom-openai" | "custom-openai-responses") + && looks_like_anthropic_endpoint(base_url) + { + Err("这个地址看起来是 Anthropic 兼容端点。请改选「自定义 Anthropic」,或使用 OpenAI 兼容 base root(如 https://api.moonshot.cn/v1)。".to_string()) + } else { + Ok(()) + } +} + +fn parse_host(url: &str) -> Option { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host = rest.split(['/', ':', '?', '#']).next().unwrap_or(""); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +fn upstream_host(adapter: &str, base_url: &str) -> String { + match adapter { + "deepseek" => "api.deepseek.com".to_string(), + "qwen" => "dashscope.aliyuncs.com".to_string(), + _ => parse_host(base_url).unwrap_or_default(), + } +} + +struct ProxyLaunch { + adapter: String, + base_url: String, + model: String, + key: String, + key_env: &'static str, + thinking_policy: &'static str, +} + +fn proxy_launch_for(profile: &config::Profile) -> ProxyLaunch { + let adapter = templates::adapter_for(&profile.template_id).to_string(); + ProxyLaunch { + key_env: key_env_for_adapter(&adapter), + adapter, + base_url: profile.base_url.clone(), + model: profile.model.clone(), + key: profile.api_key.clone(), + thinking_policy: templates::thinking_policy_for(&profile.template_id), + } +} + +fn assert_profile_runnable(profile: &config::Profile) -> Result<(), String> { + match profile.api_format.as_str() { + "anthropic" | "openai_chat" | "openai_responses" => {} + other => { + return Err(format!( + "api_format `{other}` 暂不支持,请选 anthropic、openai_chat 或 openai_responses。" + )); + } + } + let launch = proxy_launch_for(profile); + if launch.key.trim().is_empty() { + return Err("当前 Profile 未填写 API Key。请填写后重试。".into()); + } + if !is_native_adapter(&launch.adapter) { + if launch.base_url.trim().is_empty() + || !(launch.base_url.starts_with("http://") || launch.base_url.starts_with("https://")) + { + return Err("relay 配置需要 http(s):// 开头的 base_url。".into()); + } + if launch.model.trim().is_empty() { + return Err("relay 配置需要选择或填写模型。".into()); + } + } + Ok(()) +} + +// ---------- 路径与日志 ---------- +/// 定位 CSSwitch 仓库根(含 proxy/csswitch_proxy.py)。优先 CSSWITCH_REPO, +/// 否则从可执行文件与当前目录逐级上溯。找不到返回 None。 +fn repo_root() -> Option { + let marker = Path::new("proxy/csswitch_proxy.py"); + // 显式指定优先:规范化后再判定,避免相对/软链歧义。 + if let Some(r) = std::env::var_os("CSSWITCH_REPO") { + if let Ok(p) = std::fs::canonicalize(PathBuf::from(r)) { + if p.join(marker).is_file() { + return Some(p); + } + } + } + // 否则只从【可执行文件位置】上溯。刻意不看 current_dir:启动目录可被影响, + // 若据此找到别处的 csswitch_proxy.py,会把带 key 的环境交给来路不明的脚本。 + if let Ok(exe) = std::env::current_exe() { + let mut dir: Option<&Path> = exe.parent(); + while let Some(d) = dir { + if d.join(marker).is_file() { + return Some(d.to_path_buf()); + } + dir = d.parent(); + } + } + None +} + +/// 定位「资源根」(含 proxy/、scripts/)。打包成 .app 后,proxy/ 与 scripts/ 被 +/// bundle 进 `Contents/Resources`;开发态则回退到仓库根。找不到返回 None。 +/// 这样从 Finder 启动的正式 .app 也能找到代理脚本(修 P1-1)。 +fn asset_root(app: &tauri::AppHandle) -> Option { + let marker = Path::new("proxy/csswitch_proxy.py"); + // 打包态:Tauri 资源目录。 + if let Ok(res) = app.path().resource_dir() { + if res.join(marker).is_file() { + return Some(res); + } + } + // 开发态:从可执行文件位置上溯(见 repo_root 注释,刻意不看 current_dir)。 + repo_root() +} + +/// 沙箱可写工作目录(独立 HOME):`~/.csswitch/sandbox/home`。 +/// 仅 macOS 本地模式有效(依赖 SCIENCE_BIN 和沙箱脚本)。 +/// 打包后资源目录只读,沙箱状态(虚拟登录、克隆运行时、钥匙串)必须落在可写处; +/// 该路径同时交给 launch/stop 脚本(`SANDBOX_HOME` 环境变量)与取 URL 逻辑,三者一致。 +#[cfg(target_os = "macos")] +fn sandbox_home() -> PathBuf { + config::default_dir().join("sandbox").join("home") +} + +fn log_path(name: &str) -> PathBuf { + config::default_dir().join("logs").join(name) +} + +/// 打开(truncate)一个子进程日志文件,父目录 0700、文件 0600(防同机其它用户读到 secret 尾巴)。 +/// 跨平台:Unix 用 `O_NOFOLLOW` 防符号链接跟随;Windows 无此概念,仅做普通 open。 +/// 注意:symlink 检查 `config::assert_not_symlink` 本身在所有平台可用 +/// (`std::fs::symlink_metadata` + `is_symlink()` 是跨平台的)。 +fn open_log(name: &str) -> std::io::Result { + let p = log_path(name); + if let Some(parent) = p.parent() { + config::assert_not_symlink(parent)?; + std::fs::create_dir_all(parent)?; + let _ = set_file_permissions(parent, 0o700); + } + // 日志路径不许是符号链接:否则 truncate+写会覆盖链接目标文件(修 P2-1)。 + config::assert_not_symlink(&p)?; + let f = open_log_file(&p)?; + // 文件已存在时 mode 不复位,显式再夹一次。 + let _ = set_file_permissions(&p, 0o600); + Ok(f) +} + +/// 把字符串里的 secret 明文替换成 ****,用于任何要回显给前端的错误尾巴。 +fn redact(s: &str, secret: &str) -> String { + if secret.is_empty() { + s.to_string() + } else { + s.replace(secret, "****") + } +} + +fn tail_file(path: &Path, max: usize) -> String { + match std::fs::read(path) { + Ok(b) => { + let start = b.len().saturating_sub(max); + String::from_utf8_lossy(&b[start..]).trim().to_string() + } + Err(_) => String::new(), + } +} + +fn kill_child(slot: &mut Option) { + if let Some(mut c) = slot.take() { + let _ = c.kill(); + let _ = c.wait(); + } +} + +/// 取锁并从 poison 中恢复:某线程持锁时 panic 不应把整个 app 卡死。 +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, AppState> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// 用系统浏览器打开 URL。 +/// 跨平台:macOS 用 `open` 命令,Windows 用 `cmd /c start`(或 Tauri opener 插件)。 +/// 校验退出码:非零视为失败(P2c)。 +fn open_in_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let st = Command::new("open") + .arg(url) + .status() + .map_err(|e| format!("打开浏览器失败:{e}"))?; + if !st.success() { + return Err(format!("open 非零退出({:?})", st.code())); + } + } + #[cfg(target_os = "windows")] + { + let st = Command::new("cmd") + .args(["/c", "start", url]) + .status() + .map_err(|e| format!("打开浏览器失败:{e}"))?; + if !st.success() { + return Err(format!("start 非零退出({:?})", st.code())); + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + // Linux 等其他平台:尝试 xdg-open + let st = Command::new("xdg-open") + .arg(url) + .status() + .map_err(|e| format!("打开浏览器失败:{e}"))?; + if !st.success() { + return Err(format!("xdg-open 非零退出({:?})", st.code())); + } + } + Ok(()) +} + +// ---------- 代理生命周期核心 ---------- +/// 转义 ERE(extended regex)元字符,让路径按字面参与 `pkill -f` 匹配(避免路径里的 +/// `.`/`(`/`[` 等被当作正则、误配或失配)。 +/// 仅在 Unix 平台的 ensure_proxy 中被调用(pkill 为 Unix 专有)。 +/// 非 Unix 平台未使用,保留以备将来跨平台进程管理需求。 +#[allow(dead_code)] +fn ere_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 8); + for c in s.chars() { + if "\\.^$*+?()[]{}|".contains(c) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// 本次 ensure_proxy 对代理做了什么(供一键据实提示)。 +#[derive(Clone, Copy, PartialEq)] +enum ProxyAction { + Reused, // 端口+provider+key 指纹一致且健康,原样复用 + Restarted, // 首次起 / 换 key / 换 provider / 不健康,重起了代理 +} + +/// 确保代理在跑且健康;返回 (端口, secret, 本次动作)。幂等:已健康则复用。 +fn ensure_proxy( + app: &tauri::AppHandle, + state: &State<'_, Mutex>, + lifecycle: &lifecycle::Lifecycle, +) -> Result<(u16, String, ProxyAction), String> { + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let profile = cfg.active_profile().cloned() + .ok_or("没有生效的配置 Profile。请先「+ 新建」或「设为当前」。")?; + assert_profile_runnable(&profile)?; + let launch = proxy_launch_for(&profile); + let key_fp = key_fingerprint(&launch.key); + let port = cfg.proxy_port; + let root = asset_root(app) + .ok_or("找不到代理脚本 proxy/csswitch_proxy.py(打包资源或仓库根均未命中)。开发态可设 CSSWITCH_REPO。")?; + let py = proc::find_exe("python3") + .ok_or("缺少依赖 python3(起翻译代理需要)。已查 PATH、常见目录与登录 shell 仍未找到;macOS 一般自带 /usr/bin/python3(装 Xcode 命令行工具:xcode-select --install)。")?; + + // path-secret:**持久化复用**。已在跑的沙箱把该 secret 嵌进了 ANTHROPIC_BASE_URL, + // 若每次起代理都换 secret,代理一重启(换 key/换 provider/重开 app)沙箱就会拿旧 secret + // 打到新代理 → 全部 403(修 P1:代理重启后沙箱失联)。故从 config 读稳定 secret, + // 首次为空才生成一次并写回,之后所有代理进程都复用它。 + let secret = if !cfg.secret.is_empty() { + cfg.secret.clone() + } else { + let s = proc::gen_secret().map_err(|e| format!("无法生成安全 secret:{e}"))?; + let s2 = s.clone(); + config::update(&dir, move |c| c.secret = s2).map_err(|e| e.to_string())?; + s + }; + + let generation = lifecycle.current_generation(); + + // 整个「检查 → 清残留 → 起进程 → 记账」在同一把锁内完成,避免并发双击时 + // 两路都判定「没健康代理」各起一个、后者覆盖前者的 Child 句柄导致前者被孤儿泄漏。 + { + let mut st = lock(state); + // 幂等:已在跑且健康、且【端口 + provider + key 指纹】都一致才复用。 + // 只比端口会在「换 provider / 换 key」后误用带旧配置的代理(修 P1-2)。 + if st.proxy.is_some() + && st.proxy_port == port + && st.provider == launch.adapter + && st.key_fp == key_fp + && proc::http_health(port, Some(&st.secret), 500) + { + return Ok((port, st.secret.clone(), ProxyAction::Reused)); + } + // 清残留(换端口/换 provider/换 key/不健康)。 + kill_child(&mut st.proxy); + let script = root.join("proxy/csswitch_proxy.py"); + // 再清掉上次会话遗留、绑在同端口上的孤儿代理:崩溃或强退不会触发本进程的 kill, + // 孤儿仍占着端口 → 新代理绑不上(Errno 48)→ 探活超时。 + // 收紧(P2 GPT 复审):匹配【本安装的绝对脚本路径】+ 端口,而非仅「脚本名+端口」, + // 避免误杀另一个 checkout / 用户手启的同名代理。路径里的正则元字符转义按字面匹配。 + // 跨平台:`pkill` 仅 Unix 可用;Windows 上孤儿进程由系统自动回收,且远程模式为主要场景。 + #[cfg(unix)] + { + let pat = format!("{}.*--port {port}", ere_escape(&script.to_string_lossy())); + let _ = Command::new("pkill").arg("-f").arg(&pat).status(); + } + + let logf = open_log("proxy.log").map_err(|e| format!("建日志失败:{e}"))?; + let logf2 = logf.try_clone().map_err(|e| e.to_string())?; + let mut cmd = Command::new(&py); + cmd.arg(&script) + .arg("--provider") + .arg(&launch.adapter) + .arg("--port") + .arg(port.to_string()) + .arg("--auth-token") + .arg(&secret) + // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 + .env(launch.key_env, &launch.key); + if !is_native_adapter(&launch.adapter) { + if is_openai_adapter(&launch.adapter) { + cmd.env("CSSWITCH_OPENAI_BASE_URL", &launch.base_url); + if !launch.model.is_empty() { + cmd.env("CSSWITCH_OPENAI_MODEL", &launch.model); + } + } else { + cmd.env("CSSWITCH_RELAY_BASE_URL", &launch.base_url); + if !launch.model.is_empty() { + cmd.env("CSSWITCH_RELAY_MODEL", &launch.model); + } + if !launch.thinking_policy.is_empty() { + cmd.env("CSSWITCH_RELAY_THINKING", launch.thinking_policy); + } + } + } + let child = cmd + .stdout(Stdio::from(logf)) + .stderr(Stdio::from(logf2)) + .spawn() + .map_err(|e| format!("启动代理失败:{e}"))?; + st.proxy = Some(child); + st.proxy_port = port; + st.secret = secret.clone(); + st.provider = launch.adapter.clone(); + st.key_fp = key_fp; + } + + // 探活最多 ~4s(锁外,不阻塞 status 等命令)。 + let mut ok = false; + for _ in 0..40 { + std::thread::sleep(Duration::from_millis(100)); + if proc::http_health(port, Some(&secret), 400) { + ok = true; + break; + } + } + if !ok { + let mut st = lock(state); + // 只在仍是本次起的代理时才清(secret 匹配),避免误杀并发重启起来的新代理。 + if st.secret == secret { + kill_child(&mut st.proxy); + } + let tail = redact(&tail_file(&log_path("proxy.log"), 500), &secret); + return Err(format!( + "代理起后探活超时(端口 {port} 可能被占用,或 key 无效)。\n{tail}" + )); + } + if lifecycle.current_generation() != generation { + let mut st = lock(state); + if st.secret == secret { + kill_child(&mut st.proxy); + st.provider.clear(); + st.key_fp = 0; + } + return Err("代理启动已被更新的停止/切换操作取代,请重试。".to_string()); + } + Ok((port, secret, ProxyAction::Restarted)) +} + +/// 停沙箱。返回 Err 表示 stop 脚本非零退出(Science 可能没停干净), +/// 调用方据此如实报告,不再无条件报「已停止」(修 P1 停止虚假成功)。 +/// 仅 macOS 有效;非 macOS 上本地沙箱不存在,直接清 state 返回 Ok。 +fn stop_sandbox_inner(app: &tauri::AppHandle, st: &mut AppState) -> Result<(), String> { + // 沙箱由脚本以 --detached 起 Science,本进程持有的是脚本 child(已退出)。 + // 真正停 Science 要调 stop 脚本(按 data-dir,绝不碰真实 8765)。 + // 修 P1(GPT 复审):定位不到资源根 / 停止脚本时,绝不静默返回成功——detached 沙箱 + // 可能仍在跑,谎报「已停止」会让「切官方模式」误以为第三方链路已拆。此时如实报错。 + #[cfg(not(target_os = "macos"))] + { + let _ = app; + kill_child(&mut st.sandbox); + st.sandbox_url = None; + return Ok(()); + } + #[cfg(target_os = "macos")] + { + let mut err = None; + match asset_root(app) { + Some(root) => { + let stop = root.join("scripts/stop-science-sandbox.sh"); + if stop.is_file() { + match Command::new("zsh") // stop 脚本是 #!/bin/zsh(用了 ${VAR:A} realpath) + .arg(&stop) + // 与 launch 时一致的可写沙箱 HOME,stop 才能按同一 data-dir 停对进程。 + .env("SANDBOX_HOME", sandbox_home()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(s) if s.success() => {} + Ok(s) => err = Some(format!("停止沙箱脚本非零退出({:?})。", s.code())), + Err(e) => err = Some(format!("调用停止沙箱脚本失败:{e}")), + } + } else { + err = Some(format!( + "找不到停止脚本 {},无法确认沙箱已停止(沙箱可能仍在运行)。", + stop.display() + )); + } + } + None => { + err = Some( + "定位不到资源根,取不到停止脚本,无法确认沙箱已停止(沙箱可能仍在运行)。" + .to_string(), + ); + } + } + kill_child(&mut st.sandbox); + st.sandbox_url = None; + match err { + Some(e) => Err(e), + None => Ok(()), + } + } // #[cfg(target_os = "macos")] +} + +// ---------- Tauri commands ---------- +fn build_list_templates() -> Vec { + templates::all() + .iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "category": t.category, + "api_format": t.api_format, + "adapter": t.adapter, + "base_url": t.base_url, + "base_url_editable": t.base_url_editable, + "requires_model_override": t.requires_model_override, + "builtin_models": t.builtin_models, + "website_url": t.website_url, + "icon": t.icon, + "icon_color": t.icon_color, + "thinking_policy": t.thinking_policy, + }) + }) + .collect() +} + +fn build_get_config(dir: &Path) -> Result { + let cfg = config::load_from(dir).map_err(|e| e.to_string())?; + let notice = cfg.pending_notice.clone(); + if notice.is_some() { + config::update(dir, |c| c.pending_notice = None).map_err(|e| e.to_string())?; + } + Ok(json!({ + "schema_version": cfg.schema_version, + "active_id": cfg.active_id, + "proxy_port": cfg.proxy_port, + "sandbox_port": cfg.sandbox_port, + "mode": cfg.mode, + "pending_notice": notice, + "templates": build_list_templates(), + "profiles": cfg.profiles.iter().map(|p| json!({ + "id": p.id, + "name": p.name, + "template_id": p.template_id, + "category": p.category, + "api_format": p.api_format, + "base_url": p.base_url, + "model": p.model, + "key": config::mask(&p.api_key), + "website_url": p.website_url, + "icon": p.icon, + "icon_color": p.icon_color, + "sort_index": p.sort_index, + "notes": p.notes, + })).collect::>(), + })) +} + +#[tauri::command] +fn get_config() -> Result { + build_get_config(&config::default_dir()) +} + +#[tauri::command] +fn list_templates() -> Vec { + build_list_templates() +} + +/// 切换运行模式("proxy" 第三方 / "official" 官方)。 +/// +/// 切到「官方」是**真正的切换**,不只是改配置:先把第三方链路拆掉(停沙箱 Science + 杀代理、 +/// 清 secret)。否则代理/沙箱会留在后台空跑;且 macOS 单实例语义下,后面 `open` 可能只是聚焦 +/// 还活着的沙箱实例(带着改过的 ANTHROPIC_* 环境)而非官方实例,把用户误导回第三方链路。 +/// 切回「第三方」不自动起任何东西(仍需用户填 key 后点「一键开始」)。全程绝不碰真实 8765。 +#[tauri::command] +fn set_mode( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + mode: String, +) -> Result<(), String> { + if mode != "proxy" && mode != "official" { + return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); + } + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + + // 事务化(修 P2 GPT 复审):切官方要「先拆第三方链路,成功了再落盘 official」。 + // 旧序(先落盘再拆)若拆沙箱失败,会留下「磁盘=official、UI/进程=第三方」的状态分裂 + // (前端收到 Err 保持第三方 UI,磁盘却已是 official,下次启动就错进官方模式)。 + // 现序保证:拆失败 → 不落盘、保持 proxy 模式、如实报错,磁盘/UI/进程一致。 + if mode == "official" { + lifecycle.bump_generation(); + let mut st = lock(&state); + // 先停沙箱:失败就在动代理/落盘之前中止,状态不分裂。 + stop_sandbox_inner(&app, &mut st).map_err(|e| { + format!("停止沙箱失败,未切换到官方模式:{e}(真实实例 8765 未受影响)") + })?; + kill_child(&mut st.proxy); + st.secret.clear(); + st.provider.clear(); + st.key_fp = 0; + } + // 拆链已成功(或切回 proxy 无需拆)→ 落盘。 + config::update(&dir, { + let mode = mode.clone(); + move |c| c.mode = mode + }) + .map_err(|e| e.to_string())?; + Ok(()) + }) +} + +/// 官方模式:干净地打开用户【真实】的 Claude Science(用户自己的官方登录与订阅)。 +/// 仅 macOS 有效(需本地安装 Claude Science.app);在 Windows / 其他平台上返回明确提示, +/// 引导用户使用远程模式管理服务器上的 Science。 +/// +/// 铁律:绝不碰/复制真实凭证;用 `open`(系统 LaunchServices 正常启动)而非注入环境变量, +/// 并显式抹掉任何 `ANTHROPIC_*`,确保**不用改过的环境变量启动真实实例**(真实实例走它自己的 +/// 官方端点,不经本代理)。CSSwitch 只把用户交回官方客户端,不托管其登录。 +#[tauri::command] +fn open_official() -> Result<(), String> { + #[cfg(not(target_os = "macos"))] + { + return Err("本地模式「打开官方 Claude Science」仅支持 macOS。请使用远程模式连接到运行 Science 的 Linux 服务器。".into()); + } + #[cfg(target_os = "macos")] + { + let app_path = "/Applications/Claude Science.app"; + let mut cmd = Command::new("open"); + if Path::new(app_path).is_dir() { + cmd.arg(app_path); + } else { + cmd.arg("-a").arg("Claude Science"); + } + // 防御性:即便 `open` 通常不向被启动 app 传本进程环境,也显式抹掉,杜绝把改过的 + // ANTHROPIC_* 带进真实实例(铁律 3)。 + cmd.env_remove("ANTHROPIC_BASE_URL") + .env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN"); + match cmd.status() { + Ok(s) if s.success() => Ok(()), + Ok(_) => Err("未能打开 Claude Science。请确认已安装官方 Claude Science。".into()), + Err(e) => Err(format!("打开官方 Claude Science 失败:{e}")), + } + } +} + +#[derive(Deserialize)] +struct UiSettings { + proxy_port: u16, + sandbox_port: u16, +} + +fn settings_change_needs_teardown( + old_proxy: u16, + new_proxy: u16, + old_sandbox: u16, + new_sandbox: u16, +) -> bool { + old_proxy != new_proxy || old_sandbox != new_sandbox +} + +#[tauri::command] +fn set_config( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + cfg: UiSettings, +) -> Result<(), String> { + set_settings(app, state, lifecycle, cfg) +} + +#[tauri::command] +fn set_settings( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + cfg: UiSettings, +) -> Result<(), String> { + if cfg.proxy_port == 8765 || cfg.sandbox_port == 8765 { + return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); + } + if cfg.proxy_port == 0 || cfg.sandbox_port == 0 { + return Err("端口不能为 0。".into()); + } + if cfg.proxy_port == cfg.sandbox_port { + return Err("代理端口与沙箱端口不能相同。".into()); + } + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let old = config::load_from(&dir).map_err(|e| e.to_string())?; + let teardown = settings_change_needs_teardown( + old.proxy_port, + cfg.proxy_port, + old.sandbox_port, + cfg.sandbox_port, + ); + if teardown { + let mut st = lock(&state); + stop_sandbox_inner(&app, &mut st).map_err(|e| { + format!( + "端口未更改:无法停止指向旧端口的沙箱({e}),为避免留下失效链路,端口保持不变。(真实实例 8765 未受影响)" + ) + })?; + lifecycle.bump_generation(); + kill_child(&mut st.proxy); + st.secret.clear(); + st.provider.clear(); + st.key_fp = 0; + st.sandbox_port = 0; + st.sandbox_url = None; + } + config::update(&dir, move |c| { + c.proxy_port = cfg.proxy_port; + c.sandbox_port = cfg.sandbox_port; + }) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +fn save_provider_key( + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + provider: String, + key: String, +) -> Result { + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let key2 = key.clone(); + config::update(&dir, move |c| { + // 兼容旧接口:按 adapter/template_id 找到 active profile 并保存 key + if let Some(p) = c.active_profile_mut() { + if templates::adapter_for(&p.template_id) == provider || p.template_id == provider { + p.api_key = key2; + } + } + }) + .map_err(|e| e.to_string())?; + lifecycle.bump_generation(); + stop_proxy_state(&state); + Ok(config::mask(&key)) + }) +} + +fn template_default_model(tpl: &templates::Template) -> String { + tpl.builtin_models.first().map(|s| (*s).to_string()).unwrap_or_default() +} + +fn validate_base_url_for_profile(profile: &config::Profile) -> Result<(), String> { + let launch = proxy_launch_for(profile); + if !is_native_adapter(&launch.adapter) + && (launch.base_url.trim().is_empty() + || !(launch.base_url.starts_with("http://") || launch.base_url.starts_with("https://"))) + { + return Err("base_url 必须以 http:// 或 https:// 开头。".into()); + } + reject_openai_custom_anthropic_base(&profile.template_id, &profile.base_url)?; + Ok(()) +} + +fn create_profile_inner( + dir: &Path, + template_id: &str, + name: &str, + key: Option<&str>, + base_url: Option<&str>, + model: Option<&str>, +) -> Result { + let tpl = templates::by_id(template_id).ok_or_else(|| format!("未知模板:{template_id}"))?; + let id = config::new_id(); + let mut model_value = model.unwrap_or("").trim().to_string(); + if tpl.requires_model_override && model_value.is_empty() { + model_value = template_default_model(tpl); + } + if tpl.requires_model_override && model_value.is_empty() { + return Err("该来源需要选择或填写模型。".into()); + } + let base = base_url + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(tpl.base_url) + .to_string(); + let now = config::now_ms(); + let mut candidate = config::Profile { + id: id.clone(), + name: if name.trim().is_empty() { tpl.name.to_string() } else { name.trim().to_string() }, + template_id: tpl.id.to_string(), + category: tpl.category.to_string(), + api_format: tpl.api_format.to_string(), + base_url: base, + api_key: key.unwrap_or("").trim().to_string(), + model: model_value, + website_url: Some(tpl.website_url.to_string()), + icon: Some(tpl.icon.to_string()), + icon_color: Some(tpl.icon_color.to_string()), + sort_index: None, + created_at: Some(now), + notes: None, + }; + validate_base_url_for_profile(&candidate)?; + config::update(dir, |c| { + candidate.sort_index = Some(c.profiles.len() as i64); + c.profiles.push(candidate); + }) + .map_err(|e| e.to_string())?; + Ok(id) +} + +fn update_profile_metadata_inner( + dir: &Path, + id: &str, + name: &str, + notes: Option<&str>, +) -> Result<(), String> { + if config::load_from(dir).map_err(|e| e.to_string())?.profile_by_id(id).is_none() { + return Err(format!("找不到 profile:{id}")); + } + config::update(dir, |c| { + if let Some(p) = c.profile_by_id_mut(id) { + p.name = if name.trim().is_empty() { "未命名".into() } else { name.trim().into() }; + p.notes = notes.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + } + }) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn update_profile_connection_inner( + dir: &Path, + id: &str, + base_url: Option<&str>, + api_format: Option<&str>, + model: Option<&str>, + key: Option<&str>, +) -> Result<(), String> { + let cfg = config::load_from(dir).map_err(|e| e.to_string())?; + let mut candidate = cfg + .profile_by_id(id) + .cloned() + .ok_or_else(|| format!("找不到 profile:{id}"))?; + if let Some(v) = base_url { + candidate.base_url = v.trim().to_string(); + } + if let Some(v) = api_format { + candidate.api_format = v.trim().to_string(); + } + if let Some(v) = model { + candidate.model = v.trim().to_string(); + } + if let Some(v) = key { + if !v.trim().is_empty() { + candidate.api_key = v.trim().to_string(); + } + } + validate_base_url_for_profile(&candidate)?; + if templates::by_id(&candidate.template_id) + .map(|t| t.requires_model_override) + .unwrap_or(true) + && candidate.model.trim().is_empty() + { + return Err("该来源需要选择或填写模型。".into()); + } + config::update(dir, |c| { + if let Some(p) = c.profile_by_id_mut(id) { + *p = candidate; + } + }) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn clear_profile_key_inner(dir: &Path, id: &str) -> Result<(), String> { + if config::load_from(dir).map_err(|e| e.to_string())?.profile_by_id(id).is_none() { + return Err(format!("找不到 profile:{id}")); + } + config::update(dir, |c| { + if let Some(p) = c.profile_by_id_mut(id) { + p.api_key.clear(); + } + }) + .map_err(|e| e.to_string())?; + config::drop_rolling_backup(dir); + Ok(()) +} + +fn delete_profile_inner(dir: &Path, id: &str) -> Result<(), String> { + if config::load_from(dir).map_err(|e| e.to_string())?.profile_by_id(id).is_none() { + return Err(format!("找不到 profile:{id}")); + } + config::update(dir, |c| { + c.profiles.retain(|p| p.id != id); + if c.active_id == id { + c.active_id.clear(); + } + }) + .map_err(|e| e.to_string())?; + config::drop_rolling_backup(dir); + Ok(()) +} + +fn stop_proxy_state(state: &State<'_, Mutex>) { + let mut st = lock(state); + kill_child(&mut st.proxy); + st.provider.clear(); + st.secret.clear(); + st.key_fp = 0; +} + +fn apply_connection_edit( + profile: &mut config::Profile, + base_url: Option<&str>, + api_format: Option<&str>, + model: Option<&str>, + key: Option<&str>, +) { + if let Some(v) = base_url { + profile.base_url = v.trim().to_string(); + } + if let Some(v) = api_format { + profile.api_format = v.trim().to_string(); + } + if let Some(v) = model { + profile.model = v.trim().to_string(); + } + if let Some(v) = key { + if !v.trim().is_empty() { + profile.api_key = v.trim().to_string(); + } + } +} + +fn validate_profile_with_scratch( + app: &tauri::AppHandle, + profile: &config::Profile, + can_skip: bool, +) -> Result { + let launch = proxy_launch_for(profile); + let root = asset_root(app).ok_or("找不到代理脚本 proxy/csswitch_proxy.py。")?; + let py = proc::find_exe("python3").ok_or("缺少依赖 python3(起临时代理需要)。")?; + let script = root.join("proxy/csswitch_proxy.py"); + let res = scratch::scratch_probe( + &py, + &script, + &scratch::ScratchTarget { + provider: &launch.adapter, + key_env: launch.key_env, + base_url: &launch.base_url, + key: &launch.key, + model: Some(&launch.model), + relay_thinking: launch.thinking_policy, + }, + scratch::ProbeKind::Message, + ); + match scratch::classify(res.status) { + scratch::ProbeOutcome::Ok => Ok(true), + scratch::ProbeOutcome::Auth(code) => Err(format!( + "上游拒绝({code}),key/权限有误,配置未保存。" + )), + scratch::ProbeOutcome::ModelError(code) => Err(format!( + "上游拒绝该模型({code}),请换一个模型或核对 base_url,配置未保存。" + )), + scratch::ProbeOutcome::Ambiguous(code) => { + let hint = code + .map(|c| format!("上游返回 {c}")) + .unwrap_or_else(|| "上游响应不明确".to_string()); + if can_skip { + Err(format!("{hint},未切换;确认无误后可选择跳过校验。")) + } else { + Err(format!("{hint},连接未保存,请稍后重试。")) + } + } + scratch::ProbeOutcome::Unsupported(code) => { + if can_skip { + Err(format!("上游不支持当前探测端点({code}),未切换;确认无误后可选择跳过校验。")) + } else { + Err(format!("上游不支持当前探测端点({code}),连接未保存。")) + } + } + scratch::ProbeOutcome::NoResponse => { + if can_skip { + Err("临时校验无响应,未切换;确认网络和配置无误后可选择跳过校验。".to_string()) + } else { + Err("临时校验无响应,连接未保存。".to_string()) + } + } + } +} + +#[tauri::command] +fn create_profile( + lifecycle: State<'_, lifecycle::Lifecycle>, + template_id: String, + name: String, + key: Option, + base_url: Option, + model: Option, +) -> Result { + lifecycle.with_serialized(|| { + create_profile_inner( + &config::default_dir(), + &template_id, + &name, + key.as_deref(), + base_url.as_deref(), + model.as_deref(), + ) + }) +} + +#[tauri::command] +fn update_profile_metadata( + lifecycle: State<'_, lifecycle::Lifecycle>, + id: String, + name: String, + notes: Option, +) -> Result<(), String> { + lifecycle.with_serialized(|| { + update_profile_metadata_inner(&config::default_dir(), &id, &name, notes.as_deref()) + }) +} + +#[tauri::command] +fn update_profile_connection( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + id: String, + base_url: Option, + api_format: Option, + model: Option, + key: Option, +) -> Result<(), String> { + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let mut candidate = cfg + .profile_by_id(&id) + .cloned() + .ok_or_else(|| format!("找不到 profile:{id}"))?; + apply_connection_edit( + &mut candidate, + base_url.as_deref(), + api_format.as_deref(), + model.as_deref(), + key.as_deref(), + ); + assert_profile_runnable(&candidate)?; + validate_profile_with_scratch(&app, &candidate, false)?; + let was_active = cfg.active_id == id; + update_profile_connection_inner( + &dir, + &id, + base_url.as_deref(), + api_format.as_deref(), + model.as_deref(), + key.as_deref(), + )?; + if was_active { + lifecycle.bump_generation(); + stop_proxy_state(&state); + } + Ok(()) + }) +} + +#[tauri::command] +fn clear_profile_key( + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + id: String, +) -> Result<(), String> { + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let was_active = config::load_from(&dir) + .map(|c| c.active_id == id) + .unwrap_or(false); + clear_profile_key_inner(&dir, &id)?; + if was_active { + lifecycle.bump_generation(); + stop_proxy_state(&state); + } + Ok(()) + }) +} + +#[tauri::command] +fn delete_profile( + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + id: String, +) -> Result<(), String> { + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let was_active = config::load_from(&dir) + .map(|c| c.active_id == id) + .unwrap_or(false); + delete_profile_inner(&dir, &id)?; + if was_active { + lifecycle.bump_generation(); + stop_proxy_state(&state); + } + Ok(()) + }) +} + +#[tauri::command] +fn set_active_profile( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, + id: String, + skip_verify: bool, +) -> Result { + lifecycle.with_serialized(|| { + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let profile = cfg + .profile_by_id(&id) + .cloned() + .ok_or_else(|| format!("找不到 profile:{id}"))?; + if !skip_verify { + if let Err(e) = assert_profile_runnable(&profile) + .and_then(|_| validate_profile_with_scratch(&app, &profile, true).map(|_| ())) + { + return Ok(json!({ + "committed": false, + "active_id": cfg.active_id, + "hint": e, + })); + } + } + config::update(&dir, |c| c.active_id = id.clone()).map_err(|e| e.to_string())?; + lifecycle.bump_generation(); + stop_proxy_state(&state); + Ok(json!({ + "committed": true, + "active_id": id, + "hint": if skip_verify { "已跳过校验并设为当前。" } else { "已设为当前。" }, + })) + }) +} + +#[derive(Deserialize)] +struct FetchModelsReq { + template_id: String, + #[serde(default)] + base_url: String, + #[serde(default)] + key: String, + #[serde(default)] + profile_id: Option, +} + +fn is_main_list_model(id: &str) -> bool { + for fam in ["claude-opus-", "claude-sonnet-", "claude-haiku-"] { + if let Some(rest) = id.strip_prefix(fam) { + return rest + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false); + } + } + false +} + +fn merge_and_sort_models( + live: Vec<(String, Option)>, + builtin: &[&str], +) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + let mut merged: Vec<(String, Option)> = Vec::new(); + for (id, st) in live { + if seen.insert(id.clone()) { + merged.push((id, st)); + } + } + for b in builtin { + if seen.insert(b.to_string()) { + merged.push((b.to_string(), None)); + } + } + merged.sort_by_key(|(id, st)| { + let cap = match st { + Some(true) => 0u8, + None => 1, + Some(false) => 2, + }; + let main = if is_main_list_model(id) { 0u8 } else { 1 }; + (cap, main) + }); + merged + .into_iter() + .map(|(id, st)| json!({ "id": id, "supports_tools": st })) + .collect() +} + +fn resolve_probe_key(profile_id: Option<&str>, candidate: &str) -> Result { + let c = candidate.trim(); + if !c.is_empty() { + return Ok(c.to_string()); + } + let pid = profile_id.ok_or("请先填写 API Key / Token。")?; + let cfg = config::load_from(&config::default_dir()).map_err(|e| e.to_string())?; + cfg.profile_by_id(pid) + .map(|p| p.api_key.clone()) + .filter(|k| !k.is_empty()) + .ok_or_else(|| "请先填写 API Key / Token。".to_string()) +} + +#[tauri::command] +fn fetch_models(app: tauri::AppHandle, req: FetchModelsReq) -> Result { + let tid = req.template_id.trim(); + let tpl = templates::by_id(tid).ok_or_else(|| format!("未知模板:{tid}"))?; + let base_url = if tpl.base_url_editable { + req.base_url.trim().to_string() + } else { + tpl.base_url.to_string() + }; + if base_url.is_empty() || !(base_url.starts_with("http://") || base_url.starts_with("https://")) + { + return Err("请先填写 base_url(http:// 或 https:// 开头)。".into()); + } + reject_openai_custom_anthropic_base(tid, &base_url)?; + let key = resolve_probe_key(req.profile_id.as_deref(), &req.key)?; + let root = asset_root(&app).ok_or("找不到代理脚本 proxy/csswitch_proxy.py。")?; + let py = proc::find_exe("python3").ok_or("缺少依赖 python3(起临时代理需要)。")?; + let script = root.join("proxy/csswitch_proxy.py"); + let adapter = templates::adapter_for(tid); + + let res = scratch::scratch_probe( + &py, + &script, + &scratch::ScratchTarget { + provider: adapter, + key_env: key_env_for_adapter(adapter), + base_url: &base_url, + key: &key, + model: None, + relay_thinking: tpl.thinking_policy, + }, + scratch::ProbeKind::Models, + ); + let builtin = tpl.builtin_models; + match scratch::classify(res.status) { + scratch::ProbeOutcome::Ok => { + let v: serde_json::Value = + serde_json::from_str(&res.body).map_err(|e| format!("解析模型列表失败:{e}"))?; + let live: Vec<(String, Option)> = v + .get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m.get("id")?.as_str()?.to_string(); + let st = m.get("supports_tools").and_then(|b| b.as_bool()); + Some((id, st)) + }) + .collect() + }) + .unwrap_or_default(); + if live.is_empty() { + return Ok(json!({ + "models": merge_and_sort_models(vec![], builtin), + "source": "builtin", "error_kind": null, "upstream_status": 200 + })); + } + Ok(json!({ + "models": merge_and_sort_models(live, builtin), + "source": "live", "error_kind": null, "upstream_status": 200 + })) + } + scratch::ProbeOutcome::Auth(code) => { + Err(format!("上游拒绝({code}),key 或权限可能有误。")) + } + other => { + let source = scratch::discovery_fallback_source(&other); + let error_kind = if source == "network" { + json!("network") + } else { + json!(null) + }; + Ok(json!({ + "models": merge_and_sort_models(vec![], builtin), + "source": source, + "error_kind": error_kind, + "upstream_status": res.status + })) + } + } +} + +#[tauri::command] +fn start_proxy( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, +) -> Result { + lifecycle.with_serialized(|| { + let (port, _secret, _action) = ensure_proxy(&app, &state, &lifecycle)?; + Ok(json!({ "port": port })) + }) +} + +/// 「存 key 即验证」:确保代理在跑,再经代理向上游发一个**最小**请求 +/// (`max_tokens:1`,一句 "ping"),据响应状态码判断 key 是否真的可用。 +/// 返回 `{ok, hint}`:ok=true 表示上游接受(key 有效);ok=false 表示上游拒绝或异常, +/// hint 给人话。彻底避免「只看绿灯(代理起来了)≠ key 真能用」。 +#[tauri::command] +fn verify_key( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, +) -> Result { + let (port, secret, _action) = + lifecycle.with_serialized(|| ensure_proxy(&app, &state, &lifecycle))?; + // 走稳定模型 id(代理内部映射到当前 provider 的真实模型),非流式、只要 1 个 token。 + let body = br#"{"model":"claude-opus-4-8","max_tokens":1,"messages":[{"role":"user","content":"ping"}]}"#; + match proc::http_post_status(port, Some(&secret), "/v1/messages", body, 15000) { + Some(200) => Ok(json!({ "ok": true, "hint": "key 有效,上游已接受。" })), + Some(code @ (401 | 403)) => Ok( + json!({ "ok": false, "hint": format!("上游拒绝({code}),key 可能无效或无权限。") }), + ), + Some(code) => Ok(json!({ + "ok": false, + "hint": format!("上游返回 {code},可能是 key 无效、额度不足或上游异常。") + })), + None => Err("验证请求无响应(多为网络或上游不通)。".to_string()), + } +} + +#[tauri::command] +fn stop_all( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, +) -> Result<(), String> { + lifecycle.with_serialized(|| { + lifecycle.bump_generation(); + let mut st = lock(&state); + // 先停沙箱并记录结果;代理无论如何都杀。沙箱没停干净则如实返错,不虚报成功。 + let sandbox_res = stop_sandbox_inner(&app, &mut st); + kill_child(&mut st.proxy); + st.secret.clear(); + st.provider.clear(); + st.key_fp = 0; + sandbox_res.map_err(|e| format!("代理已停;但{e}真实实例 8765 未受影响。")) + }) +} + +/// 「一键开始」:起代理 → 写虚拟 OAuth → 起沙箱 Science → 探活 → 开浏览器。 +/// 仅 macOS 本地模式有效。Windows/其他平台应使用远程模式 (`remote_*` 命令)。 +#[tauri::command] +fn one_click_login( + app: tauri::AppHandle, + state: State<'_, Mutex>, + lifecycle: State<'_, lifecycle::Lifecycle>, +) -> Result { + #[cfg(not(target_os = "macos"))] + { + let _ = (&app, &state, &lifecycle); + return Err("本地模式「一键开始」仅支持 macOS。请切换到「远程服务器」模式管理 Linux 服务器上的 Science。".into()); + } + #[cfg(target_os = "macos")] + { + lifecycle.with_serialized(|| { + // 1~3. 确保代理在跑且健康(内部已查 key、探活)。带回本次是复用还是重启。 + let (pport, secret, proxy_action) = ensure_proxy(&app, &state, &lifecycle)?; + + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let sport = cfg.sandbox_port; + + // sandbox_home() 作沙箱根:伪造器要求解析后的 auth_dir 落在其下,防符号链接重定向(P1)。 + let sbx_home = sandbox_home(); + let auth_dir = sbx_home.join(".claude-science"); + + // 沙箱已健康 → 但「daemon 活着」≠「登录态可用」:先只读校验虚拟登录是否自洽(修 0.2.1 Bug2)。 + // - 自洽 → 绝不重伪造、绝不重跑 launch(连 auth 文件都不读,operon 可能正在用),只重取 + // URL + 打开。修 #3/#6:活动 org 不变,旧对话一直在。 + // - 健康但登录失效(旧版遗留 / 凭证损坏 / 已落登录页)→ 重开也只会再落登录页,故停沙箱、 + // 落到下面「修复保 org + 重启」路径自愈(0.2.0 的健康快捷路径漏了这一步)。 + // P2b:asset_root() 只在下面「需启动」分支才取。 + // P2(GPT 复审):用 sandbox_running_ours 而非裸端口 /health——按 data-dir 强身份判定, + // 避免端口被冒名服务占用且恰好返回 200 时误报「已重新打开 Science」。 + if sandbox_running_ours(sport) { + if oauth_forge::login_intact(&auth_dir, "virtual@localhost.invalid", &sbx_home) { + let url = sandbox_url(sport); + { + let mut st = lock(&state); + st.sandbox_port = sport; + st.sandbox_url = Some(url.clone()); + } + let base = match proxy_action { + ProxyAction::Reused => "已在运行", + ProxyAction::Restarted => "已用新配置重启代理,Science 沿用不变", + }; + // P2c:捕获打开结果——open 失败不谎报「已重新打开」,改提示手动打开。 + let msg = match open_in_browser(&url) { + Ok(()) => format!("{base},已重新打开 Science。"), + Err(_) => format!("{base},服务已就绪,请手动打开:{url}"), + }; + return Ok(json!({ "url": url, "msg": msg, "action": "reopened" })); + } + // 健康但登录态失效:停沙箱,让下面 relaunch 拿到修复后的登录材料(daemon 运行中不会 + // 重读 auth)。ensure_virtual_login 幂等:保住 org(旧对话不丢),只重铸失效的登录。 + { + let mut st = lock(&state); + let _ = stop_sandbox_inner(&app, &mut st); + } + } + + // 沙箱没起 / 挂了 / 登录失效已停 → 需要 launch 资源,此时才定位(P2b)。确保虚拟登录(幂等)+ launch。 + let root = asset_root(&app) + .ok_or("找不到 scripts/launch-virtual-sandbox.sh(打包资源或仓库根均未命中)。")?; + + // 进程内确保虚拟 OAuth(Rust 原生密码学,零 node)。幂等:现有登录完整就复用、部分坏就 + // 修复但保住 org、真首次才铸新 —— 修 #3/#6 的核心(不再无条件换 org 孤儿化旧对话)。 + let (forged, login_action) = + oauth_forge::ensure_virtual_login(&auth_dir, "virtual@localhost.invalid", &sbx_home) + .map_err(|e| format!("写虚拟登录失败:{e}"))?; + + let launch = root.join("scripts/launch-virtual-sandbox.sh"); + if !launch.is_file() { + return Err("找不到 scripts/launch-virtual-sandbox.sh。".into()); + } + + // 4. 起沙箱:脚本以 --detached 起 Science,然后返回。 + let proxy_url = format!("http://127.0.0.1:{pport}/{secret}"); + let logf = open_log("sandbox.log").map_err(|e| format!("建日志失败:{e}"))?; + // 虚拟登录摘要面包屑(无密钥;uuid/假账号/沙箱路径均不敏感),便于用户附日志排查。 + { + use std::io::Write; + let mut lw = &logf; + let _ = writeln!( + lw, + "[oauth] 虚拟登录已就绪(Rust,零 node;action={:?}):auth_dir={} account={} org={} enc={}", + login_action, + forged.auth_dir.display(), + forged.account_uuid, + forged.org_uuid, + forged.enc_file.display() + ); + } + let logf2 = logf.try_clone().map_err(|e| e.to_string())?; + let status = Command::new("zsh") // launch 脚本是 #!/bin/zsh(用了 ${VAR:A} realpath) + .arg(&launch) + .arg("--port") + .arg(sport.to_string()) + .arg("--proxy-url") + .arg(&proxy_url) + .arg("--skip-oauth-forge") // OAuth 已由上面 Rust 进程内伪造,脚本别再调 node + // 沙箱状态落在可写目录(打包后资源目录只读),launch/stop/取 URL 三处同一路径。 + .env("SANDBOX_HOME", sandbox_home()) + .stdout(Stdio::from(logf)) + .stderr(Stdio::from(logf2)) + .status() + .map_err(|e| format!("起沙箱失败:{e}"))?; + if !status.success() { + let tail = redact(&tail_file(&log_path("sandbox.log"), 600), &secret); + return Err(format!("起沙箱脚本失败。\n{tail}")); + } + + // 5. 轮询沙箱 /health 直到就绪或超时(~8s)。 + let mut ok = false; + for _ in 0..80 { + std::thread::sleep(Duration::from_millis(100)); + if proc::http_health(sport, None, 400) { + ok = true; + break; + } + } + if !ok { + let tail = redact(&tail_file(&log_path("sandbox.log"), 600), &secret); + // 探活超时:脚本已把 Science 以 --detached 起在后台,必须停掉, + // 否则留一个孤儿沙箱进程(修 P2-2)。 + { + let mut st = lock(&state); + let _ = stop_sandbox_inner(&app, &mut st); // best-effort 清理,结果不影响这里的报错 + } + return Err(format!( + "沙箱起后探活超时(端口 {sport})。已尝试停掉刚起的沙箱。\n{tail}" + )); + } + + // 5b. 身份确认(修 P2 GPT 复审):/health 200 只证明端口在服务,不证明是我们的 Science。 + // 用 data-dir 强身份再确认一次;不是我们的(端口被冒名服务占用)→ 当启动失败处理, + // 停掉可能已在后台的沙箱并如实报错,别对着冒名服务谎报「已启动」。 + if !sandbox_running_ours(sport) { + { + let mut st = lock(&state); + let _ = stop_sandbox_inner(&app, &mut st); + } + return Err(format!( + "端口 {sport} 有服务响应,但按 data-dir 确认不是本沙箱 Science(疑似被其它服务占用)。已尝试停掉刚起的沙箱。" + )); + } + + // 6. 取 UI URL(登录态),交系统浏览器打开。 + let url = sandbox_url(sport); + { + let mut st = lock(&state); + st.sandbox_port = sport; + st.sandbox_url = Some(url.clone()); + } + let started = match login_action { + oauth_forge::LoginAction::Created => "已启动", + _ => "沙箱已重新启动,沿用原有对话", // Reused / Repaired + }; + // P2c:同样捕获打开结果。 + let msg = match open_in_browser(&url) { + Ok(()) => format!("{started}。"), + Err(_) => format!("{started},服务已就绪,请手动打开:{url}"), + }; + Ok(json!({ "url": url, "msg": msg, "action": "started" })) + }) + } // #[cfg(target_os = "macos")] +} + +/// 从 `claude-science url` 的 stdout 里取**第一条**合法 http(s) URL。 +/// 仅 macOS 本地模式需要(依赖 `claude-science` 二进制调用)。 +/// Science 的 `url` 命令会输出多行(第一行是真 URL,随后行是「single-use…」说明);把整段 +/// stdout 当 URL 交给 `open` 会带上换行与说明文字 → 打开错误入口、nonce 不被正确消费 → 落到 +/// `/login`(修 0.2.1 Bug1)。故逐行找第一条以 `http://`/`https://` 开头的行,并只取该行首个 +/// 非空白 token(URL 内不含空白,若同行尾随了说明也被切掉)。找不到返回 None。 +#[cfg(target_os = "macos")] +fn first_http_url(stdout: &str) -> Option { + for line in stdout.lines() { + let t = line.trim(); + if t.starts_with("http://") || t.starts_with("https://") { + let url = t.split_whitespace().next().unwrap_or(t); + return Some(url.to_string()); + } + } + None +} + +/// 取沙箱 UI 链接:` url --data-dir /.claude-science`,HOME 指向沙箱 HOME。 +/// 仅 macOS 调用(one_click_login 的 macOS 路径)。非 macOS 平台编译通过但无调用方。 +/// 失败退回 http://127.0.0.1:。沙箱 HOME 用 [`sandbox_home`](与 launch 时一致)。 +#[allow(dead_code)] +fn sandbox_url(port: u16) -> String { + #[cfg(not(target_os = "macos"))] + { + return format!("http://127.0.0.1:{port}"); + } + #[cfg(target_os = "macos")] + { + let home = sandbox_home(); + let data_dir = home.join(".claude-science"); + if Path::new(SCIENCE_BIN).is_file() { + if let Ok(out) = Command::new(SCIENCE_BIN) + .arg("url") + .arg("--data-dir") + .arg(&data_dir) + .env("HOME", &home) + .output() + { + let s = String::from_utf8_lossy(&out.stdout); + // 只取第一条合法 URL(修 0.2.1 Bug1):url 命令多行输出里第一行才是真 URL。 + if let Some(url) = first_http_url(&s) { + return url; + } + } + } + format!("http://127.0.0.1:{port}") + } // #[cfg(target_os = "macos")] +} + +/// 判断「我们自己的」沙箱 Science 是否在跑(供一键健康分派)。收紧(P2 GPT 复审):优先用 +/// Science 二进制按【我们的 data-dir】查 `{"running":true}`,这是强身份——不会被恰好占用 +/// `port` 且返回 200 的冒名服务骗过;再叠加端口 /health 确认确实在服务。二进制不在(纯 dev / +/// 研究者机器)时退化为仅端口探活(原行为)。 +/// 仅 macOS 调用(one_click_login/status 的 macOS 路径)。非 macOS 退化为纯端口探活。 +#[allow(dead_code)] +fn sandbox_running_ours(port: u16) -> bool { + #[cfg(not(target_os = "macos"))] + { + return proc::http_health(port, None, 400); + } + #[cfg(target_os = "macos")] + { + let home = sandbox_home(); + let data_dir = home.join(".claude-science"); + if Path::new(SCIENCE_BIN).is_file() { + match Command::new(SCIENCE_BIN) + .arg("status") + .arg("--data-dir") + .arg(&data_dir) + .env("HOME", &home) + .output() + { + Ok(out) => { + let s = String::from_utf8_lossy(&out.stdout); + // 审核 P2-8 修复:用 serde_json 解析而非 contains 字符串匹配(避免嵌套误判)。 + let running = serde_json::from_str::(&s) + .map(|v| v.get("running").and_then(|r| r.as_bool()).unwrap_or(false)) + .unwrap_or(false); + return running && proc::http_health(port, None, 400); + } + // 二进制在但调用失败 → 保守退化到端口探活,别因探测本身出错就误判没起。 + Err(_) => return proc::http_health(port, None, 400), + } + } + proc::http_health(port, None, 400) + } // #[cfg(target_os = "macos")] +} + +#[tauri::command] +fn status(state: State<'_, Mutex>) -> serde_json::Value { + // 先在锁外加载配置(磁盘 I/O),避免阻塞其他需要锁的命令。 + let cfg = match config::load_from(&config::default_dir()) { + Ok(c) => c, + Err(e) => { + eprintln!("status: 读取配置失败,使用默认值: {e}"); + config::Config::default() + } + }; + let (adapter, base_url) = cfg + .active_profile() + .map(|p| (templates::adapter_for(&p.template_id).to_string(), p.base_url.clone())) + .unwrap_or_default(); + + // 只在锁内取 AppState 字段(无 I/O)。 + let (pport, secret, sport) = { + let st = lock(&state); + let pport = if st.proxy_port != 0 { + st.proxy_port + } else { + cfg.proxy_port + }; + let sport = if st.sandbox_port != 0 { + st.sandbox_port + } else { + cfg.sandbox_port + }; + (pport, st.secret.clone(), sport) + }; + let proxy = if !secret.is_empty() && proc::http_health(pport, Some(&secret), 300) { + "green" + } else { + "amber" + }; + // 状态灯也用 data-dir 强身份(修 P2 GPT 复审),避免端口被冒名服务占用时误显绿灯。 + // status() 是按需调用(前端 refreshStatus 在动作后触发,非高频轮询),一次子进程可接受。 + let sandbox = if sandbox_running_ours(sport) { + "green" + } else { + "amber" + }; + // 无活跃 profile 时 adapter/base_url 为空,跳过上游探活避免空主机名连接。 + let upstream = if adapter.is_empty() { + "amber" + } else if proc::tcp_reachable(&upstream_host(&adapter, &base_url), 443, 500) { + "green" + } else { + "amber" + }; + json!({ "proxy": proxy, "sandbox": sandbox, "upstream": upstream }) +} + +#[tauri::command] +fn open_url(state: State<'_, Mutex>, url: Option) -> Result<(), String> { + let url = match url { + Some(url) => { + let trimmed = url.trim(); + if trimmed != url { + return Err("URL 不能包含首尾空白。".to_string()); + } + let lower = trimmed.to_ascii_lowercase(); + let allowed = lower.starts_with("http://127.0.0.1:") + || lower.starts_with("http://localhost:") + || lower.starts_with("http://[::1]:"); + if !allowed { + return Err("只允许打开本地沙箱 URL。".to_string()); + } + url + } + None => lock(&state) + .sandbox_url + .clone() + .ok_or("还没有沙箱 URL,请先「一键开始」。")?, + }; + open_in_browser(&url) +} + +/// 运行诊断脚本 `scripts/doctor.sh`。仅 macOS 本地模式有效。 +/// Windows/其他平台上返回明确提示,引导使用远程模式诊断。 +#[tauri::command] +fn run_doctor(app: tauri::AppHandle) -> Result { + #[cfg(not(target_os = "macos"))] + { + let _ = &app; + return Err("本地模式「自检」仅支持 macOS。请切换到「远程服务器」模式使用远程诊断功能。".into()); + } + #[cfg(target_os = "macos")] + { + let root = asset_root(&app).ok_or("找不到 scripts/doctor.sh(打包资源或仓库根均未命中)。")?; + let cfg = config::load_from(&config::default_dir()).unwrap_or_default(); + let doctor = root.join("scripts/doctor.sh"); + let active = cfg.active_profile().cloned(); + let adapter = active + .as_ref() + .map(|p| templates::adapter_for(&p.template_id).to_string()) + .unwrap_or_default(); + let mut cmd = Command::new("bash"); + cmd.arg(&doctor) + .env("CSSWITCH_PROVIDER", &adapter) + .env("CSSWITCH_PROXY_PORT", cfg.proxy_port.to_string()) + .env("CSSWITCH_SANDBOX_PORT", cfg.sandbox_port.to_string()); + // doctor 只做 -n 判空来报 key 有无。只让它知道「存在」,绝不把真实 key 传进其环境。 + if let Some(p) = active.as_ref() { + if !p.api_key.is_empty() { + cmd.env(key_env_for_adapter(&adapter), "***present***"); + } + } + let out = cmd.output().map_err(|e| e.to_string())?; + let mut text = String::from_utf8_lossy(&out.stdout).to_string(); + let err = String::from_utf8_lossy(&out.stderr); + if !err.trim().is_empty() { + text.push_str("\n[stderr] "); + text.push_str(err.trim()); + } + Ok(text) + } // #[cfg(target_os = "macos")] +} + +/// 当前 app 版本(供前端「检查更新」与页脚版本号用)。 +#[tauri::command] +fn app_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// 打开 GitHub Releases 页(检查更新时用系统浏览器打开,浏览器走用户自己的代理)。 +#[tauri::command] +fn open_release_page() -> Result<(), String> { + open_in_browser("https://github.com/SuperJJ007/CSswitch/releases/latest") +} + +/// 打开「报 bug」页(预填 bug 模板);用系统浏览器,走用户自己的代理。 +#[tauri::command] +fn report_bug() -> Result<(), String> { + open_in_browser("https://github.com/SuperJJ007/CSswitch/issues/new?template=bug_report.yml") +} + +/// 在文件管理器中打开日志目录 `~/.csswitch/logs`(跨平台)。 +/// macOS 用 `open`,Windows 用 `explorer`,Linux 用 `xdg-open`。 +#[tauri::command] +fn open_logs() -> Result<(), String> { + let dir = config::default_dir().join("logs"); + let _ = std::fs::create_dir_all(&dir); + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(&dir) + .status() + .map_err(|e| format!("打开日志目录失败:{e}"))?; + } + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .arg(&dir) + .status() + .map_err(|e| format!("打开日志目录失败:{e}"))?; + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Command::new("xdg-open") + .arg(&dir) + .status() + .map_err(|e| format!("打开日志目录失败:{e}"))?; + } + Ok(()) +} + +#[tauri::command] +fn quit_app(app: tauri::AppHandle, state: State<'_, Mutex>) -> Result<(), String> { + // 默认:退 app 停代理、保留沙箱运行(spec §5.1)。 + { + let mut st = lock(&state); + kill_child(&mut st.proxy); + st.secret.clear(); + } + app.exit(0); + Ok(()) +} + +// ---------- 入口 ---------- +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .manage(Mutex::new(AppState::default())) + .manage(lifecycle::Lifecycle::new()) + .invoke_handler(tauri::generate_handler![ + // 本地命令(macOS 本地模式) + get_config, + list_templates, + set_config, + set_settings, + set_mode, + open_official, + save_provider_key, + create_profile, + update_profile_metadata, + update_profile_connection, + clear_profile_key, + delete_profile, + set_active_profile, + fetch_models, + start_proxy, + verify_key, + stop_all, + one_click_login, + status, + open_url, + run_doctor, + app_version, + open_release_page, + report_bug, + open_logs, + quit_app, + // 远程命令(跨平台) + remote_commands::remote_list_profiles, + remote_commands::remote_save_profile, + remote_commands::remote_delete_profile, + remote_commands::remote_validate_profile, + remote_commands::remote_list_wsl_distributions, + remote_commands::remote_save_login_secret, + remote_commands::remote_delete_login_secret, + remote_commands::remote_auth_prompt_respond, + remote_commands::remote_check_health, + remote_commands::remote_prepare_helper, + remote_commands::remote_install_helper, + remote_commands::remote_get_config, + remote_commands::remote_set_config, + remote_commands::remote_save_provider_key, + remote_commands::remote_start_proxy, + remote_commands::remote_stop_proxy, + remote_commands::remote_stop_all, + remote_commands::remote_proxy_status, + remote_commands::remote_verify_key, + remote_commands::remote_status, + remote_commands::remote_logs, + remote_commands::remote_doctor, + remote_commands::remote_one_click, + ]) + .setup(|app| { + remote::askpass::set_app_handle(app.handle().clone()); + // 正常桌面应用:进 Dock、走常规应用生命周期(默认 Regular 策略, + // 不再设 Accessory)。窗口在 tauri.conf.json 里配了 decorations(标题栏 + // 三键:关闭/最小化/缩放)+ visible + center,启动即居中弹出、可拖动 + // (修 #4;标题栏自带拖动,顺带解决 #1 拖不动)。托盘图标已移除。 + + // 关窗即退出:与「退出」按钮一致 —— 停代理、清 secret,保留沙箱运行 + // (spec §5.1)。不接这一步,从标题栏红叉关窗会绕过 quit_app 直接退, + // 把代理子进程留成孤儿。 + if let Some(win) = app.get_webview_window("main") { + let handle = app.handle().clone(); + win.on_window_event(move |ev| { + if let tauri::WindowEvent::CloseRequested { .. } = ev { + let state = handle.state::>(); + let mut st = lock(&state); + kill_child(&mut st.proxy); + st.secret.clear(); + } + }); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +#[cfg(test)] +mod tests { + // first_http_url 和 sandbox_home 仅 macOS 编译,测试也仅在 macOS 运行。 + #[cfg(target_os = "macos")] + use super::{first_http_url, sandbox_home}; + use super::{ + assert_profile_runnable, key_env_for_adapter, key_fingerprint, redact, + settings_change_needs_teardown, + }; + use crate::config::Profile; + + /// 测试 URL 解析(仅 macOS,依赖 first_http_url)。 + #[cfg(target_os = "macos")] + #[test] + fn first_http_url_takes_only_first_valid_url() { + // Science 的 `url` 命令输出两行:第一行是真 URL,第二行是「single-use…」说明。 + // 旧代码把整段 stdout 当 URL 交给 open → 换行+说明污染参数、nonce 不被消费 → 落登录页。 + // 只能取第一条合法 http(s) URL(修 0.2.1 Bug1)。 + let multi = "http://127.0.0.1:8990/setup?nonce=abc123\n\ + This is a single-use link, expires in 60 seconds."; + assert_eq!( + first_http_url(multi).as_deref(), + Some("http://127.0.0.1:8990/setup?nonce=abc123"), + "多行输出必须只取第一行 URL,丢弃说明文字" + ); + // 同一行 URL 后跟了说明,只取 URL token(URL 内不含空白)。 + let inline = "https://x.example/y?z=1 (single-use)"; + assert_eq!( + first_http_url(inline).as_deref(), + Some("https://x.example/y?z=1") + ); + // 前导非 URL 行被跳过,取第一条 http 行。 + let lead = "Open this link in your browser:\nhttp://127.0.0.1:8990/a"; + assert_eq!( + first_http_url(lead).as_deref(), + Some("http://127.0.0.1:8990/a") + ); + // 无任何 URL → None(sandbox_url 据此退回裸端口)。 + assert_eq!(first_http_url("no url here\nnor here"), None); + // 单行纯 URL 原样返回。 + assert_eq!( + first_http_url("http://127.0.0.1:8990").as_deref(), + Some("http://127.0.0.1:8990") + ); + } + + #[test] + fn redact_scrubs_secret_and_is_noop_when_empty() { + assert_eq!( + redact("推理指向 http://127.0.0.1:18991/abcd1234 尾巴", "abcd1234"), + "推理指向 http://127.0.0.1:18991/**** 尾巴" + ); + assert_eq!(redact("原样返回", ""), "原样返回"); + assert!(!redact("leak abcd1234 leak abcd1234", "abcd1234").contains("abcd1234")); + } + + #[test] + fn key_fingerprint_stable_and_distinct() { + // 同 key 稳定、异 key 不同:这是「换 key 触发代理重启」判断的基础(P1-2)。 + assert_eq!(key_fingerprint("sk-aaaa"), key_fingerprint("sk-aaaa")); + assert_ne!(key_fingerprint("sk-aaaa"), key_fingerprint("sk-bbbb")); + assert_ne!(key_fingerprint(""), key_fingerprint("x")); + } + + #[test] + fn openai_adapters_use_openai_key_env() { + assert_eq!(key_env_for_adapter("openai-custom"), "CSSWITCH_OPENAI_KEY"); + assert_eq!(key_env_for_adapter("openai-responses"), "CSSWITCH_OPENAI_KEY"); + } + + #[test] + fn openai_responses_profile_is_runnable() { + let profile = Profile { + template_id: "custom-openai-responses".into(), + api_format: "openai_responses".into(), + base_url: "https://api.openai.com/v1".into(), + api_key: "sk-test".into(), + model: "gpt-5.2".into(), + ..Default::default() + }; + assert!(assert_profile_runnable(&profile).is_ok()); + } + + #[test] + fn settings_change_tears_down_only_when_ports_change() { + assert!(!settings_change_needs_teardown(18991, 18991, 8990, 8990)); + assert!(settings_change_needs_teardown(18991, 19000, 8990, 8990)); + assert!(settings_change_needs_teardown(18991, 18991, 8990, 9000)); + assert!(settings_change_needs_teardown(18991, 19000, 8990, 9000)); + } + + /// 测试 sandbox_home 路径(仅 macOS,依赖 sandbox_home 函数)。 + #[cfg(target_os = "macos")] + #[test] + fn sandbox_home_is_writable_under_config_dir() { + // 沙箱状态目录必须在可写的 ~/.csswitch 下(不在只读的 .app 资源里)——P1-1。 + let h = sandbox_home(); + assert!(h.ends_with("sandbox/home"), "应以 sandbox/home 结尾:{h:?}"); + assert!( + h.to_string_lossy().contains(".csswitch"), + "应在 .csswitch 下:{h:?}" + ); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index bea3c23..af257ff 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,5 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if std::env::var_os("CSSWITCH_ASKPASS_MODE").is_some() { + std::process::exit(desktop_lib::remote::askpass::run_cli()); + } desktop_lib::run() } diff --git a/desktop/src-tauri/src/oauth_forge.rs b/desktop/src-tauri/src/oauth_forge.rs index 6ae7785..1a5930d 100644 --- a/desktop/src-tauri/src/oauth_forge.rs +++ b/desktop/src-tauri/src/oauth_forge.rs @@ -18,10 +18,15 @@ //! 与 `.mjs` 的 v2 GCM 格式**字节兼容**,由本文件 `tests` 的 node↔rust 双向对拍单测钉死。 use std::collections::BTreeMap; -use std::io::{Read, Write}; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::io::Write; use std::path::{Path, PathBuf}; +// 跨平台文件权限抽象(仅 macOS 编译此模块,但保持导入一致)。 +use crate::fs_ext::{set_file_permissions, OpenOptionsExt, PermissionsExt}; +// 替代 /dev/urandom 的跨平台随机数生成。 +use rand::rngs::OsRng; +use rand::RngCore; + use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::{Aes256Gcm, Key, Nonce}; use base64::engine::general_purpose::STANDARD as B64; @@ -57,10 +62,10 @@ pub enum LoginAction { } // ---------- 随机与编码 ---------- +/// 生成 n 字节加密级随机数。跨平台:使用 `OsRng`(Unix: `/dev/urandom`;Windows: `BCryptGenRandom`)。 fn rand_bytes(n: usize) -> std::io::Result> { - let mut f = std::fs::File::open("/dev/urandom")?; let mut b = vec![0u8; n]; - f.read_exact(&mut b)?; + OsRng.fill_bytes(&mut b); Ok(b) } @@ -197,13 +202,14 @@ fn safe_write(path: &Path, data: &[u8], mode: u32) -> Result<(), String> { .map_err(|e| format!("写临时文件失败:{e}"))?; } std::fs::rename(&tmp, path).map_err(|e| format!("rename 失败:{e}"))?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) - .map_err(|e| format!("chmod 失败:{e}"))?; + // 跨平台:Unix 设置 mode 权限,Windows 为 no-op。 + set_file_permissions(path, mode).map_err(|e| format!("chmod 失败:{e}"))?; Ok(()) } +/// 尽力设置文件权限(跨平台:Unix 有效,Windows no-op)。 fn chmod_best_effort(p: &Path, mode: u32) { - let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)); + let _ = set_file_permissions(p, mode); } // ---------- 主流程 ---------- @@ -767,6 +773,8 @@ mod tests { ); } + /// 测试铁律:沙箱根经符号链接落入真实树应被拒绝(仅 Unix,依赖 symlink)。 + #[cfg(unix)] #[test] fn forge_rejects_symlink_into_real_science_tree() { // 铁律回归:把沙箱根的祖先预置成指向【真实 Science 目录】的符号链接——此时沙箱根 @@ -803,6 +811,8 @@ mod tests { } } + /// 测试 P1:符号链接逃出沙箱根应被拒绝(仅 Unix)。 + #[cfg(unix)] #[test] fn forge_rejects_symlink_escaping_sandbox_root() { // P1 回归:把沙箱内的 auth_dir 预置成指向沙箱外目录的符号链接,伪造器必须 diff --git a/desktop/src-tauri/src/proc.rs b/desktop/src-tauri/src/proc.rs index 72f3d54..dc803ba 100644 --- a/desktop/src-tauri/src/proc.rs +++ b/desktop/src-tauri/src/proc.rs @@ -1,12 +1,19 @@ -//! 进程管家用到的纯 std 辅助:探活、依赖定位、一次性 secret 生成、上游可达性。 -//! 无第三方依赖,便于单测;有状态的子进程编排放在 lib.rs(持 Child 句柄)。 +//! 进程管家用到的辅助:探活、依赖定位、一次性 secret 生成、上游可达性。 +//! 跨平台适配:`/dev/urandom` 改用 `rand::OsRng`,文件可执行判断用 `fs_ext::is_executable`。 +//! 有状态的子进程编排放在 lib.rs(持 Child 句柄)。 use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::PathBuf; +// Command/Stdio 仅在 Unix 的 which_via_login_shell() 中使用, +// Windows 上该函数被 #[cfg(unix)] 跳过,但保留 import 避免 Linux 编译报错。 +#[allow(unused_imports)] use std::process::{Command, Stdio}; use std::time::Duration; +use rand::rngs::OsRng; +use rand::RngCore; + /// 对本地回环代理做 HTTP 探活:`GET //health`,响应状态行含 200 即视为健康。 /// 代理带 path-secret 鉴权时必须带上 secret,否则会拿到 403。 pub fn http_health(port: u16, secret: Option<&str>, timeout_ms: u64) -> bool { @@ -182,8 +189,12 @@ pub fn which(name: &str) -> Option { return Some(hit); } } - // 2) GUI/.app 最小 PATH 兜底:扫常见安装目录。 - find_in_dirs(name, common_bin_dirs()) + // 2) GUI/.app 最小 PATH 兜底:扫常见安装目录(仅 Unix)。 + #[cfg(unix)] + if let Some(hit) = find_in_dirs(name, common_bin_dirs()) { + return Some(hit); + } + None } /// 在给定目录序列里找可执行文件(第一个命中即返回)。 @@ -197,9 +208,11 @@ fn find_in_dirs(name: &str, dirs: impl IntoIterator) -> Option

/bin`(目录枚举)。 +/// Windows 上不需要此项(PATH 已包含常见安装位置或被远程模式替代)。 +#[cfg(unix)] fn common_bin_dirs() -> Vec { let mut dirs = vec![ PathBuf::from("/opt/homebrew/bin"), // Homebrew(Apple Silicon) @@ -222,11 +235,13 @@ fn common_bin_dirs() -> Vec { } /// [`which`] 找不到时的最后兜底:用登录 shell 解析用户的**真实 PATH**。 +/// 仅 Unix 平台可用(依赖 zsh)。Windows 上由远程模式替代本地查找。 /// /// GUI/.app 从访达启动只有最小 PATH,且用户可能用 fnm / nvm / asdf 等在 `.zshrc` /// 里配置的版本管理器([`common_bin_dirs`] 的静态枚举覆盖不到)。这里跑 /// `zsh -lic 'command -v '`(登录 + 交互 shell,会 source 用户 rc)拿其真实 /// 解析路径。用独立线程 + `recv_timeout` 兜底,病态 rc 不会卡死调用方。 +#[cfg(unix)] pub fn which_via_login_shell(name: &str) -> Option { // name 出自本代码("node"/"python3"),仍做白名单,杜绝拼进 shell 的注入面。 if name.is_empty() @@ -278,27 +293,32 @@ pub fn which_via_login_shell(name: &str) -> Option { } /// 定位可执行文件(含登录 shell 兜底):[`which`](PATH + 常见安装目录)未命中时, -/// 再用 [`which_via_login_shell`] 解析用户真实 PATH。node / python3 都走这个,覆盖 -/// 「GUI 最小 PATH + 版本管理器」这类多位客户反馈的「已装 node 却报缺依赖」(修 #2)。 +/// 在 Unix 上再用 [`which_via_login_shell`] 解析用户真实 PATH。 +/// Windows 上仅走 `which()`(PATH 搜索),因为无 zsh 登录 shell 且远程模式为主要场景。 +/// node / python3 都走这个,覆盖「GUI 最小 PATH + 版本管理器」问题(修 #2)。 pub fn find_exe(name: &str) -> Option { - which(name).or_else(|| which_via_login_shell(name)) + let hit = which(name); + #[cfg(unix)] + let hit = hit.or_else(|| which_via_login_shell(name)); + hit } +/// 判断路径是否为可执行文件。 +/// 跨平台:Unix 检查执行权限位 `0o111`,Windows 仅检查是否为文件(扩展名判断由调用方负责)。 fn is_exec(p: &std::path::Path) -> bool { - use std::os::unix::fs::PermissionsExt; match std::fs::metadata(p) { - Ok(md) => md.is_file() && (md.permissions().mode() & 0o111 != 0), + Ok(md) => crate::fs_ext::is_executable(&md), Err(_) => false, } } -/// 生成一次性 path-secret:从 /dev/urandom 取 16 字节,hex 编码为 32 字符。 -/// 失败关闭:urandom 不可用时返回 Err,绝不退回可猜的弱 secret(宁可起代理失败)。 +/// 生成一次性 path-secret。 +/// 使用操作系统加密级随机源(Unix: `/dev/urandom`;Windows: `BCryptGenRandom`)取 16 字节, +/// hex 编码为 32 字符。失败关闭,绝不退回可猜的弱 secret(宁可起代理失败)。 +/// 跨平台:用 `rand::OsRng` 替代直接读 `/dev/urandom`,Windows 下对应 `BCryptGenRandom`。 pub fn gen_secret() -> std::io::Result { - use std::fs::File; let mut b = [0u8; 16]; - let mut f = File::open("/dev/urandom")?; - f.read_exact(&mut b)?; + OsRng.fill_bytes(&mut b); Ok(hex(&b)) } @@ -322,12 +342,15 @@ mod tests { assert!(!http_health(59999, None, 300)); } + /// PATH 中找 sh(仅 Unix)。 + #[cfg(unix)] #[test] fn get_body_none_when_nothing_listening() { // 没人监听 → 连不上 → None(与 http_health 一致的失败关闭语义)。 assert!(http_get_body(59998, Some("secret"), "/v1/models", 300).is_none()); } + #[cfg(unix)] #[test] fn which_finds_sh() { let sh = which("sh"); @@ -340,6 +363,8 @@ mod tests { assert!(which("definitely-not-a-real-binary-xyzzy").is_none()); } + /// 在常见目录找 sh(仅 Unix)。 + #[cfg(unix)] #[test] fn find_in_dirs_locates_exec() { // /bin/sh 几乎肯定存在且可执行。 @@ -353,6 +378,8 @@ mod tests { assert!(find_in_dirs("definitely-not-xyzzy", vec![PathBuf::from("/bin")]).is_none()); } + /// 登录 shell 解析可执行文件(仅 Unix,依赖 zsh)。 + #[cfg(unix)] #[test] fn login_shell_resolves_sh_when_zsh_present() { // 环境无 zsh 则跳过(CI 容器可能没有)。 @@ -365,6 +392,8 @@ mod tests { assert!(p.is_absolute() && is_exec(&p)); } + /// 登录 shell 拒绝恶意名称(仅 Unix)。 + #[cfg(unix)] #[test] fn login_shell_rejects_bad_names_without_spawning() { // 白名单:带 shell 元字符的名字直接拒(防注入),空名亦拒。 @@ -373,11 +402,15 @@ mod tests { assert!(which_via_login_shell("").is_none()); } + /// find_exe 应能找到 sh(仅 Unix)。 + #[cfg(unix)] #[test] fn find_exe_finds_sh() { assert!(find_exe("sh").is_some()); } + /// 常见 bin 目录覆盖 Homebrew 和版本管理器(仅 Unix)。 + #[cfg(unix)] #[test] fn common_bin_dirs_covers_homebrew_and_home_managers() { let dirs = common_bin_dirs(); diff --git a/desktop/src-tauri/src/remote/askpass.rs b/desktop/src-tauri/src/remote/askpass.rs new file mode 100644 index 0000000..1baae48 --- /dev/null +++ b/desktop/src-tauri/src/remote/askpass.rs @@ -0,0 +1,556 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::thread; +use std::time::{Duration, Instant}; + +use rand::{distributions::Alphanumeric, Rng}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +use super::credentials::{self, CredentialKind}; +use super::prompt::{classify_prompt, PromptKind}; + +lazy_static::lazy_static! { + static ref SESSIONS: Mutex> = Mutex::new(HashMap::new()); + static ref APP_HANDLE: Mutex> = Mutex::new(None); +} + +const TRANSIENT_PASSWORD_FILE: &str = "transient-password"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AskpassRequest { + pub id: String, + pub prompt: String, + pub profile_id: String, + #[serde(default)] + pub key_path: Option, +} + +impl AskpassRequest { + #[allow(dead_code)] + pub fn new(prompt: &str, profile_id: &str, key_path: Option) -> Self { + Self { + id: new_id(), + prompt: prompt.to_string(), + profile_id: profile_id.to_string(), + key_path, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AskpassResponse { + #[serde(default)] + pub secret: Option, + #[serde(default)] + pub cancelled: bool, + #[serde(default)] + pub remember: bool, +} + +impl AskpassResponse { + pub fn secret(secret: &str) -> Self { + Self { + secret: Some(secret.to_string()), + cancelled: false, + remember: false, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AskpassPromptPayload { + pub session_id: String, + pub request_id: String, + pub profile_id: String, + pub prompt: String, + pub kind: String, + pub remember_allowed: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AskpassClosePayload { + pub session_id: String, +} + +pub struct AskpassBroker { + session_id: String, + app: tauri::AppHandle, + stop: Arc, + worker: Option>, +} + +pub fn set_app_handle(app: tauri::AppHandle) { + *APP_HANDLE.lock().unwrap() = Some(app); +} + +pub fn app_handle() -> Option { + APP_HANDLE.lock().unwrap().clone() +} + +pub fn run_cli() -> i32 { + let prompt = std::env::args().nth(1).unwrap_or_default(); + let session_dir = match std::env::var("CSSWITCH_ASKPASS_DIR") { + Ok(value) if !value.trim().is_empty() => value, + _ => return 1, + }; + let profile_id = match std::env::var("CSSWITCH_ASKPASS_PROFILE") { + Ok(value) if !value.trim().is_empty() => value, + _ => return 1, + }; + let key_path = std::env::var("CSSWITCH_ASKPASS_KEY_PATH") + .ok() + .filter(|value| !value.trim().is_empty()); + + let request = AskpassRequest::new(&prompt, &profile_id, key_path); + if write_request(&session_dir, &request).is_err() { + return 1; + } + + match wait_response(&session_dir, &request.id, Duration::from_secs(120)) { + Ok(response) if response.cancelled => 1, + Ok(response) => { + if let Some(secret) = response.secret { + println!("{secret}"); + 0 + } else { + 1 + } + } + Err(_) => 1, + } +} + +impl AskpassBroker { + pub fn start( + app: tauri::AppHandle, + session_dir: PathBuf, + transient_password: Option, + ) -> Result { + ensure_dirs(&session_dir)?; + if let Some(secret) = transient_password + .as_deref() + .filter(|secret| !secret.is_empty()) + { + write_transient_password(&session_dir, secret)?; + } + let session_id = new_id(); + register_session(&session_id, session_dir.clone()); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let worker_session_id = session_id.clone(); + let worker_app = app.clone(); + let worker = thread::spawn(move || { + poll_requests(worker_app, worker_session_id, session_dir, worker_stop); + }); + Ok(Self { + session_id, + app, + stop, + worker: Some(worker), + }) + } + + // askpass broker 创建后启动后台线程轮询请求文件, + // 生命周期由调用方(auth_runtime_options)持有直到 SSH 命令结束。 +} + +impl Drop for AskpassBroker { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let session_dir = unregister_session(&self.session_id); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + if let Some(session_dir) = session_dir { + cleanup_session_dir(&session_dir); + } + let _ = self.app.emit( + "remote-auth-prompt-close", + AskpassClosePayload { + session_id: self.session_id.clone(), + }, + ); + } +} + +pub fn register_session(session_id: &str, session_dir: PathBuf) { + SESSIONS + .lock() + .unwrap() + .insert(session_id.to_string(), session_dir); +} + +pub fn unregister_session(session_id: &str) -> Option { + SESSIONS.lock().unwrap().remove(session_id) +} + +#[allow(dead_code)] +pub fn write_request(session_dir: impl AsRef, req: &AskpassRequest) -> Result<(), String> { + let session_dir = session_dir.as_ref(); + ensure_dirs(session_dir)?; + write_json(request_path(session_dir, &req.id), req) +} + +pub fn read_request( + session_dir: impl AsRef, + request_id: &str, +) -> Result { + read_json(request_path(session_dir.as_ref(), request_id)) +} + +pub fn write_response( + session_dir: impl AsRef, + request_id: &str, + resp: &AskpassResponse, +) -> Result<(), String> { + let session_dir = session_dir.as_ref(); + ensure_dirs(session_dir)?; + write_json(response_path(session_dir, request_id), resp) +} + +pub fn read_response( + session_dir: impl AsRef, + request_id: &str, +) -> Result { + read_json(response_path(session_dir.as_ref(), request_id)) +} + +fn consume_response( + session_dir: impl AsRef, + request_id: &str, +) -> Result { + let session_dir = session_dir.as_ref(); + let path = response_path(session_dir, request_id); + let response = read_json(path.clone())?; + let _ = fs::remove_file(path); + Ok(response) +} + +#[allow(dead_code)] +pub fn wait_response( + session_dir: impl AsRef, + request_id: &str, + timeout: Duration, +) -> Result { + let session_dir = session_dir.as_ref(); + let started = Instant::now(); + loop { + if !session_dir.exists() { + return Err("登录会话已结束".to_string()); + } + match consume_response(session_dir, request_id) { + Ok(resp) => return Ok(resp), + Err(_) if started.elapsed() < timeout => thread::sleep(Duration::from_millis(100)), + Err(e) => return Err(format!("等待登录验证超时:{e}")), + } + } +} + +pub fn respond( + session_id: &str, + request_id: &str, + secret: Option, + cancelled: bool, + remember: bool, +) -> Result<(), String> { + let session_dir = SESSIONS + .lock() + .unwrap() + .get(session_id) + .cloned() + .ok_or_else(|| "登录会话已结束,请重新连接。".to_string())?; + let request = read_request(&session_dir, request_id)?; + let response = AskpassResponse { + secret: secret.clone(), + cancelled, + remember, + }; + if remember && !cancelled { + if let Some(secret) = secret.as_deref().filter(|s| !s.is_empty()) { + let _ = remember_secret(&request, secret); + } + } + write_response(session_dir, request_id, &response) +} + +fn remember_secret(request: &AskpassRequest, secret: &str) -> Result<(), String> { + match classify_prompt(&request.prompt) { + PromptKind::Password => { + credentials::save_secret(&request.profile_id, CredentialKind::Password, secret) + } + PromptKind::KeyPassword => { + let Some(key_path) = request.key_path.as_deref() else { + return Ok(()); + }; + credentials::save_secret( + &request.profile_id, + CredentialKind::KeyPassword(key_path), + secret, + ) + } + PromptKind::VerificationCode | PromptKind::Unknown => Ok(()), + } +} + +fn poll_requests( + app: tauri::AppHandle, + session_id: String, + session_dir: PathBuf, + stop: Arc, +) { + let mut seen = HashSet::new(); + while !stop.load(Ordering::Relaxed) { + if let Ok(entries) = fs::read_dir(requests_dir(&session_dir)) { + for entry in entries.flatten() { + let path = entry.path(); + let Some(request_id) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + if !seen.insert(request_id.clone()) { + continue; + } + if let Ok(request) = read_request(&session_dir, &request_id) { + if try_auto_response(&session_dir, &request).is_err() { + let _ = + app.emit("remote-auth-prompt", prompt_payload(&session_id, &request)); + } + } + } + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn try_auto_response(session_dir: &Path, request: &AskpassRequest) -> Result<(), String> { + let secret = match classify_prompt(&request.prompt) { + PromptKind::Password => read_transient_password(session_dir) + .or_else(|| credentials::read_secret(&request.profile_id, CredentialKind::Password)), + PromptKind::KeyPassword => { + let key_path = request + .key_path + .as_deref() + .ok_or_else(|| "missing key path".to_string())?; + credentials::read_secret(&request.profile_id, CredentialKind::KeyPassword(key_path)) + } + PromptKind::VerificationCode | PromptKind::Unknown => None, + }; + let Some(secret) = secret else { + return Err("no saved secret".to_string()); + }; + write_response(session_dir, &request.id, &AskpassResponse::secret(&secret)) +} + +fn write_transient_password(session_dir: impl AsRef, secret: &str) -> Result<(), String> { + let session_dir = session_dir.as_ref(); + ensure_dirs(session_dir)?; + let path = transient_password_path(session_dir); + fs::write(&path, secret).map_err(|e| format!("写入临时登录密码失败:{e}"))?; + crate::fs_ext::set_file_permissions(&path, 0o600) + .map_err(|e| format!("设置临时登录密码权限失败:{e}")) +} + +fn read_transient_password(session_dir: &Path) -> Option { + let secret = fs::read_to_string(transient_password_path(session_dir)).ok()?; + if secret.is_empty() { + None + } else { + Some(secret) + } +} + +fn prompt_payload(session_id: &str, request: &AskpassRequest) -> AskpassPromptPayload { + let kind = classify_prompt(&request.prompt); + AskpassPromptPayload { + session_id: session_id.to_string(), + request_id: request.id.clone(), + profile_id: request.profile_id.clone(), + prompt: request.prompt.clone(), + kind: prompt_kind_name(&kind).to_string(), + remember_allowed: matches!(kind, PromptKind::Password | PromptKind::KeyPassword), + } +} + +fn prompt_kind_name(kind: &PromptKind) -> &'static str { + match kind { + PromptKind::Password => "password", + PromptKind::KeyPassword => "keyPassword", + PromptKind::VerificationCode => "verificationCode", + PromptKind::Unknown => "unknown", + } +} + +fn ensure_dirs(session_dir: &Path) -> Result<(), String> { + fs::create_dir_all(requests_dir(session_dir)) + .map_err(|e| format!("无法创建登录请求目录:{e}"))?; + fs::create_dir_all(responses_dir(session_dir)) + .map_err(|e| format!("无法创建登录响应目录:{e}"))?; + crate::fs_ext::set_file_permissions(session_dir, 0o700) + .map_err(|e| format!("设置登录会话目录权限失败:{e}"))?; + crate::fs_ext::set_file_permissions(&requests_dir(session_dir), 0o700) + .map_err(|e| format!("设置登录请求目录权限失败:{e}"))?; + crate::fs_ext::set_file_permissions(&responses_dir(session_dir), 0o700) + .map_err(|e| format!("设置登录响应目录权限失败:{e}"))?; + Ok(()) +} + +fn requests_dir(session_dir: &Path) -> PathBuf { + session_dir.join("requests") +} + +fn responses_dir(session_dir: &Path) -> PathBuf { + session_dir.join("responses") +} + +fn transient_password_path(session_dir: &Path) -> PathBuf { + session_dir.join(TRANSIENT_PASSWORD_FILE) +} + +fn request_path(session_dir: &Path, request_id: &str) -> PathBuf { + requests_dir(session_dir).join(format!("{request_id}.json")) +} + +fn response_path(session_dir: &Path, request_id: &str) -> PathBuf { + responses_dir(session_dir).join(format!("{request_id}.json")) +} + +fn cleanup_session_dir(session_dir: &Path) { + let _ = fs::remove_dir_all(session_dir); +} + +fn write_json(path: PathBuf, value: &impl Serialize) -> Result<(), String> { + let json = serde_json::to_vec(value).map_err(|e| format!("序列化登录验证数据失败:{e}"))?; + // 临时文件名包含 PID 和线程 ID,防止同进程多线程并发写入同一文件时冲突。 + let tmp = path.with_extension(format!( + "json.tmp.{}.{:?}", + std::process::id(), + std::thread::current().id() + )); + fs::write(&tmp, json).map_err(|e| format!("写入登录验证数据失败:{e}"))?; + crate::fs_ext::set_file_permissions(&tmp, 0o600) + .map_err(|e| format!("设置登录验证数据权限失败:{e}"))?; + fs::rename(&tmp, &path).map_err(|e| format!("保存登录验证数据失败:{e}"))?; + crate::fs_ext::set_file_permissions(&path, 0o600) + .map_err(|e| format!("确认登录验证数据权限失败:{e}")) +} + +fn read_json Deserialize<'de>>(path: PathBuf) -> Result { + let raw = fs::read(&path).map_err(|e| format!("读取登录验证数据失败:{e}"))?; + serde_json::from_slice(&raw).map_err(|e| format!("解析登录验证数据失败:{e}")) +} + +fn new_id() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(24) + .map(char::from) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_session_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "csswitch-askpass-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn askpass_request_roundtrip_uses_json_files() { + let dir = temp_session_dir(); + let req = AskpassRequest::new("Password:", "profile-1", None); + + write_request(&dir, &req).unwrap(); + let loaded = read_request(&dir, &req.id).unwrap(); + assert_eq!(loaded.prompt, "Password:"); + assert_eq!(loaded.profile_id, "profile-1"); + + write_response(&dir, &req.id, &AskpassResponse::secret("pw")).unwrap(); + let resp = read_response(&dir, &req.id).unwrap(); + assert_eq!(resp.secret.as_deref(), Some("pw")); + assert!(!resp.cancelled); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn transient_password_auto_answers_password_prompt_without_keyring() { + let dir = temp_session_dir(); + write_transient_password(&dir, "typed-password").unwrap(); + let req = AskpassRequest::new("root@example.com's password:", "profile-1", None); + + try_auto_response(&dir, &req).unwrap(); + + let resp = read_response(&dir, &req.id).unwrap(); + assert_eq!(resp.secret.as_deref(), Some("typed-password")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn respond_rejects_unknown_session() { + let err = respond("missing", "req", Some("pw".to_string()), false, false).unwrap_err(); + assert!(err.contains("登录会话")); + } + + #[test] + fn consume_response_removes_secret_file() { + let dir = temp_session_dir(); + let req = AskpassRequest::new("Password:", "profile-1", None); + write_request(&dir, &req).unwrap(); + write_response(&dir, &req.id, &AskpassResponse::secret("pw")).unwrap(); + + let resp = consume_response(&dir, &req.id).unwrap(); + + assert_eq!(resp.secret.as_deref(), Some("pw")); + assert!(!response_path(&dir, &req.id).exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn cleanup_session_dir_removes_requests_and_responses() { + let dir = temp_session_dir(); + let req = AskpassRequest::new("Password:", "profile-1", None); + write_request(&dir, &req).unwrap(); + write_response(&dir, &req.id, &AskpassResponse::secret("pw")).unwrap(); + + cleanup_session_dir(&dir); + + assert!(!dir.exists()); + } + + #[test] + fn wait_response_returns_when_session_dir_disappears() { + let dir = temp_session_dir(); + let req = AskpassRequest::new("Password:", "profile-1", None); + write_request(&dir, &req).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + + let started = Instant::now(); + let result = wait_response(&dir, &req.id, Duration::from_millis(300)); + + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_millis(150)); + } +} diff --git a/desktop/src-tauri/src/remote/auth.rs b/desktop/src-tauri/src/remote/auth.rs new file mode 100644 index 0000000..6655888 --- /dev/null +++ b/desktop/src-tauri/src/remote/auth.rs @@ -0,0 +1,338 @@ +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use super::types::{RemoteAuthMethod, RemoteHostProfile}; + +#[derive(Debug, Clone, Default)] +pub struct SshAuthPlan { + pub args: Vec, + #[allow(dead_code)] + pub env: Vec<(String, String)>, + #[allow(dead_code)] + pub default_key_paths: Vec, + #[allow(dead_code)] + pub interactive: bool, + #[allow(dead_code)] + pub remember_connection: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct AuthRuntimeOptions { + pub askpass_path: Option, + pub askpass_session_dir: Option, + pub askpass_env: Vec<(String, String)>, + pub control_path: Option, + pub default_ssh_dir: Option, +} + +impl AuthRuntimeOptions { + pub fn default_for(profile: &RemoteHostProfile) -> Self { + Self { + askpass_path: None, + askpass_session_dir: None, + askpass_env: Vec::new(), + control_path: default_control_path(profile), + default_ssh_dir: dirs::home_dir().map(|home| home.join(".ssh")), + } + } + + #[cfg(test)] + pub fn test() -> Self { + Self { + askpass_path: Some("csswitch-ssh-askpass".to_string()), + askpass_session_dir: Some("csswitch-askpass-session".to_string()), + askpass_env: Vec::new(), + control_path: Some("csswitch-control.sock".to_string()), + default_ssh_dir: None, + } + } +} + +impl SshAuthPlan { + pub fn from_profile(profile: &RemoteHostProfile, runtime: AuthRuntimeOptions) -> Self { + let mut args = Vec::new(); + let mut env = Vec::new(); + let mut default_key_paths: Vec = Vec::new(); + let mut askpass_key_path: Option = None; + + let (interactive, remember_connection) = match &profile.auth_method { + RemoteAuthMethod::SshAgent => (false, false), + RemoteAuthMethod::Recommended { + use_default_key_files, + allow_password, + allow_verification_code, + remember_connection, + strict, + .. + } => { + if *use_default_key_files { + default_key_paths = + collect_default_key_paths(runtime.default_ssh_dir.as_deref()); + for path in &default_key_paths { + push_option_value(&mut args, "-i", path); + } + } + if *strict { + push_ssh_option(&mut args, "IdentitiesOnly=yes"); + } + ( + *allow_password || *allow_verification_code, + *remember_connection, + ) + } + RemoteAuthMethod::Password { + remember_connection, + .. + } => (true, *remember_connection), + RemoteAuthMethod::KeyFile { + path, + save_key_password, + allow_password_fallback, + allow_verification_code, + remember_connection, + .. + } => { + push_option_value(&mut args, "-i", path); + askpass_key_path = Some(path.clone()); + if !allow_password_fallback { + push_ssh_option(&mut args, "IdentitiesOnly=yes"); + } + ( + *save_key_password || *allow_password_fallback || *allow_verification_code, + *remember_connection, + ) + } + }; + + if interactive { + push_ssh_option(&mut args, "BatchMode=no"); + push_ssh_option(&mut args, "NumberOfPasswordPrompts=3"); + if let Some(askpass_path) = runtime.askpass_path { + env.push(("SSH_ASKPASS".to_string(), askpass_path)); + env.push(("SSH_ASKPASS_REQUIRE".to_string(), "force".to_string())); + env.push(("DISPLAY".to_string(), "csswitch".to_string())); + env.extend(runtime.askpass_env); + } + if let Some(session_dir) = runtime.askpass_session_dir { + env.push(("CSSWITCH_ASKPASS_DIR".to_string(), session_dir)); + env.push(("CSSWITCH_ASKPASS_PROFILE".to_string(), profile.id.clone())); + } + if let Some(key_path) = askpass_key_path { + env.push(("CSSWITCH_ASKPASS_KEY_PATH".to_string(), key_path)); + } + } else { + push_ssh_option(&mut args, "BatchMode=yes"); + push_ssh_option(&mut args, "NumberOfPasswordPrompts=0"); + } + + if remember_connection { + if let Some(control_path) = runtime.control_path { + push_ssh_option(&mut args, "ControlMaster=auto"); + push_ssh_option(&mut args, "ControlPersist=10m"); + push_ssh_option(&mut args, &format!("ControlPath={control_path}")); + } + } + + if profile.ssh_options.legacy_compat { + push_ssh_option(&mut args, "HostKeyAlgorithms=+ssh-rsa"); + push_ssh_option(&mut args, "PubkeyAcceptedAlgorithms=+ssh-rsa"); + push_ssh_option(&mut args, "KexAlgorithms=+diffie-hellman-group14-sha1"); + } + + Self { + args, + env, + default_key_paths, + interactive, + remember_connection, + } + } +} + +fn push_option_value(args: &mut Vec, option: &str, value: &str) { + args.push(option.to_string()); + args.push(value.to_string()); +} + +fn push_ssh_option(args: &mut Vec, option: &str) { + push_option_value(args, "-o", option); +} + +#[cfg_attr(windows, allow(dead_code))] +pub fn control_path_for(profile: &RemoteHostProfile) -> PathBuf { + let raw = format!("{}@{}:{}", profile.username, profile.host, profile.port); + let hash = Sha256::digest(raw.as_bytes()); + crate::config::default_dir() + .join("ssh-control") + .join(format!("{hash:x}.sock")) +} + +#[cfg(windows)] +fn default_control_path(_profile: &RemoteHostProfile) -> Option { + None +} + +#[cfg(not(windows))] +fn default_control_path(profile: &RemoteHostProfile) -> Option { + Some(control_path_for(profile).to_string_lossy().into_owned()) +} + +fn collect_default_key_paths(ssh_dir: Option<&Path>) -> Vec { + let Some(ssh_dir) = ssh_dir else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(ssh_dir) else { + return Vec::new(); + }; + let names = + entries.filter_map(|entry| entry.ok()?.path().file_name()?.to_str().map(str::to_string)); + order_default_key_names(names) + .into_iter() + .map(|name| ssh_dir.join(name).to_string_lossy().into_owned()) + .collect() +} + +#[allow(dead_code)] +pub fn order_default_key_names(names: I) -> Vec +where + I: IntoIterator, + S: Into, +{ + let mut names: Vec = names + .into_iter() + .map(Into::into) + .filter(|name| name.starts_with("id_")) + .filter(|name| !name.ends_with(".pub")) + .collect(); + let preferred = ["id_ed25519", "id_ecdsa", "id_rsa"]; + let mut ordered = Vec::new(); + for preferred_name in preferred { + if let Some(pos) = names.iter().position(|name| name == preferred_name) { + ordered.push(names.remove(pos)); + } + } + names.sort(); + ordered.extend(names); + ordered +} + +#[cfg(test)] +mod tests { + use super::super::types::{RemoteAuthMethod, RemoteHostProfile, RemoteSshAdvancedOptions}; + use super::*; + use std::path::PathBuf; + + fn sample_profile(auth_method: RemoteAuthMethod) -> RemoteHostProfile { + RemoteHostProfile { + id: "profile-1".to_string(), + name: "Test".to_string(), + kind: super::super::types::RemoteTargetKind::Ssh, + host: "example.com".to_string(), + port: 22, + distribution: None, + username: "ubuntu".to_string(), + auth_method, + helper_path: "/usr/local/bin/csswitch-helper".to_string(), + last_connected: None, + ssh_options: RemoteSshAdvancedOptions::default(), + transient_password: None, + } + } + + fn temp_ssh_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "csswitch-auth-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn default_key_candidates_prefer_modern_keys() { + let names = vec!["id_rsa", "id_custom", "not_a_key", "id_ed25519", "id_ecdsa"]; + + let ordered = order_default_key_names(names); + + assert_eq!( + ordered, + vec!["id_ed25519", "id_ecdsa", "id_rsa", "id_custom"] + ); + } + + #[test] + fn recommended_auth_uses_default_keys_askpass_and_connection_reuse() { + let ssh_dir = temp_ssh_dir(); + for name in ["id_rsa", "id_ed25519", "id_custom", "id_rsa.pub"] { + std::fs::write(ssh_dir.join(name), "").unwrap(); + } + let profile = sample_profile(RemoteAuthMethod::Recommended { + use_saved_keys: true, + use_default_key_files: true, + allow_password: true, + allow_verification_code: true, + remember_connection: true, + strict: false, + }); + + let plan = SshAuthPlan::from_profile( + &profile, + AuthRuntimeOptions { + askpass_path: Some("C:/csswitch/csswitch-ssh-askpass.exe".to_string()), + askpass_session_dir: Some("C:/Temp/csswitch-askpass".to_string()), + askpass_env: Vec::new(), + control_path: Some("C:/Temp/csswitch-control.sock".to_string()), + default_ssh_dir: Some(ssh_dir.clone()), + }, + ); + + assert!(plan.args.contains(&"BatchMode=no".to_string())); + assert!(plan.args.contains(&"NumberOfPasswordPrompts=3".to_string())); + assert!(plan.args.contains(&"ControlMaster=auto".to_string())); + assert_eq!(plan.default_key_paths.len(), 3); + assert!(plan.default_key_paths[0].ends_with("id_ed25519")); + assert!(plan.default_key_paths[1].ends_with("id_rsa")); + assert!(plan.default_key_paths[2].ends_with("id_custom")); + assert!(plan.env.iter().any(|(key, _)| key == "SSH_ASKPASS")); + + let _ = std::fs::remove_dir_all(ssh_dir); + } + + #[test] + fn key_file_without_fallback_stays_noninteractive() { + let profile = sample_profile(RemoteAuthMethod::KeyFile { + path: "~/.ssh/id_ed25519".to_string(), + save_key_password: false, + allow_password_fallback: false, + allow_verification_code: false, + remember_connection: false, + }); + + let plan = SshAuthPlan::from_profile(&profile, AuthRuntimeOptions::test()); + + assert!(plan.args.contains(&"-i".to_string())); + assert!(plan.args.contains(&"~/.ssh/id_ed25519".to_string())); + assert!(plan.args.contains(&"BatchMode=yes".to_string())); + assert!(plan.args.contains(&"NumberOfPasswordPrompts=0".to_string())); + } + + #[test] + fn legacy_compat_args_are_opt_in() { + let mut profile = sample_profile(RemoteAuthMethod::SshAgent); + let plan = SshAuthPlan::from_profile(&profile, AuthRuntimeOptions::test()); + assert!(!plan + .args + .contains(&"HostKeyAlgorithms=+ssh-rsa".to_string())); + + profile.ssh_options.legacy_compat = true; + let plan = SshAuthPlan::from_profile(&profile, AuthRuntimeOptions::test()); + assert!(plan + .args + .contains(&"HostKeyAlgorithms=+ssh-rsa".to_string())); + } +} diff --git a/desktop/src-tauri/src/remote/credentials.rs b/desktop/src-tauri/src/remote/credentials.rs new file mode 100644 index 0000000..4c2328c --- /dev/null +++ b/desktop/src-tauri/src/remote/credentials.rs @@ -0,0 +1,619 @@ +//! Remote SSH login secrets backed by the system credential store. +//! +//! Plaintext passwords, key passphrases, and verification codes must never be +//! written to `remote-hosts.json` or logs. This module prefers the OS +//! credential store and falls back to an app-local encrypted file when the OS +//! store is unavailable. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine as _; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const SERVICE: &str = "CSSwitch"; +const LOCAL_SECRET_FILE: &str = "remote-secrets.json"; +const LOCAL_KEY_FILE: &str = "encryption.key"; +const LOCAL_KEY_NAME: &str = "REMOTE_SECRET_ENCRYPTION_KEY"; +const LOCAL_HKDF_INFO: &[u8] = b"csswitch:remote-login-secrets:v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialKind<'a> { + Password, + KeyPassword(&'a str), +} + +pub fn credential_kind_from_parts<'a>( + kind: &str, + key_path: Option<&'a str>, +) -> Result, String> { + match kind { + "password" => Ok(CredentialKind::Password), + "keyPassword" => { + let path = key_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "密钥文件路径不能为空".to_string())?; + Ok(CredentialKind::KeyPassword(path)) + } + _ => Err("未知登录信息类型".to_string()), + } +} + +pub fn credential_label(profile_id: &str, kind: CredentialKind<'_>) -> String { + let profile_hash = hash_label_part(profile_id); + match kind { + CredentialKind::Password => format!("remote:{profile_hash}:password"), + CredentialKind::KeyPassword(path) => { + let path_hash = hash_label_part(path); + format!("remote:{profile_hash}:key-password:{path_hash}") + } + } +} + +fn hash_label_part(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +#[allow(dead_code)] +trait CredentialStore { + fn save(&self, label: &str, secret: &str) -> Result<(), String>; + fn read(&self, label: &str) -> Option; + fn delete(&self, label: &str) -> Result<(), String>; +} + +struct SystemCredentialStore; + +impl CredentialStore for SystemCredentialStore { + fn save(&self, label: &str, secret: &str) -> Result<(), String> { + keyring::Entry::new(SERVICE, label) + .map_err(|e| format!("无法打开系统安全存储:{e}"))? + .set_password(secret) + .map_err(|e| format!("保存登录信息失败:{e}")) + } + + fn read(&self, label: &str) -> Option { + keyring::Entry::new(SERVICE, label) + .ok()? + .get_password() + .ok() + } + + fn delete(&self, label: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE, label) + .map_err(|e| format!("无法打开系统安全存储:{e}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("删除登录信息失败:{e}")), + } + } +} + +struct LocalEncryptedCredentialStore { + dir: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LocalSecretFile { + version: u32, + #[serde(default)] + items: HashMap, +} + +impl Default for LocalSecretFile { + fn default() -> Self { + Self { + version: 1, + items: HashMap::new(), + } + } +} + +impl LocalEncryptedCredentialStore { + fn new(dir: PathBuf) -> Self { + Self { dir } + } + + fn default() -> Self { + Self::new(crate::config::default_dir()) + } + + fn secrets_path(&self) -> PathBuf { + self.dir.join(LOCAL_SECRET_FILE) + } + + fn key_path(&self) -> PathBuf { + self.dir.join(LOCAL_KEY_FILE) + } + + fn ensure_dir(&self) -> Result<(), String> { + crate::config::assert_not_symlink(&self.dir) + .map_err(|e| format!("本机密码目录安全拒绝:{e}"))?; + fs::create_dir_all(&self.dir) + .map_err(|e| format!("无法创建本机密码目录 {}:{e}", self.dir.display()))?; + crate::fs_ext::set_file_permissions(&self.dir, 0o700) + .map_err(|e| format!("设置本机密码目录权限失败:{e}")) + } + + fn load_file(&self) -> Result { + let path = self.secrets_path(); + crate::config::assert_not_symlink(&path) + .map_err(|e| format!("本机密码文件安全拒绝:{e}"))?; + if !path.exists() { + return Ok(LocalSecretFile::default()); + } + let raw = fs::read_to_string(&path) + .map_err(|e| format!("读取本机密码文件 {} 失败:{e}", path.display()))?; + if raw.trim().is_empty() { + return Ok(LocalSecretFile::default()); + } + serde_json::from_str(&raw).map_err(|e| format!("解析本机密码文件失败:{e}")) + } + + fn save_file(&self, file: &LocalSecretFile) -> Result<(), String> { + self.ensure_dir()?; + let path = self.secrets_path(); + crate::config::assert_not_symlink(&path) + .map_err(|e| format!("本机密码文件安全拒绝:{e}"))?; + let json = + serde_json::to_vec_pretty(file).map_err(|e| format!("序列化本机密码文件失败:{e}"))?; + let tmp = path.with_extension(format!( + "json.tmp.{}.{:?}", + std::process::id(), + std::thread::current().id() + )); + fs::write(&tmp, json).map_err(|e| format!("写入本机密码临时文件失败:{e}"))?; + crate::fs_ext::set_file_permissions(&tmp, 0o600) + .map_err(|e| format!("设置本机密码文件权限失败:{e}"))?; + fs::rename(&tmp, &path).map_err(|e| format!("替换本机密码文件失败:{e}"))?; + crate::fs_ext::set_file_permissions(&path, 0o600) + .map_err(|e| format!("确认本机密码文件权限失败:{e}")) + } + + fn read_or_create_key(&self) -> Result { + self.ensure_dir()?; + let path = self.key_path(); + crate::config::assert_not_symlink(&path) + .map_err(|e| format!("本机密码密钥文件安全拒绝:{e}"))?; + if path.exists() { + let raw = + fs::read_to_string(&path).map_err(|e| format!("读取本机密码密钥文件失败:{e}"))?; + if let Some(value) = parse_local_key(&raw) { + return Ok(value); + } + } + + let key = random_key_b64(); + let body = format!("{LOCAL_KEY_NAME}={key}\n"); + fs::write(&path, body).map_err(|e| format!("写入本机密码密钥文件失败:{e}"))?; + crate::fs_ext::set_file_permissions(&path, 0o600) + .map_err(|e| format!("设置本机密码密钥文件权限失败:{e}"))?; + Ok(key) + } + + fn read_key(&self) -> Result, String> { + crate::config::assert_not_symlink(&self.dir) + .map_err(|e| format!("本机密码目录安全拒绝:{e}"))?; + if !self.dir.exists() { + return Ok(None); + } + + let path = self.key_path(); + crate::config::assert_not_symlink(&path) + .map_err(|e| format!("本机密码密钥文件安全拒绝:{e}"))?; + if !path.exists() { + return Ok(None); + } + + let raw = + fs::read_to_string(&path).map_err(|e| format!("读取本机密码密钥文件失败:{e}"))?; + Ok(parse_local_key(&raw)) + } +} + +impl CredentialStore for LocalEncryptedCredentialStore { + fn save(&self, label: &str, secret: &str) -> Result<(), String> { + let key = self.read_or_create_key()?; + let encrypted = encrypt_local_secret(label, secret.as_bytes(), &key)?; + let mut file = self.load_file()?; + file.version = 1; + file.items.insert(label.to_string(), encrypted); + self.save_file(&file) + } + + fn read(&self, label: &str) -> Option { + let key = self.read_key().ok()??; + let file = self.load_file().ok()?; + let encrypted = file.items.get(label)?; + let plaintext = decrypt_local_secret(label, encrypted, &key).ok()?; + String::from_utf8(plaintext).ok() + } + + fn delete(&self, label: &str) -> Result<(), String> { + let path = self.secrets_path(); + crate::config::assert_not_symlink(&path) + .map_err(|e| format!("本机密码文件安全拒绝:{e}"))?; + if !path.exists() { + return Ok(()); + } + + let mut file = self.load_file()?; + if file.items.remove(label).is_none() { + return Ok(()); + } + if file.items.is_empty() { + fs::remove_file(&path).map_err(|e| format!("删除本机密码文件失败:{e}")) + } else { + self.save_file(&file) + } + } +} + +fn parse_local_key(raw: &str) -> Option { + for line in raw.lines() { + if let Some(value) = line.strip_prefix(&format!("{LOCAL_KEY_NAME}=")) { + let value = value.trim(); + if B64.decode(value).map(|b| b.len() >= 16).unwrap_or(false) { + return Some(value.to_string()); + } + } + } + None +} + +fn random_key_b64() -> String { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + B64.encode(key) +} + +fn derive_local_key(root_key_b64: &str) -> Result<[u8; 32], String> { + let ikm = B64 + .decode(root_key_b64.trim()) + .map_err(|e| format!("本机密码密钥不是合法 base64:{e}"))?; + if ikm.len() < 16 { + return Err("本机密码密钥长度不足".to_string()); + } + let hk = Hkdf::::new(Some(&[]), &ikm); + let mut out = [0u8; 32]; + hk.expand(LOCAL_HKDF_INFO, &mut out) + .map_err(|_| "本机密码密钥派生失败".to_string())?; + Ok(out) +} + +fn encrypt_local_secret( + label: &str, + plaintext: &[u8], + root_key_b64: &str, +) -> Result { + let key = derive_local_key(root_key_b64)?; + let mut nonce = [0u8; 12]; + OsRng.fill_bytes(&mut nonce); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: label.as_bytes(), + }, + ) + .map_err(|_| "加密本机密码失败".to_string())?; + let mut body = nonce.to_vec(); + body.extend(ciphertext); + Ok(format!("v1:{}", B64.encode(body))) +} + +fn decrypt_local_secret(label: &str, body: &str, root_key_b64: &str) -> Result, String> { + let raw = B64 + .decode(body.strip_prefix("v1:").ok_or("本机密码密文缺少 v1 前缀")?) + .map_err(|e| format!("本机密码密文不是合法 base64:{e}"))?; + if raw.len() < 12 + 16 { + return Err("本机密码密文过短".to_string()); + } + let (nonce, ciphertext) = raw.split_at(12); + let key = derive_local_key(root_key_b64)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + cipher + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: label.as_bytes(), + }, + ) + .map_err(|_| "解密本机密码失败".to_string()) +} + +#[cfg(test)] +fn save_secret_with_store( + store: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, + secret: &str, +) -> Result<(), String> { + let label = credential_label(profile_id, kind); + store.save(&label, secret) +} + +#[cfg(test)] +fn read_secret_with_store( + store: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, +) -> Option { + let label = credential_label(profile_id, kind); + store.read(&label) +} + +#[cfg(test)] +fn delete_secret_with_store( + store: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, +) -> Result<(), String> { + let label = credential_label(profile_id, kind); + store.delete(&label) +} + +fn save_secret_with_fallback( + system: &impl CredentialStore, + fallback: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, + secret: &str, +) -> Result<(), String> { + let label = credential_label(profile_id, kind); + match system.save(&label, secret) { + Ok(()) => { + let _ = fallback.delete(&label); + Ok(()) + } + Err(system_error) => fallback.save(&label, secret).map_err(|fallback_error| { + format!("系统安全存储失败:{system_error};本机加密存储也失败:{fallback_error}") + }), + } +} + +fn read_secret_with_fallback( + system: &impl CredentialStore, + fallback: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, +) -> Option { + let label = credential_label(profile_id, kind); + system.read(&label).or_else(|| fallback.read(&label)) +} + +fn delete_secret_with_fallback( + system: &impl CredentialStore, + fallback: &impl CredentialStore, + profile_id: &str, + kind: CredentialKind<'_>, +) -> Result<(), String> { + let label = credential_label(profile_id, kind); + let system_result = system.delete(&label); + let fallback_result = fallback.delete(&label); + match (system_result, fallback_result) { + (Ok(()), _) | (_, Ok(())) => Ok(()), + (Err(system_error), Err(fallback_error)) => Err(format!( + "系统安全存储删除失败:{system_error};本机加密存储删除失败:{fallback_error}" + )), + } +} + +pub fn save_secret(profile_id: &str, kind: CredentialKind<'_>, secret: &str) -> Result<(), String> { + save_secret_with_fallback( + &SystemCredentialStore, + &LocalEncryptedCredentialStore::default(), + profile_id, + kind, + secret, + ) +} + +#[allow(dead_code)] +pub fn read_secret(profile_id: &str, kind: CredentialKind<'_>) -> Option { + read_secret_with_fallback( + &SystemCredentialStore, + &LocalEncryptedCredentialStore::default(), + profile_id, + kind, + ) +} + +pub fn delete_secret(profile_id: &str, kind: CredentialKind<'_>) -> Result<(), String> { + delete_secret_with_fallback( + &SystemCredentialStore, + &LocalEncryptedCredentialStore::default(), + profile_id, + kind, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + #[derive(Default)] + struct MemoryCredentialStore { + values: Mutex>, + } + + impl CredentialStore for MemoryCredentialStore { + fn save(&self, label: &str, secret: &str) -> Result<(), String> { + self.values + .lock() + .unwrap() + .insert(label.to_string(), secret.to_string()); + Ok(()) + } + + fn read(&self, label: &str) -> Option { + self.values.lock().unwrap().get(label).cloned() + } + + fn delete(&self, label: &str) -> Result<(), String> { + self.values.lock().unwrap().remove(label); + Ok(()) + } + } + + #[test] + fn credential_labels_are_stable_and_secret_free() { + let profile_id = "host.example.com/root/password-ish"; + let password = credential_label(profile_id, CredentialKind::Password); + let key_pass = credential_label( + profile_id, + CredentialKind::KeyPassword("C:/Users/me/.ssh/id_ed25519"), + ); + + assert!(password.starts_with("remote:")); + assert!(password.ends_with(":password")); + assert!(!password.contains(profile_id)); + assert!(key_pass.starts_with("remote:")); + assert!(key_pass.contains(":key-password:")); + assert!(!key_pass.contains(profile_id)); + assert!(!key_pass.contains("id_ed25519")); + assert!(!key_pass.contains(".ssh")); + assert_eq!(password.len(), "remote:".len() + 64 + ":password".len()); + assert_eq!( + key_pass.len(), + "remote:".len() + 64 + ":key-password:".len() + 64 + ); + } + + #[test] + fn credential_kind_parser_rejects_unknown_kind() { + assert!(credential_kind_from_parts("verificationCode", None).is_err()); + } + + #[test] + fn key_password_kind_requires_key_path() { + assert!(credential_kind_from_parts("keyPassword", None).is_err()); + assert!(credential_kind_from_parts("keyPassword", Some(" ")).is_err()); + assert_eq!( + credential_kind_from_parts("keyPassword", Some(" ~/.ssh/id_ed25519 ")).unwrap(), + CredentialKind::KeyPassword("~/.ssh/id_ed25519") + ); + } + + #[test] + fn memory_store_roundtrip_does_not_touch_system_credentials() { + let store = MemoryCredentialStore::default(); + let kind = CredentialKind::Password; + + save_secret_with_store(&store, "p2", kind, "server-password").unwrap(); + assert_eq!( + read_secret_with_store(&store, "p2", kind).as_deref(), + Some("server-password") + ); + + delete_secret_with_store(&store, "p2", kind).unwrap(); + assert!(read_secret_with_store(&store, "p2", kind).is_none()); + } + + struct FailingCredentialStore; + + impl CredentialStore for FailingCredentialStore { + fn save(&self, _label: &str, _secret: &str) -> Result<(), String> { + Err("No default store has been set".to_string()) + } + + fn read(&self, _label: &str) -> Option { + None + } + + fn delete(&self, _label: &str) -> Result<(), String> { + Err("No default store has been set".to_string()) + } + } + + fn temp_secret_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "csswitch-remote-secrets-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn encrypted_fallback_roundtrip_when_system_store_is_unavailable() { + let dir = temp_secret_dir(); + let system = FailingCredentialStore; + let fallback = LocalEncryptedCredentialStore::new(dir.clone()); + + save_secret_with_fallback( + &system, + &fallback, + "profile-1", + CredentialKind::Password, + "server-password", + ) + .unwrap(); + + assert_eq!( + read_secret_with_fallback(&system, &fallback, "profile-1", CredentialKind::Password) + .as_deref(), + Some("server-password") + ); + + let raw = std::fs::read_to_string(dir.join("remote-secrets.json")).unwrap(); + assert!(!raw.contains("server-password")); + assert!(raw.contains("v1:")); + assert!(dir.join("encryption.key").is_file()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn encrypted_fallback_delete_works_even_when_system_store_is_unavailable() { + let dir = temp_secret_dir(); + let system = FailingCredentialStore; + let fallback = LocalEncryptedCredentialStore::new(dir.clone()); + let kind = CredentialKind::Password; + + save_secret_with_fallback(&system, &fallback, "profile-1", kind, "server-password") + .unwrap(); + delete_secret_with_fallback(&system, &fallback, "profile-1", kind).unwrap(); + + assert!(read_secret_with_fallback(&system, &fallback, "profile-1", kind).is_none()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn encrypted_fallback_read_does_not_create_key_when_empty() { + let dir = temp_secret_dir(); + let system = FailingCredentialStore; + let fallback = LocalEncryptedCredentialStore::new(dir.clone()); + + assert!(read_secret_with_fallback( + &system, + &fallback, + "profile-1", + CredentialKind::Password + ) + .is_none()); + assert!(!dir.join("encryption.key").exists()); + assert!(!dir.join("remote-secrets.json").exists()); + + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/desktop/src-tauri/src/remote/mod.rs b/desktop/src-tauri/src/remote/mod.rs new file mode 100644 index 0000000..5b2d309 --- /dev/null +++ b/desktop/src-tauri/src/remote/mod.rs @@ -0,0 +1,24 @@ +//! 远程服务器管理模块。 +//! +//! 通过 SSH 连接远程 Linux 服务器,执行 `csswitch-helper` CLI 来管理: +//! - 翻译代理的启停与状态监控 +//! - 配置文件读写(~/.csswitch/config.json) +//! - Claude Science 沙箱管理 +//! - 日志查看与诊断 +//! +//! 架构参考 cc-switch-remote 的 `remote/` 模块,按 CSSwitch 需求大幅简化。 + +#[cfg(feature = "desktop")] +pub mod askpass; +pub mod auth; +pub mod credentials; +pub mod prompt; +pub mod ssh; +pub mod store; +pub mod transport; +pub mod types; +pub mod wsl; + +// 重新导出常用类型和函数,方便外部模块使用。 +pub use store::*; +pub use types::*; diff --git a/desktop/src-tauri/src/remote/prompt.rs b/desktop/src-tauri/src/remote/prompt.rs new file mode 100644 index 0000000..fec9e2e --- /dev/null +++ b/desktop/src-tauri/src/remote/prompt.rs @@ -0,0 +1,74 @@ +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptKind { + Password, + KeyPassword, + VerificationCode, + Unknown, +} + +#[allow(dead_code)] +pub fn classify_prompt(prompt: &str) -> PromptKind { + let lower = prompt.to_lowercase(); + let verification_words = [ + "one-time", + "one time", + "otp", + "verification", + "passcode", + "token", + "2fa", + "mfa", + "two-factor", + "multi-factor", + "duo", + "动态", + "一次性", + "验证码", + "令牌", + "双因素", + "多因素", + "短信验证", + "手机验证", + ]; + if verification_words.iter().any(|word| lower.contains(word)) { + return PromptKind::VerificationCode; + } + if lower.contains("passphrase") || prompt.contains("密钥密码") { + return PromptKind::KeyPassword; + } + if lower.contains("password") || prompt.contains("密码") || prompt.contains("口令") { + return PromptKind::Password; + } + PromptKind::Unknown +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn otp_like_prompts_are_not_password_prompts() { + for prompt in [ + "One-time password:", + "Verification code:", + "Duo passcode:", + "Enter token:", + "请输入动态验证码", + "短信验证", + "双因素认证", + ] { + assert_eq!(classify_prompt(prompt), PromptKind::VerificationCode); + } + } + + #[test] + fn password_prompts_are_recognized_after_otp_words_are_excluded() { + assert_eq!( + classify_prompt("ubuntu@example.com's password:"), + PromptKind::Password + ); + assert_eq!(classify_prompt("Password:"), PromptKind::Password); + assert_eq!(classify_prompt("请输入密码"), PromptKind::Password); + } +} diff --git a/desktop/src-tauri/src/remote/ssh.rs b/desktop/src-tauri/src/remote/ssh.rs new file mode 100644 index 0000000..204dca4 --- /dev/null +++ b/desktop/src-tauri/src/remote/ssh.rs @@ -0,0 +1,1544 @@ +//! SSH 连接与远程 Helper 命令执行。 +//! +//! 通过命令行 `ssh` 与远程服务器通信,执行 `csswitch-helper` 的 JSON 命令。 +//! 支持 KeyFile(私钥文件)和 SshAgent(ssh-agent)两种认证方式。 +//! MVP 阶段不支持密码认证。 +//! +//! 设计参考 cc-switch-remote 的 `remote/ssh.rs`,按 CSSwitch 实际需求简化: +//! - 一次 SSH 调用执行一个命令(无持久会话模式,CSSwitch 操作频率低) +//! - 超时 + 重试(指数退避:2s/4s/8s) +//! - 解析 helper 的 JSON 响应 + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::de::DeserializeOwned; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +#[cfg(feature = "desktop")] +use super::askpass::{self, AskpassBroker}; +use super::auth::{AuthRuntimeOptions, SshAuthPlan}; +use super::types::{RemoteAuthMethod, RemoteError, RemoteHostProfile}; + +/// Windows: 禁止弹出命令行窗口(CREATE_NO_WINDOW) +#[cfg(windows)] +const NO_WINDOW: u32 = 0x08000000; + +/// 创建隐藏窗口的 Command(Windows 上不弹 cmd 窗口) +pub(crate) fn hide_cmd(mut cmd: Command) -> Command { + #[cfg(windows)] + { + cmd.creation_flags(NO_WINDOW); + } + cmd +} + +/// SSH 超时秒数(ConnectTimeout)。 +const SSH_TIMEOUT_SECS: u64 = 10; +/// Helper 命令执行超时(适用于大多数操作)。 +pub(crate) const DEFAULT_CMD_TIMEOUT_SECS: u64 = 30; +/// 安装等慢速操作的超时。 +pub(crate) const SLOW_CMD_TIMEOUT_SECS: u64 = 120; +/// 默认重试次数。 +pub(crate) const DEFAULT_RETRIES: u32 = 3; +/// Helper 发布的 GitHub 仓库(可通过环境变量覆盖)。 +pub(crate) const HELPER_RELEASE_REPO_ENV: &str = "CSSWITCH_HELPER_RELEASE_REPO"; + +/// 校验 GitHub owner/repo 格式,防止命令注入。 +/// 只允许字母、数字、连字符、下划线、点。 +pub(crate) fn validate_repo_format(repo: &str) -> Option<&str> { + let repo = repo.trim(); + if repo.is_empty() { + return None; + } + // 格式: owner/name,两部分都是 [a-zA-Z0-9._-]+ + let (owner, name) = repo.split_once('/')?; + if owner.is_empty() || name.is_empty() { + return None; + } + let valid = |s: &str| -> bool { + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + }; + if valid(owner) && valid(name) { + Some(repo) + } else { + None + } +} + +fn parse_github_repo(remote: &str) -> Option { + let mut value = remote.trim().trim_end_matches('/').trim_end_matches(".git"); + if value.is_empty() { + return None; + } + if let Some(rest) = value.strip_prefix("git@github.com:") { + value = rest; + } else if let Some(rest) = value.strip_prefix("ssh://git@github.com/") { + value = rest; + } else if let Some(rest) = value.strip_prefix("https://github.com/") { + value = rest; + } else if let Some(rest) = value.strip_prefix("http://github.com/") { + value = rest; + } + validate_repo_format(value).map(str::to_string) +} + +pub(crate) fn resolve_helper_release_repo_from( + env_repo: Option<&str>, + build_repo: Option<&str>, + git_remote: Option<&str>, +) -> Result { + for candidate in [env_repo, build_repo] { + if let Some(repo) = candidate.and_then(validate_repo_format) { + return Ok(repo.to_string()); + } + } + if let Some(repo) = git_remote.and_then(parse_github_repo) { + return Ok(repo); + } + Err(RemoteError { + code: "helper_release_repo_unknown".to_string(), + message: "无法确定 Helper Release 仓库".to_string(), + details: None, + recoverable: true, + suggestion: Some(format!( + "请设置 {HELPER_RELEASE_REPO_ENV}=owner/repo,或在 CI 构建时注入 GITHUB_REPOSITORY。" + )), + }) +} + +fn git_origin_remote() -> Option { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?; + Some(text.trim().to_string()).filter(|s| !s.is_empty()) +} + +pub(crate) fn resolve_helper_release_repo() -> Result { + resolve_helper_release_repo_from( + std::env::var(HELPER_RELEASE_REPO_ENV).ok().as_deref(), + option_env!("GITHUB_REPOSITORY"), + git_origin_remote().as_deref(), + ) +} + +#[derive(Debug, Clone)] +pub struct SshCommandSpec { + pub args: Vec, + pub env: Vec<(String, String)>, +} + +// ============================================================================ +// SSH 参数构建 +// ============================================================================ + +/// 构建 SSH 基础参数(通用部分)。 +/// 参数说明: +/// - `ConnectTimeout`:连接超时 10 秒,避免网络不通时无限等待。 +/// - `ServerAliveInterval`:每 15 秒发送 keepalive,防止 NAT/防火墙断开空闲连接。 +/// - `StrictHostKeyChecking=accept-new`:首次自动接受主机密钥(后续连接验证指纹)。 +/// - `BatchMode`:KeyFile/Agent 时设为 yes(禁止交互),密码时不设。 +fn build_ssh_base_args_with_plan( + profile: &RemoteHostProfile, + auth_plan: &SshAuthPlan, +) -> Vec { + let mut args = vec![ + "-p".to_string(), + profile.port.to_string(), + "-o".to_string(), + format!("ConnectTimeout={SSH_TIMEOUT_SECS}"), + "-o".to_string(), + "ServerAliveInterval=15".to_string(), + "-o".to_string(), + "ServerAliveCountMax=3".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=accept-new".to_string(), + ]; + + args.extend(auth_plan.args.clone()); + + args.push("--".to_string()); + args.push(format!("{}@{}", profile.username, profile.host)); + args +} + +/// 构建执行一次 helper 命令的完整 SSH 参数。 +/// 远程执行:` --json ` +pub fn build_ssh_args(profile: &RemoteHostProfile, helper_args: &[String]) -> Vec { + build_ssh_command_spec( + profile, + helper_args, + AuthRuntimeOptions::default_for(profile), + ) + .args +} + +pub fn build_ssh_stdin_args(profile: &RemoteHostProfile) -> Vec { + build_ssh_stdin_command_spec(profile, AuthRuntimeOptions::default_for(profile)).args +} + +fn build_ssh_command_spec( + profile: &RemoteHostProfile, + helper_args: &[String], + runtime: AuthRuntimeOptions, +) -> SshCommandSpec { + let auth_plan = SshAuthPlan::from_profile(profile, runtime); + let mut args = build_ssh_base_args_with_plan(profile, &auth_plan); + // 构建 helper 命令行:` --json ` + let cmd = format!( + "{} --json {}", + shell_quote(&profile.helper_path), + helper_args + .iter() + .map(|a| shell_quote(a)) + .collect::>() + .join(" ") + ); + args.push(cmd); + SshCommandSpec { + args, + env: auth_plan.env, + } +} + +fn build_ssh_stdin_command_spec( + profile: &RemoteHostProfile, + runtime: AuthRuntimeOptions, +) -> SshCommandSpec { + let auth_plan = SshAuthPlan::from_profile(profile, runtime); + let mut args = build_ssh_base_args_with_plan(profile, &auth_plan); + args.push(format!( + "{} --json serve", + shell_quote(&profile.helper_path) + )); + SshCommandSpec { + args, + env: auth_plan.env, + } +} + +/// 构建安装 helper 的 SSH 命令。 +/// 在远程执行 shell 脚本:下载 release 资产 → 校验 → 安装。 +/// +/// P0-2 修复:对平台信息进行白名单校验,防止命令注入。 +pub fn build_helper_install_args(profile: &RemoteHostProfile) -> Result, RemoteError> { + Ok(build_helper_install_command_spec(profile, AuthRuntimeOptions::default_for(profile))?.args) +} + +pub fn build_helper_install_command_spec( + profile: &RemoteHostProfile, + runtime: AuthRuntimeOptions, +) -> Result { + let auth_plan = SshAuthPlan::from_profile(profile, runtime); + let mut args = build_ssh_base_args_with_plan(profile, &auth_plan); + let helper_path = shell_quote(&profile.helper_path); + let repo = resolve_helper_release_repo()?; + + let helper_version = env!("CARGO_PKG_VERSION"); + + // P0-2: 安装脚本中加入架构白名单校验,防止注入 + // 即使攻击者控制了 uname 输出,也只能匹配预定义的安全值 + let script = format!( + r#"set -e +HELPER_PATH={helper_path} +HELPER_DIR=$(dirname "$HELPER_PATH") +mkdir -p "$HELPER_DIR" + +download() {{ + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + echo "远程服务器需要 curl 或 wget 来下载 helper。请手动安装。" >&2 + exit 1 + fi +}} + +# P0-2 修复:对架构和 OS 进行严格白名单校验 +ARCH_RAW=$(uname -m) +case "$ARCH_RAW" in + x86_64|amd64) ARCH=x86_64 ;; + aarch64|arm64) ARCH=aarch64 ;; + *) + echo "不支持的架构: $ARCH_RAW(仅支持 x86_64/aarch64)" >&2 + exit 1 + ;; +esac + +OS_RAW=$(uname -s) +case "$OS_RAW" in + Linux) OS=linux ;; + *) + echo "不支持的操作系统: $OS_RAW(仅支持 Linux)" >&2 + exit 1 + ;; +esac + +# 尝试从 GitHub API 获取与桌面端同版本 release 的下载 URL +# 使用硬编码的文件名模式,防止通配符注入 +API_URL="https://api.github.com/repos/{repo}/releases/tags/v{helper_version}" +BINARY_NAME="csswitch-helper-${{OS}}-${{ARCH}}" + +# 从 API 响应中提取匹配的下载 URL +# 优先 jq(JSON 专用工具最可靠),其次 python3(Linux 标配),最后 awk(兜底) +API_JSON=$(mktemp) +download "$API_URL" "$API_JSON" + +if command -v jq >/dev/null 2>&1; then + DOWNLOAD_URL=$(jq -r ".assets[] | select(.name==\"$BINARY_NAME\") | .browser_download_url" "$API_JSON" 2>/dev/null || true) +elif command -v python3 >/dev/null 2>&1; then + DOWNLOAD_URL=$(python3 -c " +import json,sys +data=json.load(open('$API_JSON')) +for a in data.get('assets',[]): + if a.get('name')=='$BINARY_NAME': + print(a['browser_download_url']) + break +" 2>/dev/null || true) +else + DOWNLOAD_URL=$(awk -v name="\"$BINARY_NAME\"" ' + $0 ~ name {{ found=1 }} + found && /browser_download_url/ {{ + if (match($0, /https:[^"]+/)) {{ + print substr($0, RSTART, RLENGTH) + exit + }} + }} + ' "$API_JSON" || true) +fi +rm -f "$API_JSON" + +if [ -z "$DOWNLOAD_URL" ]; then + echo "无法从 GitHub Releases 获取 $BINARY_NAME 下载链接。" >&2 + echo "手动安装: wget -O $HELPER_PATH && chmod +x $HELPER_PATH" >&2 + exit 1 +fi + +TMP=$(mktemp) +download "$DOWNLOAD_URL" "$TMP" +chmod +x "$TMP" +mv "$TMP" "$HELPER_PATH" +"$HELPER_PATH" --json status +"#, + helper_path = helper_path, + repo = repo, + helper_version = helper_version, + ); + args.push(script); + Ok(SshCommandSpec { + args, + env: auth_plan.env, + }) +} + +// ============================================================================ +// 命令执行 +// ============================================================================ + +/// 在远程服务器上执行一次 helper 命令,解析 JSON 响应。 +/// +/// 参数: +/// - `profile`:SSH 连接配置 +/// - `helper_args`:helper 子命令,如 `["proxy", "status"]` +/// - `timeout_secs`:超时秒数(含 SSH 连接和命令执行) +/// - `retries`:重试次数(0=不重试) +/// +/// 返回:反序列化后的命令结果(T 类型)。 +/// +/// 错误:返回结构化的 `RemoteError`,包含可重试标记和修复建议。 +pub fn detect_remote_platform( + profile: &RemoteHostProfile, +) -> Result<(String, String), RemoteError> { + let out = run_ssh_script( + profile, + "printf '%s\\n%s\\n' \"$(uname -s)\" \"$(uname -m)\"", + DEFAULT_CMD_TIMEOUT_SECS, + )?; + let mut lines = out.lines().map(str::trim).filter(|line| !line.is_empty()); + let os_raw = lines.next().unwrap_or_default(); + let arch_raw = lines.next().unwrap_or_default(); + + // P0-2 修复:对 OS 名称做严格白名单校验,防止非预期平台字符串污染 UI/日志 + let os = match os_raw { + "Linux" => "linux", + "Darwin" => "macos", + other => { + return Err(RemoteError { + code: "unsupported_platform".to_string(), + message: format!("远程服务器平台不支持:{other}(仅支持 Linux)"), + details: None, + recoverable: false, + suggestion: Some( + "远程 Helper 目前仅支持 Linux 服务器。请在 Linux 上部署。".to_string(), + ), + }); + } + } + .to_string(); + + let arch = match arch_raw { + "x86_64" | "amd64" => "x86_64", + "aarch64" | "arm64" => "aarch64", + _ => arch_raw, + } + .to_string(); + + Ok((os, arch)) +} + +pub fn build_helper_stdin_install_args(profile: &RemoteHostProfile) -> Vec { + build_helper_stdin_install_command_spec(profile, AuthRuntimeOptions::default_for(profile)).args +} + +fn build_helper_stdin_install_command_spec( + profile: &RemoteHostProfile, + runtime: AuthRuntimeOptions, +) -> SshCommandSpec { + let auth_plan = SshAuthPlan::from_profile(profile, runtime); + let mut args = build_ssh_base_args_with_plan(profile, &auth_plan); + let helper_path = shell_quote(&profile.helper_path); + let script = format!( + r#"set -e +HELPER_PATH={helper_path} +HELPER_DIR=$(dirname "$HELPER_PATH") +mkdir -p "$HELPER_DIR" +TMP=$(mktemp "$HELPER_DIR/.csswitch-helper.XXXXXX") +cat > "$TMP" +chmod +x "$TMP" +mv "$TMP" "$HELPER_PATH" +"$HELPER_PATH" --json status +"#, + helper_path = helper_path, + ); + args.push(script); + SshCommandSpec { + args, + env: auth_plan.env, + } +} + +pub fn install_helper_from_stdin( + profile: &RemoteHostProfile, + helper_bytes: &[u8], +) -> Result { + let (runtime, _broker) = auth_runtime_options(profile)?; + let spec = build_helper_stdin_install_command_spec(profile, runtime); + let mut command = hide_cmd(Command::new("ssh")); + command.args(&spec.args); + for (key, value) in &spec.env { + command.env(key, value); + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| RemoteError { + code: "ssh_spawn_failed".to_string(), + message: format!("无法启动 SSH 客户端:{e}"), + details: Some(format!("请确认 OpenSSH 客户端已安装并在 PATH 中:{e}")), + recoverable: false, + suggestion: Some( + "Windows 10+ 自带 OpenSSH。请在系统可选功能中确认已安装。".to_string(), + ), + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(helper_bytes).map_err(|e| RemoteError { + code: "helper_upload_failed".to_string(), + message: format!("上传 Helper 二进制失败:{e}"), + details: None, + recoverable: true, + suggestion: Some("请检查 SSH 连接是否稳定,并重试保存服务器。".to_string()), + })?; + } + + let output = match wait_with_timeout_legacy(child, Duration::from_secs(SLOW_CMD_TIMEOUT_SECS)) { + Ok(result) => result.map_err(|e| RemoteError { + code: "ssh_io_error".to_string(), + message: format!("SSH 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), + })?, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + return Err(RemoteError { + code: "ssh_timeout".to_string(), + message: format!("SSH 上传 Helper 超时({}秒)", SLOW_CMD_TIMEOUT_SECS), + details: Some(format!("目标:{}@{}", profile.username, profile.host)), + recoverable: true, + suggestion: Some("网络慢或远程命令卡住。请检查 SSH 连接后重试。".to_string()), + }); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(RemoteError { + code: "ssh_thread_panic".to_string(), + message: "SSH 执行线程异常退出".to_string(), + details: None, + recoverable: false, + suggestion: Some("这可能是程序错误。请报告此问题。".to_string()), + }); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(map_ssh_error(profile, &stderr, output.status.code())); + } + + const MAX_UPLOAD_OUTPUT_SIZE: usize = 1024 * 1024; + if output.stdout.len() > MAX_UPLOAD_OUTPUT_SIZE { + return Err(RemoteError { + code: "output_too_large".to_string(), + message: format!("Helper 输出过大({} 字节)", output.stdout.len()), + details: None, + recoverable: false, + suggestion: Some("请在远程服务器上查看 Helper 日志排查问题。".to_string()), + }); + } + + String::from_utf8(output.stdout).map_err(|_| RemoteError { + code: "invalid_utf8".to_string(), + message: "Helper 返回了无效的 UTF-8 数据".to_string(), + details: None, + recoverable: false, + suggestion: Some("这可能表示 Helper 二进制损坏。请尝试重新安装 Helper。".to_string()), + }) +} + +pub fn run_helper_json( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, + retries: u32, +) -> Result { + let mut last_error: Option = None; + + for attempt in 0..=retries { + if attempt > 0 { + // 指数退避:2s / 4s / 8s + let delay = Duration::from_secs(2u64.saturating_mul(1 << (attempt - 1))); + std::thread::sleep(delay); + } + + match try_run_ssh(profile, helper_args, timeout_secs) { + Ok(stdout) => match parse_helper_response::(&stdout) { + Ok(data) => return Ok(data), + Err(e) => { + last_error = Some(e); + // JSON 解析失败不重试(不是网络问题) + break; + } + }, + Err(e) => { + let recoverable = is_recoverable_error(&e); + last_error = Some(e); + if !recoverable { + break; + } + // 可恢复错误继续重试 + } + } + } + + Err(last_error.unwrap_or_else(|| RemoteError { + code: "unknown".to_string(), + message: "未知远程错误".to_string(), + details: None, + recoverable: false, + suggestion: Some("请查看日志或联系支持".to_string()), + })) +} + +/// 便捷方法:使用默认超时和不重试。 +/// 注意:内部 `auth_runtime_options` 和 `build_ssh_command_spec` 各计算一次 SshAuthPlan +/// (开销极低,仅为几个字符串分配,可忽略不计)。 +pub fn run_helper_json_simple( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json(profile, helper_args, DEFAULT_CMD_TIMEOUT_SECS, 0) +} + +/// 便捷方法:使用默认超时和默认重试。 +pub fn run_helper_json_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json( + profile, + helper_args, + DEFAULT_CMD_TIMEOUT_SECS, + DEFAULT_RETRIES, + ) +} + +pub fn run_helper_json_stdin_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json_stdin( + profile, + helper_args, + DEFAULT_CMD_TIMEOUT_SECS, + DEFAULT_RETRIES, + ) +} + +fn run_helper_json_stdin( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, + retries: u32, +) -> Result { + let mut last_error: Option = None; + + for attempt in 0..=retries { + if attempt > 0 { + let delay = Duration::from_secs(2u64.saturating_mul(1 << (attempt - 1))); + std::thread::sleep(delay); + } + + match try_run_ssh_stdin(profile, helper_args, timeout_secs) { + Ok(stdout) => match parse_helper_response::(&stdout) { + Ok(data) => return Ok(data), + Err(e) => { + last_error = Some(e); + break; + } + }, + Err(e) => { + let recoverable = is_recoverable_error(&e); + last_error = Some(e); + if !recoverable { + break; + } + } + } + } + + Err(last_error.unwrap_or_else(|| RemoteError { + code: "unknown".to_string(), + message: "未知远程错误".to_string(), + details: None, + recoverable: false, + suggestion: Some("请查看日志或联系支持".to_string()), + })) +} + +/// 用于慢速操作(如安装 helper、验证 key)。 +pub fn run_helper_json_slow( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json(profile, helper_args, SLOW_CMD_TIMEOUT_SECS, DEFAULT_RETRIES) +} + +pub fn run_helper_install(profile: &RemoteHostProfile) -> Result { + let (runtime, _broker) = auth_runtime_options(profile)?; + let spec = build_helper_install_command_spec(profile, runtime)?; + run_ssh_command( + profile, + spec, + SLOW_CMD_TIMEOUT_SECS, + "install-helper".to_string(), + ) +} + +// ============================================================================ +// 内部实现 +// ============================================================================ + +/// 执行 `ssh ... ` 并返回 stdout 字符串。 + +pub fn run_ssh_script( + profile: &RemoteHostProfile, + remote_cmd: &str, + timeout_secs: u64, +) -> Result { + let (runtime, _broker) = auth_runtime_options(profile)?; + let auth_plan = SshAuthPlan::from_profile(profile, runtime); + let mut args = build_ssh_base_args_with_plan(profile, &auth_plan); + args.push(remote_cmd.to_string()); + let mut command = hide_cmd(Command::new("ssh")); + command.args(&args); + for (key, value) in &auth_plan.env { + command.env(key, value); + } + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| RemoteError { + code: "ssh_spawn_failed".to_string(), + message: format!("无法启动 SSH 客户端:{e}"), + details: Some(format!("请确认 OpenSSH 客户端已安装并在 PATH 中:{e}")), + recoverable: false, + suggestion: Some( + "Windows 10+ 自带 OpenSSH。请在系统可选功能中确认已安装。".to_string(), + ), + })?; + + let output = match wait_with_timeout_legacy(child, Duration::from_secs(timeout_secs)) { + Ok(result) => result.map_err(|e| RemoteError { + code: "ssh_io_error".to_string(), + message: format!("SSH 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), + })?, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + return Err(RemoteError { + code: "ssh_timeout".to_string(), + message: format!("SSH 命令执行超时({}秒)", timeout_secs), + details: Some(format!( + "命令:{} {} {}", + profile.host, profile.username, remote_cmd + )), + recoverable: true, + suggestion: Some("网络慢或远程命令卡住。请检查 SSH 连接后重试。".to_string()), + }); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(RemoteError { + code: "ssh_thread_panic".to_string(), + message: "SSH 执行线程异常退出".to_string(), + details: None, + recoverable: false, + suggestion: Some("这可能是程序错误。请报告此问题。".to_string()), + }); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(map_ssh_error(profile, &stderr, output.status.code())); + } + + const MAX_SCRIPT_OUTPUT_SIZE: usize = 1024 * 1024; + if output.stdout.len() > MAX_SCRIPT_OUTPUT_SIZE { + return Err(RemoteError { + code: "output_too_large".to_string(), + message: format!("SSH 输出过大({} 字节)", output.stdout.len()), + details: None, + recoverable: false, + suggestion: Some("请在远程服务器上查看命令输出排查问题。".to_string()), + }); + } + + String::from_utf8(output.stdout).map_err(|_| RemoteError { + code: "invalid_utf8".to_string(), + message: "SSH 返回了无效的 UTF-8 数据".to_string(), + details: None, + recoverable: false, + suggestion: Some("请检查远程 Shell 输出。".to_string()), + }) +} + +#[cfg(feature = "desktop")] +fn auth_runtime_options( + profile: &RemoteHostProfile, +) -> Result<(AuthRuntimeOptions, Option), RemoteError> { + let mut runtime = AuthRuntimeOptions::default_for(profile); + let session_dir = askpass_session_dir(); + runtime.askpass_path = Some(askpass_executable()?.to_string_lossy().into_owned()); + runtime.askpass_session_dir = Some(session_dir.to_string_lossy().into_owned()); + runtime + .askpass_env + .push(("CSSWITCH_ASKPASS_MODE".to_string(), "1".to_string())); + + let plan = SshAuthPlan::from_profile(profile, runtime.clone()); + if !plan.interactive { + return Ok((runtime, None)); + } + + let app = askpass::app_handle().ok_or_else(|| RemoteError { + code: "ssh_auth_prompt_unavailable".to_string(), + message: "需要输入登录信息,但当前窗口还没有准备好".to_string(), + details: None, + recoverable: true, + suggestion: Some("请稍后重试连接。".to_string()), + })?; + let broker = AskpassBroker::start(app, session_dir, profile.transient_password.clone()) + .map_err(|e| RemoteError { + code: "ssh_auth_prompt_failed".to_string(), + message: "无法打开 SSH 登录输入窗口".to_string(), + details: Some(e), + recoverable: true, + suggestion: Some("请重试连接;如果仍失败,请检查应用日志。".to_string()), + })?; + Ok((runtime, Some(broker))) +} + +#[cfg(not(feature = "desktop"))] +fn auth_runtime_options( + profile: &RemoteHostProfile, +) -> Result<(AuthRuntimeOptions, ()), RemoteError> { + Ok((AuthRuntimeOptions::default_for(profile), ())) +} + +#[cfg(feature = "desktop")] +fn askpass_executable() -> Result { + std::env::current_exe().map_err(|e| RemoteError { + code: "ssh_askpass_path_failed".to_string(), + message: "无法定位 SSH 登录辅助程序".to_string(), + details: Some(e.to_string()), + recoverable: false, + suggestion: Some("请重新安装或重新启动 CSSwitch。".to_string()), + }) +} + +#[cfg(feature = "desktop")] +fn askpass_session_dir() -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + crate::config::default_dir() + .join("ssh-askpass") + .join(format!("{}-{now}", std::process::id())) +} + +fn try_run_ssh( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, +) -> Result { + let (runtime, _broker) = auth_runtime_options(profile)?; + let spec = build_ssh_command_spec(profile, helper_args, runtime); + run_ssh_command(profile, spec, timeout_secs, helper_args.join(" ")) +} + +pub(crate) fn helper_stdin_payload(helper_args: &[String]) -> Result, RemoteError> { + let request = serde_json::json!({ + "id": "request", + "command": helper_args, + }); + let mut payload = serde_json::to_vec(&request).map_err(|e| RemoteError { + code: "helper_request_serialize_failed".to_string(), + message: format!("序列化 Helper 请求失败:{e}"), + details: None, + recoverable: false, + suggestion: None, + })?; + payload.push(b'\n'); + Ok(payload) +} + +fn try_run_ssh_stdin( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, +) -> Result { + let (runtime, _broker) = auth_runtime_options(profile)?; + let spec = build_ssh_stdin_command_spec(profile, runtime); + let payload = helper_stdin_payload(helper_args)?; + run_ssh_command_with_stdin(profile, spec, timeout_secs, "serve".to_string(), &payload) +} + +fn run_ssh_command( + profile: &RemoteHostProfile, + spec: SshCommandSpec, + timeout_secs: u64, + command_details: String, +) -> Result { + let mut command = hide_cmd(Command::new("ssh")); + command.args(&spec.args); + for (key, value) in &spec.env { + command.env(key, value); + } + let output = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| RemoteError { + code: "ssh_spawn_failed".to_string(), + message: format!("无法启动 SSH 客户端:{e}"), + details: Some(format!("请确认 OpenSSH 客户端已安装并在 PATH 中:{e}")), + recoverable: false, + suggestion: Some( + "Windows 10+ 自带 OpenSSH。请在「设置→应用→可选功能」中确认已安装。".to_string(), + ), + })?; + + let output = match wait_with_timeout(output, Duration::from_secs(timeout_secs)) { + Ok(Some(output)) => output, + Ok(None) => { + return Err(RemoteError { + code: "ssh_timeout".to_string(), + message: format!("SSH 命令执行超时({}秒)", timeout_secs), + details: Some(format!( + "命令:{} {} {}", + profile.host, profile.username, command_details + )), + recoverable: true, + suggestion: Some( + "网络慢或远程命令卡住。请检查网络连接,或在 SSH 配置中设置超时参数。" + .to_string(), + ), + }); + } + Err(e) => { + return Err(RemoteError { + code: "ssh_io_error".to_string(), + message: format!("SSH 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), + }) + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(map_ssh_error(profile, &stderr, output.status.code())); + } + + // P1-11 修复:限制 Helper 输出大小,防止 OOM + const MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1MB + if output.stdout.len() > MAX_OUTPUT_SIZE { + return Err(RemoteError { + code: "output_too_large".to_string(), + message: format!( + "Helper 输出过大({} 字节,限制 {} 字节)", + output.stdout.len(), + MAX_OUTPUT_SIZE + ), + details: Some("输出被截断以防止内存溢出".to_string()), + recoverable: false, + suggestion: Some( + "请在远程服务器上查看 Helper 日志文件排查问题(csswitch-helper logs proxy)。" + .to_string(), + ), + }); + } + + String::from_utf8(output.stdout).map_err(|_| RemoteError { + code: "invalid_utf8".to_string(), + message: "Helper 返回了无效的 UTF-8 数据".to_string(), + details: None, + recoverable: false, + suggestion: Some("这可能表示 Helper 二进制损坏。请尝试重新安装 Helper。".to_string()), + }) +} + +fn run_ssh_command_with_stdin( + profile: &RemoteHostProfile, + spec: SshCommandSpec, + timeout_secs: u64, + command_details: String, + stdin_bytes: &[u8], +) -> Result { + let mut command = hide_cmd(Command::new("ssh")); + command.args(&spec.args); + for (key, value) in &spec.env { + command.env(key, value); + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| RemoteError { + code: "ssh_spawn_failed".to_string(), + message: format!("无法启动 SSH 客户端:{e}"), + details: Some(format!("请确认 OpenSSH 客户端已安装并在 PATH 中:{e}")), + recoverable: false, + suggestion: Some( + "Windows 10+ 自带 OpenSSH。请在「设置→应用→可选功能」中确认已安装。".to_string(), + ), + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(stdin_bytes).map_err(|e| RemoteError { + code: "ssh_stdin_failed".to_string(), + message: format!("写入 SSH 命令 stdin 失败:{e}"), + details: None, + recoverable: true, + suggestion: Some("请检查 SSH 连接是否稳定,并重试。".to_string()), + })?; + } + + let output = match wait_with_timeout(child, Duration::from_secs(timeout_secs)) { + Ok(Some(output)) => output, + Ok(None) => { + return Err(RemoteError { + code: "ssh_timeout".to_string(), + message: format!("SSH 命令执行超时({}秒)", timeout_secs), + details: Some(format!( + "命令:{} {} {}", + profile.host, profile.username, command_details + )), + recoverable: true, + suggestion: Some( + "网络慢或远程命令卡住。请检查网络连接,或在 SSH 配置中设置超时参数。" + .to_string(), + ), + }); + } + Err(e) => { + return Err(RemoteError { + code: "ssh_io_error".to_string(), + message: format!("SSH 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), + }) + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(map_ssh_error(profile, &stderr, output.status.code())); + } + + String::from_utf8(output.stdout).map_err(|_| RemoteError { + code: "invalid_utf8".to_string(), + message: "Helper 返回了无效的 UTF-8 数据".to_string(), + details: None, + recoverable: false, + suggestion: Some("这可能表示 Helper 二进制损坏。请尝试重新安装 Helper。".to_string()), + }) +} + +const CAPTURE_OUTPUT_LIMIT: usize = 1024 * 1024 + 1; + +fn read_limited(mut reader: impl Read) -> Vec { + let mut buf = Vec::new(); + let _ = reader + .by_ref() + .take(CAPTURE_OUTPUT_LIMIT as u64) + .read_to_end(&mut buf); + buf +} + +pub(crate) fn wait_with_timeout( + mut child: std::process::Child, + timeout: Duration, +) -> std::io::Result> { + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = std::thread::spawn(move || stdout.map(read_limited).unwrap_or_default()); + let stderr_reader = std::thread::spawn(move || stderr.map(read_limited).unwrap_or_default()); + + let started = std::time::Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break Some(status); + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(50)); + }; + + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + Ok(status.map(|status| Output { + status, + stdout, + stderr, + })) +} + +fn wait_with_timeout_legacy( + child: std::process::Child, + timeout: Duration, +) -> Result, std::sync::mpsc::RecvTimeoutError> { + match wait_with_timeout(child, timeout) { + Ok(Some(output)) => Ok(Ok(output)), + Ok(None) => Err(std::sync::mpsc::RecvTimeoutError::Timeout), + Err(e) => Ok(Err(e)), + } +} + +/// 解析 helper 的 `{"ok":true,"data":...}` JSON 响应。 +pub(crate) fn parse_helper_response(stdout: &str) -> Result { + // 取最后一行非空内容(忽略 shell 登录 banner 等噪声) + let json_line = stdout + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or(stdout) + .trim(); + + let envelope: serde_json::Value = serde_json::from_str(json_line).map_err(|e| RemoteError { + code: "invalid_json".to_string(), + message: format!("Helper 返回了无效的 JSON:{e}"), + details: Some(format!( + "原始输出(截断):{}", + &json_line[..json_line.len().min(200)] + )), + recoverable: false, + suggestion: Some("Helper 版本可能不兼容。请尝试重新安装 Helper。".to_string()), + })?; + + let ok = envelope + .get("ok") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if ok { + let data = envelope + .get("data") + .cloned() + .unwrap_or(serde_json::Value::Null); + serde_json::from_value(data).map_err(|e| RemoteError { + code: "data_parse_error".to_string(), + message: format!("Helper 返回数据格式不匹配:{e}"), + details: None, + recoverable: false, + suggestion: Some("Helper 版本可能不兼容。请尝试升级 Helper。".to_string()), + }) + } else { + let error = envelope.get("error"); + Err(RemoteError { + code: error + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()) + .unwrap_or("helper_error") + .to_string(), + message: error + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("Helper 命令执行失败") + .to_string(), + details: error + .and_then(|e| e.get("details")) + .and_then(|d| d.as_str()) + .map(|s| s.to_string()), + recoverable: false, + suggestion: error + .and_then(|e| e.get("suggestion")) + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + }) + } +} + +/// 将 SSH 错误输出映射为结构化的 `RemoteError`。 +fn map_ssh_error(profile: &RemoteHostProfile, stderr: &str, exit_code: Option) -> RemoteError { + let stderr_lower = stderr.to_lowercase(); + + // 认证失败(不可重试) + // 注意:只包含 "permission denied" 但没有 "publickey"/"authentication failed" + // 的是文件权限错误,由后面 permission_denied 分支处理。 + if stderr_lower.contains("publickey") || stderr_lower.contains("authentication failed") { + return RemoteError { + code: "ssh_auth_failed".to_string(), + message: "SSH 认证失败,请检查用户名和密钥配置".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + match &profile.auth_method { + RemoteAuthMethod::KeyFile { .. } => { + "请确认私钥文件路径正确且已添加到远程服务器的 authorized_keys。" + } + RemoteAuthMethod::SshAgent => { + "请确认 ssh-agent 已运行且已添加对应密钥(ssh-add -l 查看)。" + } + RemoteAuthMethod::Recommended { .. } => { + "请检查服务器地址、用户名、密码或密钥文件是否正确。" + } + RemoteAuthMethod::Password { .. } => "请确认服务器密码正确。", + } + .to_string(), + ), + }; + } + + // 连接超时/拒绝(可重试) + if stderr_lower.contains("connection timed out") + || stderr_lower.contains("connection refused") + || stderr_lower.contains("no route to host") + || stderr_lower.contains("network is unreachable") + { + return RemoteError { + code: "ssh_connection_failed".to_string(), + message: format!( + "无法连接到 {}:{},请检查网络和服务器地址", + profile.host, profile.port + ), + details: Some(stderr.to_string()), + recoverable: true, + suggestion: Some( + "请确认:1) 服务器地址和端口正确 2) 防火墙允许 SSH 3) 服务器 SSH 服务正在运行" + .to_string(), + ), + }; + } + + // Helper 未找到 + if stderr_lower.contains("no such file") + || stderr_lower.contains("not found") + || stderr.contains("没有那个文件或目录") + { + return RemoteError { + code: "helper_not_found".to_string(), + message: format!( + "远程 Helper 未安装或路径不正确(当前:{})", + profile.helper_path + ), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请点击「安装 Helper」按钮自动安装,或手动部署 Helper 到服务器。".to_string(), + ), + }; + } + + // P1-6 修复:增强 SSH 错误映射,覆盖更多常见错误场景 + + // 磁盘空间不足 + if stderr_lower.contains("no space left") + || stderr_lower.contains("disk full") + || stderr_lower.contains("write failed") + { + return RemoteError { + code: "disk_full".to_string(), + message: "远程服务器磁盘空间不足".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请清理远程服务器磁盘空间,或使用 df -h 检查磁盘使用情况。".to_string(), + ), + }; + } + + // 权限不足(写入/执行权限)—— 非认证类的 permission denied + if stderr_lower.contains("permission denied") { + return RemoteError { + code: "permission_denied".to_string(), + message: "远程服务器权限不足".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请确认远程用户对 Helper 路径和日志目录有读写权限(chmod +x helper_path)。" + .to_string(), + ), + }; + } + + // Shell 配置错误(bashrc/profile 报错) + if stderr_lower.contains("command not found") + || stderr_lower.contains("syntax error") + || stderr_lower.contains(".bashrc") + || stderr_lower.contains(".profile") + { + return RemoteError { + code: "shell_config_error".to_string(), + message: "远程 Shell 配置异常".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请检查远程用户的 .bashrc 或 .profile 文件是否有错误(临时解决:ssh -t user@host /bin/bash --noprofile)。".to_string() + ), + }; + } + + // 端口被占用 + if stderr_lower.contains("address already in use") + || stderr_lower.contains("port is already allocated") + || stderr_lower.contains("bind: address already in use") + { + return RemoteError { + code: "port_in_use".to_string(), + message: "远程服务器端口已被占用".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请停止占用该端口的进程(lsof -i :端口 或 netstat -tulpn | grep 端口),或更换端口。".to_string() + ), + }; + } + + // 主机密钥变更(中间人攻击警告) + if stderr_lower.contains("remote host identification has changed") + || stderr_lower.contains("host key verification failed") + { + return RemoteError { + code: "host_key_changed".to_string(), + message: "远程服务器主机密钥已变更".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "服务器可能被重装或存在安全风险。请确认服务器身份后,手动删除 ~/.ssh/known_hosts 中的旧密钥。".to_string() + ), + }; + } + + // 未知错误(兜底) + RemoteError { + code: format!("ssh_exit_{}", exit_code.unwrap_or(-1)), + message: format!( + "SSH 命令执行失败(退出码 {})", + exit_code.map_or("未知".to_string(), |c| c.to_string()) + ), + details: Some(stderr.to_string()), + recoverable: exit_code.map_or(false, |c| c == 255), // 255 通常为连接错误,可重试 + suggestion: Some( + "请在终端手动执行 SSH 命令排查问题:ssh -vvv user@host(-vvv 开启详细日志)。" + .to_string(), + ), + } +} + +/// 判断错误是否可重试(网络类错误可重试,认证/配置类不可重试)。 +fn is_recoverable_error(error: &RemoteError) -> bool { + error.recoverable + && matches!( + error.code.as_str(), + "ssh_io_error" | "ssh_connection_failed" | "ssh_exit_255" | "ssh_spawn_failed" + ) +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/// 安全的 shell 引号转义。 +/// 如果参数只包含安全字符(字母数字 + `-_./:`),不添加引号; +/// 否则用单引号包裹并转义内部单引号。 +pub(crate) fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./:~".contains(c)) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +// ============================================================================ +// 测试 +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::super::types::RemoteSshAdvancedOptions; + use super::*; + + fn sample_profile() -> RemoteHostProfile { + RemoteHostProfile { + id: "test".to_string(), + name: "Test".to_string(), + kind: super::super::types::RemoteTargetKind::Ssh, + host: "example.com".to_string(), + port: 22, + distribution: None, + username: "testuser".to_string(), + auth_method: RemoteAuthMethod::SshAgent, + helper_path: "/usr/local/bin/csswitch-helper".to_string(), + last_connected: None, + ssh_options: RemoteSshAdvancedOptions::default(), + transient_password: None, + } + } + + #[test] + fn ssh_args_include_connect_timeout() { + let args = build_ssh_args(&sample_profile(), &["status".to_string()]); + assert!(args.contains(&"-o".to_string())); + assert!(args.contains(&"ConnectTimeout=10".to_string())); + } + + #[test] + fn stdin_helper_args_do_not_put_payload_on_ssh_command_line() { + let args = build_ssh_stdin_args(&sample_profile()); + let joined = args.join(" "); + assert!(joined.contains("--json serve")); + assert!(!joined.contains("sk-secret")); + assert!(!joined.contains("config set")); + } + + #[test] + fn helper_release_repo_is_detected_without_static_default() { + assert_eq!( + resolve_helper_release_repo_from( + Some("bfzha/CSswitch-wsl_linux"), + Some("SuperJJ007/CSswitch"), + None, + ) + .unwrap(), + "bfzha/CSswitch-wsl_linux" + ); + assert_eq!( + resolve_helper_release_repo_from( + None, + Some("SuperJJ007/CSswitch"), + Some("git@github.com:ignored/repo.git"), + ) + .unwrap(), + "SuperJJ007/CSswitch" + ); + assert_eq!( + resolve_helper_release_repo_from( + None, + None, + Some("https://github.com/bfzha/CSswitch-wsl_linux.git"), + ) + .unwrap(), + "bfzha/CSswitch-wsl_linux" + ); + assert!(resolve_helper_release_repo_from(None, None, None).is_err()); + } + + #[test] + fn ssh_args_include_batch_mode_for_sshagent() { + let args = build_ssh_args(&sample_profile(), &["status".to_string()]); + assert!(args.contains(&"BatchMode=yes".to_string())); + } + + #[test] + fn ssh_args_include_keyfile_for_key_auth() { + let mut p = sample_profile(); + p.auth_method = RemoteAuthMethod::KeyFile { + path: "~/.ssh/id_ed25519".to_string(), + save_key_password: true, + allow_password_fallback: true, + allow_verification_code: true, + remember_connection: true, + }; + let args = build_ssh_args(&p, &["status".to_string()]); + assert!(args.contains(&"-i".to_string())); + assert!(args.contains(&"~/.ssh/id_ed25519".to_string())); + } + + #[test] + fn password_command_spec_uses_askpass_environment() { + let mut p = sample_profile(); + p.auth_method = RemoteAuthMethod::Password { + save_password: true, + allow_verification_code: true, + remember_connection: true, + }; + + let spec = build_ssh_command_spec(&p, &["status".to_string()], AuthRuntimeOptions::test()); + + assert!(spec.args.contains(&"BatchMode=no".to_string())); + assert!(spec.args.contains(&"NumberOfPasswordPrompts=3".to_string())); + assert_env(&spec.env, "SSH_ASKPASS", "csswitch-ssh-askpass"); + assert_env(&spec.env, "SSH_ASKPASS_REQUIRE", "force"); + assert_env(&spec.env, "DISPLAY", "csswitch"); + assert_env(&spec.env, "CSSWITCH_ASKPASS_PROFILE", "test"); + assert_env( + &spec.env, + "CSSWITCH_ASKPASS_DIR", + "csswitch-askpass-session", + ); + } + + #[test] + fn key_password_command_spec_passes_key_path_to_askpass() { + let mut p = sample_profile(); + p.auth_method = RemoteAuthMethod::KeyFile { + path: "~/.ssh/id_ed25519".to_string(), + save_key_password: true, + allow_password_fallback: false, + allow_verification_code: false, + remember_connection: false, + }; + + let spec = build_ssh_command_spec(&p, &["status".to_string()], AuthRuntimeOptions::test()); + + assert!(spec.args.contains(&"BatchMode=no".to_string())); + assert_env(&spec.env, "CSSWITCH_ASKPASS_KEY_PATH", "~/.ssh/id_ed25519"); + } + + #[test] + fn ssh_agent_command_spec_stays_noninteractive() { + let spec = build_ssh_command_spec( + &sample_profile(), + &["status".to_string()], + AuthRuntimeOptions::test(), + ); + + assert!(spec.args.contains(&"BatchMode=yes".to_string())); + assert!(spec.args.contains(&"NumberOfPasswordPrompts=0".to_string())); + assert!(spec.env.is_empty()); + } + + fn assert_env(env: &[(String, String)], key: &str, value: &str) { + assert_eq!( + env.iter() + .find(|(env_key, _)| env_key == key) + .map(|(_, env_value)| env_value.as_str()), + Some(value) + ); + } + + #[test] + fn shell_quote_leaves_safe_strings_unchanged() { + assert_eq!(shell_quote("hello-world"), "hello-world"); + assert_eq!( + shell_quote("/usr/local/bin/helper"), + "/usr/local/bin/helper" + ); + } + + #[test] + fn shell_quote_quotes_unsafe_strings() { + let quoted = shell_quote("hello world"); + assert!(quoted.starts_with('\'')); + assert!(quoted.ends_with('\'')); + } + + #[test] + fn parse_response_handles_ok() { + let json = r#"{"ok":true,"data":{"status":"running"}}"#; + let result: serde_json::Value = parse_helper_response(json).unwrap(); + assert_eq!(result["status"], "running"); + } + + #[test] + fn parse_response_handles_error() { + let json = r#"{"ok":false,"error":{"code":"test_error","message":"something went wrong"}}"#; + let result: Result = parse_helper_response(json); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, "test_error"); + } + + #[test] + fn parse_response_takes_last_nonempty_line() { + let multi = "Login banner\n\n{\"ok\":true,\"data\":42}"; + let result: i32 = parse_helper_response(multi).unwrap(); + assert_eq!(result, 42); + } + + #[test] + fn recoverable_errors_are_marked_as_such() { + let err = map_ssh_error(&sample_profile(), "Connection timed out", Some(255)); + assert!(err.recoverable); + assert_eq!(err.code, "ssh_connection_failed"); + } + + #[test] + fn auth_errors_are_not_recoverable() { + let err = map_ssh_error( + &sample_profile(), + "Permission denied (publickey)", + Some(255), + ); + assert!(!err.recoverable); + assert_eq!(err.code, "ssh_auth_failed"); + } +} diff --git a/desktop/src-tauri/src/remote/store.rs b/desktop/src-tauri/src/remote/store.rs new file mode 100644 index 0000000..68bacbb --- /dev/null +++ b/desktop/src-tauri/src/remote/store.rs @@ -0,0 +1,344 @@ +//! 远程服务器 Profile 的本地持久化存储。 +//! +//! Profile 文件位置:`~/.csswitch/remote-hosts.json` +//! +//! 格式:JSON 数组 `[RemoteHostProfile]`。 +//! 支持 CRUD(Create/Read/Update/Delete)操作 + 校验。 + +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use super::types::{RemoteAuthMethod, RemoteHostProfile, RemoteTargetKind}; + +lazy_static::lazy_static! { + static ref PROFILE_STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + +/// 返回远程 Profile 文件的完整路径:`~/.csswitch/remote-hosts.json`。 +/// 跨平台:使用 `dirs::home_dir()` 获取用户主目录。 +pub fn profiles_path() -> PathBuf { + crate::config::default_dir().join("remote-hosts.json") +} + +// ============================================================================ +// CRUD 操作 +// ============================================================================ + +/// 从 `remote-hosts.json` 读取所有远程 Profile。 +/// 文件不存在时返回空 Vec(首次使用)。 +pub fn load_profiles() -> Result, String> { + let path = profiles_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&path) + .map_err(|e| format!("无法读取远程服务器配置 {}:{e}", path.display()))?; + if raw.trim().is_empty() { + return Ok(Vec::new()); + } + let profiles: Vec = serde_json::from_str(&raw) + .map_err(|e| format!("远程服务器配置格式错误 {}:{e}", path.display()))?; + for profile in &profiles { + validate_profile(profile)?; + } + Ok(profiles) +} + +/// 将 Profile 列表写入 `remote-hosts.json`(安全写入:symlink 防护 + 原子 rename)。 +/// 父目录不存在时自动创建。 +/// 审核 P1-5 修复:增加 symlink 防护,对齐 `config.rs` 的安全标准。 +/// P0-3 修复:保存后设置文件权限为 0600,防止其他用户读取 SSH 配置。 +/// P1-5 修复:使用文件锁防止并发写入时数据丢失。 +pub fn save_profiles(profiles: &[RemoteHostProfile]) -> Result<(), String> { + for profile in profiles { + validate_profile(profile)?; + } + let path = profiles_path(); + // 拒绝符号链接目标(防止写入重定向到非预期文件)。 + crate::config::assert_not_symlink(&path).map_err(|e| format!("远程配置路径安全拒绝:{e}"))?; + if let Some(parent) = path.parent() { + crate::config::assert_not_symlink(parent) + .map_err(|e| format!("远程配置父目录安全拒绝:{e}"))?; + fs::create_dir_all(parent) + .map_err(|e| format!("无法创建远程配置目录 {}:{e}", parent.display()))?; + } + + // P1-5 修复:使用文件锁防止并发写入 + // 锁文件放在与目标文件相同目录,使用 .lock 后缀 + let lock_path = path.with_extension("json.lock"); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(&lock_path) + .map_err(|e| format!("无法创建锁文件 {}:{e}", lock_path.display()))?; + + // 尝试获取排他锁,超时 5 秒 + let lock_acquired = { + use fs2::FileExt; + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + + loop { + match lock_file.try_lock_exclusive() { + Ok(_) => break true, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if start.elapsed() >= timeout { + break false; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + return Err(format!("文件锁操作失败:{e}")); + } + } + } + }; + + if !lock_acquired { + return Err("获取文件锁超时(5秒)。可能有其他操作正在保存配置,请稍后重试。".to_string()); + } + + // 在锁保护下执行写入操作 + let result = (|| { + let json = + serde_json::to_vec_pretty(profiles).map_err(|e| format!("序列化远程配置失败:{e}"))?; + // 原子写入:pid+thread 随机化临时文件名(避免并发冲突) + let tmp = path.with_file_name(format!( + ".remote-hosts.json.tmp.{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + fs::write(&tmp, &json).map_err(|e| format!("写入远程配置临时文件失败:{e}"))?; + + // P0-3 修复:在 rename 前先设置临时文件权限为 0600 + // 这样 rename 后目标文件继承正确的权限 + crate::fs_ext::set_file_permissions(&tmp, 0o600) + .map_err(|e| format!("设置远程配置文件权限失败:{e}"))?; + + fs::rename(&tmp, &path).map_err(|e| format!("替换远程配置文件失败:{e}"))?; + + // 双重保险:rename 后再次确认权限(某些文件系统可能不保留权限) + crate::fs_ext::set_file_permissions(&path, 0o600) + .map_err(|e| format!("确认远程配置文件权限失败:{e}"))?; + + Ok(()) + })(); + + // 释放锁(文件关闭时自动释放,这里显式 unlock 以便错误处理) + drop(lock_file); // 显式关闭文件释放锁 + + result +} + +/// 插入或更新一个 Profile(按 `id` 匹配)。 +/// 不存在则插入到列表头部(最近使用的排前面)。 +pub fn upsert_profile(profile: RemoteHostProfile) -> Result { + validate_profile(&profile)?; + let _guard = PROFILE_STORE_LOCK + .lock() + .map_err(|_| "远程配置锁异常".to_string())?; + let mut profiles = load_profiles()?; + if let Some(existing) = profiles.iter_mut().find(|p| p.id == profile.id) { + *existing = profile.clone(); + } else { + profiles.insert(0, profile.clone()); + } + save_profiles(&profiles)?; + Ok(profile) +} + +/// 删除指定 `id` 的 Profile。返回 true 表示成功删除,false 表示未找到。 +pub fn delete_profile(id: &str) -> Result { + let _guard = PROFILE_STORE_LOCK + .lock() + .map_err(|_| "远程配置锁异常".to_string())?; + let mut profiles = load_profiles()?; + let before = profiles.len(); + profiles.retain(|p| p.id != id); + if profiles.len() == before { + return Ok(false); + } + save_profiles(&profiles)?; + Ok(true) +} + +// ============================================================================ +// 校验 +// ============================================================================ + +/// 校验 Profile 的各字段是否合法。 +/// - host:非空 +/// - port:1-65535 +/// - username:非空 +/// - helper_path:非空且格式为绝对路径(以 `/` 或 `~` 开头) +/// - KeyFile 路径:非空(如果 auth_method 为 KeyFile) +pub fn validate_profile(profile: &RemoteHostProfile) -> Result<(), String> { + if profile.id.trim().is_empty() { + return Err("远程服务器 Profile ID 不得为空".into()); + } + match profile.kind { + RemoteTargetKind::Ssh => { + if profile.host.trim().is_empty() { + return Err("远程服务器地址不得为空".into()); + } + if profile.port == 0 { + return Err("远程 SSH 端口不得为 0".into()); + } + } + RemoteTargetKind::Wsl => { + if profile + .distribution + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err("WSL 发行版不得为空".into()); + } + } + } + if profile.username.trim().is_empty() { + return Err("远程目标用户名不得为空".into()); + } + if profile.helper_path.trim().is_empty() { + return Err("Helper 路径不得为空".into()); + } + // 校验 helper_path 格式:应该是绝对路径或以 ~ 开头 + let hp = profile.helper_path.trim(); + if !hp.starts_with('/') && !hp.starts_with('~') { + return Err(format!("Helper 路径应为绝对路径或以 ~ 开头:{hp}")); + } + if let RemoteAuthMethod::KeyFile { path, .. } = &profile.auth_method { + if path.trim().is_empty() { + return Err("选择私钥文件认证时,密钥路径不得为空".into()); + } + } + Ok(()) +} + +// ============================================================================ +// 测试 +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::super::types::RemoteSshAdvancedOptions; + use super::*; + + fn sample_profile(id: &str) -> RemoteHostProfile { + RemoteHostProfile { + id: id.to_string(), + name: "测试服务器".to_string(), + kind: super::super::types::RemoteTargetKind::Ssh, + host: "192.168.1.100".to_string(), + port: 22, + distribution: None, + username: "testuser".to_string(), + auth_method: RemoteAuthMethod::SshAgent, + helper_path: "~/.csswitch/bin/csswitch-helper".to_string(), + last_connected: None, + ssh_options: RemoteSshAdvancedOptions::default(), + transient_password: None, + } + } + + fn tmp_path() -> PathBuf { + let d = std::env::temp_dir().join(format!("csswitch-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d.join("remote-hosts.json") + } + + #[test] + fn test_crud_roundtrip() { + let p = tmp_path(); + // 初始为空 + // (实际调用 load_profiles 使用的是 profiles_path(),我们不 override,改为测试 core logic) + let profile = sample_profile("test-01"); + validate_profile(&profile).unwrap(); + // core logic: save, load, upsert, delete + let single = vec![profile.clone()]; + let json = serde_json::to_vec_pretty(&single).unwrap(); + fs::write(&p, &json).unwrap(); + let loaded: Vec = + serde_json::from_slice(&fs::read(&p).unwrap()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "test-01"); + + // Delete + let loaded: Vec = + serde_json::from_slice(&fs::read(&p).unwrap()).unwrap(); + let remaining: Vec<_> = loaded.into_iter().filter(|pr| pr.id != "test-01").collect(); + let json = serde_json::to_vec_pretty(&remaining).unwrap(); + fs::write(&p, &json).unwrap(); + let loaded: Vec = + serde_json::from_slice(&fs::read(&p).unwrap()).unwrap(); + assert_eq!(loaded.len(), 0); + + let _ = fs::remove_file(&p); + } + + #[test] + fn test_validation_accepts_wsl_without_host_or_port() { + let mut p = sample_profile("wsl-1"); + p.kind = RemoteTargetKind::Wsl; + p.name = "Ubuntu".to_string(); + p.host = String::new(); + p.port = 0; + p.distribution = Some("Ubuntu".to_string()); + p.username = "zhawei".to_string(); + validate_profile(&p).unwrap(); + } + + #[test] + fn test_validation_rejects_wsl_without_distribution() { + let mut p = sample_profile("wsl-2"); + p.kind = RemoteTargetKind::Wsl; + p.host = String::new(); + p.port = 0; + p.distribution = None; + assert!(validate_profile(&p).is_err()); + } + + #[test] + fn test_validation_rejects_empty_host() { + let mut p = sample_profile("t1"); + p.host = "".to_string(); + assert!(validate_profile(&p).is_err()); + } + + #[test] + fn test_validation_rejects_empty_username() { + let mut p = sample_profile("t2"); + p.username = "".to_string(); + assert!(validate_profile(&p).is_err()); + } + + #[test] + fn test_validation_rejects_zero_port() { + let mut p = sample_profile("t3"); + p.port = 0; + assert!(validate_profile(&p).is_err()); + } + + #[test] + fn test_validation_rejects_relative_helper_path() { + let mut p = sample_profile("t4"); + p.helper_path = "csswitch-helper".to_string(); + assert!(validate_profile(&p).is_err()); + } + + #[test] + fn test_validation_rejects_empty_keyfile_path() { + let mut p = sample_profile("t5"); + p.auth_method = RemoteAuthMethod::KeyFile { + path: "".to_string(), + save_key_password: true, + allow_password_fallback: true, + allow_verification_code: true, + remember_connection: true, + }; + assert!(validate_profile(&p).is_err()); + } +} diff --git a/desktop/src-tauri/src/remote/transport.rs b/desktop/src-tauri/src/remote/transport.rs new file mode 100644 index 0000000..dc1a297 --- /dev/null +++ b/desktop/src-tauri/src/remote/transport.rs @@ -0,0 +1,65 @@ +//! Unified remote helper transport dispatch. +//! +//! SSH and WSL targets share the same csswitch-helper JSON protocol. This +//! module keeps command handlers from branching on transport details. + +use serde::de::DeserializeOwned; + +use super::types::{RemoteError, RemoteHostProfile, RemoteTargetKind}; +use super::{ssh, wsl}; + +pub fn run_helper_json_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + match profile.kind { + RemoteTargetKind::Ssh => ssh::run_helper_json_with_retry(profile, helper_args), + RemoteTargetKind::Wsl => wsl::run_helper_json_with_retry(profile, helper_args), + } +} + +pub fn run_helper_json_stdin_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + match profile.kind { + RemoteTargetKind::Ssh => ssh::run_helper_json_stdin_with_retry(profile, helper_args), + RemoteTargetKind::Wsl => wsl::run_helper_json_stdin_with_retry(profile, helper_args), + } +} + +pub fn run_helper_json_slow( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + match profile.kind { + RemoteTargetKind::Ssh => ssh::run_helper_json_slow(profile, helper_args), + RemoteTargetKind::Wsl => wsl::run_helper_json_slow(profile, helper_args), + } +} + +pub fn run_helper_install(profile: &RemoteHostProfile) -> Result { + match profile.kind { + RemoteTargetKind::Ssh => ssh::run_helper_install(profile), + RemoteTargetKind::Wsl => wsl::run_helper_install(profile), + } +} + +pub fn install_helper_from_stdin( + profile: &RemoteHostProfile, + helper_bytes: &[u8], +) -> Result { + match profile.kind { + RemoteTargetKind::Ssh => ssh::install_helper_from_stdin(profile, helper_bytes), + RemoteTargetKind::Wsl => wsl::install_helper_from_stdin(profile, helper_bytes), + } +} + +pub fn detect_remote_platform( + profile: &RemoteHostProfile, +) -> Result<(String, String), RemoteError> { + match profile.kind { + RemoteTargetKind::Ssh => ssh::detect_remote_platform(profile), + RemoteTargetKind::Wsl => wsl::detect_remote_platform(profile), + } +} diff --git a/desktop/src-tauri/src/remote/types.rs b/desktop/src-tauri/src/remote/types.rs new file mode 100644 index 0000000..4671c50 --- /dev/null +++ b/desktop/src-tauri/src/remote/types.rs @@ -0,0 +1,386 @@ +//! 远程服务器管理的数据类型。 +//! +//! 定义与远程 Linux 服务器通信所需的全部结构体: +//! - SSH 连接 Profile(RemoteHostProfile) +//! - 健康报告(RemoteHealth) +//! - JSON-line 协议信封(RemoteRequest / RemoteResponse) +//! +//! 设计参考 cc-switch-remote 的 `remote/types.rs`,按 CSSwitch 实际需求大幅简化。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +fn default_true() -> bool { + true +} + +fn default_remote_target_kind() -> RemoteTargetKind { + RemoteTargetKind::Ssh +} + +fn default_ssh_port() -> u16 { + 22 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_legacy_ssh_agent_auth_method() { + let auth: RemoteAuthMethod = serde_json::from_str(r#"{"type":"sshAgent"}"#).unwrap(); + assert_eq!(auth, RemoteAuthMethod::SshAgent); + } + + #[test] + fn legacy_ssh_agent_profile_still_deserializes() { + let raw = r#"{ + "id":"r1", + "name":"old", + "host":"example.com", + "port":22, + "username":"ubuntu", + "authMethod":{"type":"sshAgent"}, + "helperPath":"~/.csswitch/bin/csswitch-helper" + }"#; + + let profile: RemoteHostProfile = serde_json::from_str(raw).unwrap(); + + assert!(matches!(profile.auth_method, RemoteAuthMethod::SshAgent)); + assert!(matches!(profile.kind, RemoteTargetKind::Ssh)); + assert_eq!(profile.port, 22); + assert!(!profile.ssh_options.legacy_compat); + assert!(profile.ssh_options.extra_args.is_empty()); + } + + #[test] + fn wsl_profile_deserializes() { + let raw = r#"{ + "id":"w1", + "name":"Ubuntu", + "kind":"wsl", + "distribution":"Ubuntu", + "username":"zhawei", + "authMethod":{"type":"recommended"}, + "helperPath":"~/.csswitch/bin/csswitch-helper" + }"#; + + let profile: RemoteHostProfile = serde_json::from_str(raw).unwrap(); + + assert!(matches!(profile.kind, RemoteTargetKind::Wsl)); + assert_eq!(profile.distribution.as_deref(), Some("Ubuntu")); + assert_eq!(profile.username, "zhawei"); + assert_eq!(profile.port, 22); + } + + #[test] + fn transient_password_deserializes_but_is_never_serialized() { + let raw = r#"{ + "id":"r1", + "name":"lab", + "host":"example.com", + "port":22, + "username":"ubuntu", + "authMethod":{"type":"password"}, + "helperPath":"~/.csswitch/bin/csswitch-helper", + "transientPassword":"server-password" + }"#; + + let profile: RemoteHostProfile = serde_json::from_str(raw).unwrap(); + assert_eq!( + profile.transient_password.as_deref(), + Some("server-password") + ); + + let saved = serde_json::to_string(&profile).unwrap(); + assert!(!saved.contains("transientPassword")); + assert!(!saved.contains("server-password")); + } + + #[test] + fn deserializes_recommended_auth_method() { + let auth: RemoteAuthMethod = serde_json::from_str( + r#"{ + "type":"recommended", + "useSavedKeys":true, + "useDefaultKeyFiles":true, + "allowPassword":true, + "allowVerificationCode":true, + "rememberConnection":true + }"#, + ) + .unwrap(); + + assert_eq!( + auth, + RemoteAuthMethod::Recommended { + use_saved_keys: true, + use_default_key_files: true, + allow_password: true, + allow_verification_code: true, + remember_connection: true, + strict: false, + } + ); + } + + #[test] + fn recommended_auth_defaults_are_user_friendly() { + let auth: RemoteAuthMethod = serde_json::from_str(r#"{"type":"recommended"}"#).unwrap(); + + assert_eq!( + auth, + RemoteAuthMethod::Recommended { + use_saved_keys: true, + use_default_key_files: true, + allow_password: true, + allow_verification_code: true, + remember_connection: true, + strict: false, + } + ); + } +} + +// ============================================================================ +// Profile 与认证 +// ============================================================================ + +/// 远程服务器连接 Profile,持久存储在本地 `~/.csswitch/remote-hosts.json`。 +/// 每个 Profile 描述如何通过 SSH 连接到一台远程 Linux 服务器。 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSshAdvancedOptions { + #[serde(default)] + pub legacy_compat: bool, + #[serde(default)] + pub extra_args: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RemoteTargetKind { + Ssh, + Wsl, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteHostProfile { + /// 唯一标识符(UUID v4)。 + pub id: String, + /// 用户友好名称,如 "实验室服务器" 或 "Ubuntu"。 + pub name: String, + /// 连接目标类型:SSH 服务器或本机 WSL。 + #[serde(default = "default_remote_target_kind")] + pub kind: RemoteTargetKind, + /// 服务器 IP 地址或域名。WSL 目标不使用。 + #[serde(default)] + pub host: String, + /// SSH 端口,默认 22。WSL 目标不使用。 + #[serde(default = "default_ssh_port")] + pub port: u16, + /// WSL 发行版名称,如 Ubuntu。仅 WSL 目标使用。 + #[serde(default)] + pub distribution: Option, + /// SSH 登录用户名或 WSL Linux 用户。 + pub username: String, + /// 认证方式。WSL 目标复用该配置,用于后续凭据/交互提示。 + pub auth_method: RemoteAuthMethod, + /// 远程/WSL Helper 二进制路径,通常为 `~/.csswitch/bin/csswitch-helper`。 + pub helper_path: String, + /// 最近一次成功连接的时间戳(Unix 秒),用于 UI 排序与提示。 + #[serde(default)] + pub last_connected: Option, + #[serde(default)] + pub ssh_options: RemoteSshAdvancedOptions, + #[serde(default, skip_serializing)] + pub transient_password: Option, +} + +impl std::fmt::Debug for RemoteHostProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteHostProfile") + .field("id", &self.id) + .field("name", &self.name) + .field("kind", &self.kind) + .field("host", &self.host) + .field("port", &self.port) + .field("distribution", &self.distribution) + .field("username", &self.username) + .field("auth_method", &self.auth_method) + .field("helper_path", &self.helper_path) + .field("last_connected", &self.last_connected) + .field("ssh_options", &self.ssh_options) + .field( + "transient_password", + &self.transient_password.as_ref().map(|_| ""), + ) + .finish() + } +} + +/// SSH 认证方式。 +/// MVP 阶段不支持 Password(Windows 上 SSH_ASKPASS 兼容性不佳), +/// 推荐使用 SSH Agent 或私钥文件。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum RemoteAuthMethod { + /// 使用本地 SSH Agent(`ssh-agent`),无需指定密钥路径。 + SshAgent, + Recommended { + #[serde(default = "default_true")] + use_saved_keys: bool, + #[serde(default = "default_true")] + use_default_key_files: bool, + #[serde(default = "default_true")] + allow_password: bool, + #[serde(default = "default_true")] + allow_verification_code: bool, + #[serde(default = "default_true")] + remember_connection: bool, + #[serde(default)] + strict: bool, + }, + Password { + #[serde(default = "default_true")] + save_password: bool, + #[serde(default = "default_true")] + allow_verification_code: bool, + #[serde(default = "default_true")] + remember_connection: bool, + }, + /// 使用指定私钥文件(如 `~/.ssh/id_ed25519`)。 + KeyFile { + /// 私钥文件的绝对路径。 + path: String, + #[serde(default = "default_true")] + save_key_password: bool, + #[serde(default = "default_true")] + allow_password_fallback: bool, + #[serde(default = "default_true")] + allow_verification_code: bool, + #[serde(default = "default_true")] + remember_connection: bool, + }, +} + +// ============================================================================ +// 健康报告 +// ============================================================================ + +/// 远程服务器健康状态报告。 +/// 由 `remote_check_health` Tauri 命令通过 SSH 调用 helper `status` 获得。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteHealth { + /// SSH 连接是否成功(`ssh echo test` 通过)。 + pub reachable: bool, + /// Helper 二进制是否存在且可执行。 + pub helper_installed: bool, + /// Helper 版本号(如 "0.3.0"),未安装时为 None。 + pub helper_version: Option, + /// 桌面端版本号(`CARGO_PKG_VERSION`),用于版本兼容性检查。 + pub desktop_version: String, + /// Helper 版本与桌面端是否兼容。 + pub compatible: bool, + /// 远程平台,如 "linux"、"darwin"。 + pub platform: Option, + /// 远程 CPU 架构,如 "x86_64"、"aarch64"。 + pub arch: Option, + /// Helper 支持的能力列表(`proxy`、`sandbox`、`config` 等)。 + pub capabilities: Vec, + /// 代理进程是否正在运行。 + pub proxy_running: bool, + /// 沙箱 Science 是否正在运行。 + pub sandbox_running: bool, + /// 最近一次错误信息。 + pub last_error: Option, + /// 健康检查的时间戳(Unix 秒)。 + pub last_check: i64, +} + +// ============================================================================ +// JSON-line 协议信封 +// ============================================================================ + +/// 发送给远程 Helper 的请求。 +/// 在 serve 模式下,桌面端通过 SSH stdin 逐行发送 JSON 格式的请求。 +/// 当前仅在一次命令模式使用,serve 持久会话模式预留。 +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteRequest { + /// 请求唯一 ID(UUID v4),用于 serve 模式匹配响应。 + pub id: String, + /// Helper 命令参数,如 `["proxy", "start", "deepseek", "18991", ""]`。 + pub command: Vec, +} + +/// 远程 Helper 返回的响应。 +/// 在 serve 模式下,Helper 通过 SSH stdout 逐行返回 JSON 格式的响应。 +/// serve 持久会话模式预留。 +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteResponse { + /// 对应请求的 ID。 + pub id: String, + /// 操作是否成功。 + pub ok: bool, + /// 成功时的返回数据。 + pub data: Option, + /// 失败时的错误详情。 + pub error: Option, +} + +// ============================================================================ +// 错误类型 +// ============================================================================ + +/// 远程操作错误结构,提供用于用户提示和故障诊断的完整信息。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteError { + /// 错误码,如 `ssh_timeout`、`helper_not_found`、`port_in_use`。 + pub code: String, + /// 用户友好的错误消息。 + pub message: String, + /// 技术细节(可选),用于日志和高级诊断。 + #[serde(default)] + pub details: Option, + /// 错误是否可重试(true=用户可点击重试,false=需先修复根本原因)。 + #[serde(default)] + pub recoverable: bool, + /// 修复建议(可选),如 "点击'安装 Helper'按钮"、"检查网络连接"。 + #[serde(default)] + pub suggestion: Option, +} + +// ============================================================================ +// CSSwitch Helper 能力列表 +// ============================================================================ + +/// Helper 应支持的最少能力集。桌面端通过 capability 检查(而非 semver 比较) +/// 确认 Helper 版本是否兼容。 +/// 预留给 future 版本兼容性检查逻辑使用。 +#[allow(dead_code)] +pub const MIN_HELPER_VERSION: &str = "0.3.0"; + +/// Helper 必须支持的 capability 列表。 +/// 桌面端调用 `status` 命令后检查返回值中的 `capabilities` 是否包含所有这些项。 +pub const REQUIRED_CAPABILITIES: &[&str] = &[ + "proxy", // 翻译代理进程管理 + "config", // ~/.csswitch/config.json 读写 + "logs", // 日志文件查看 + "doctor", // 诊断命令 + "verify", // Key 有效性验证 + "proxy-bundle-v2", // 托管代理包含 csswitch_proxy.py 及其 Python 依赖 +]; + +/// Helper 可选 capability(sandbox 在无 Science 的服务器上可能不可用)。 +/// 预留给 future 能力检测和 UI 适配使用。 +#[allow(dead_code)] +pub const OPTIONAL_CAPABILITIES: &[&str] = &[ + "sandbox", // Claude Science 沙箱管理(需 Science 二进制) +]; diff --git a/desktop/src-tauri/src/remote/wsl.rs b/desktop/src-tauri/src/remote/wsl.rs new file mode 100644 index 0000000..f7391f5 --- /dev/null +++ b/desktop/src-tauri/src/remote/wsl.rs @@ -0,0 +1,869 @@ +//! WSL transport for local Windows Linux distributions. +//! +//! WSL targets reuse the same csswitch-helper JSON protocol as SSH targets, but +//! enter Linux through `wsl.exe` instead of `ssh user@host`. + +use std::io::Write; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use super::ssh; +use super::types::{RemoteError, RemoteHostProfile}; + +#[cfg(windows)] +const WSL_EXE: &str = "wsl.exe"; +#[cfg(not(windows))] +const WSL_EXE: &str = "wsl.exe"; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WslDistribution { + pub name: String, + pub state: Option, + pub version: Option, + pub is_default: bool, +} + +fn decode_wsl_output(bytes: &[u8]) -> String { + if let Some(boundary) = mixed_wsl_utf16_prefix_boundary(bytes) { + let mut decoded = decode_utf16le_lossy(&bytes[..boundary]); + decoded.push_str(&String::from_utf8_lossy(&bytes[boundary..])); + decoded + } else if looks_like_utf16le(bytes) { + decode_utf16le_lossy(bytes) + } else { + String::from_utf8_lossy(bytes).into_owned() + } +} + +fn looks_like_utf16le(bytes: &[u8]) -> bool { + bytes.iter().filter(|byte| **byte == 0).count() > bytes.len().saturating_div(8) +} + +fn decode_utf16le_lossy(bytes: &[u8]) -> String { + let units = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + String::from_utf16_lossy(&units) +} + +fn mixed_wsl_utf16_prefix_boundary(bytes: &[u8]) -> Option { + const WSL_PREFIX: &[u8] = b"w\0s\0l\0:\0"; + if !bytes.starts_with(WSL_PREFIX) { + return None; + } + + let mut i = 0; + while i + 1 < bytes.len() { + let unit = u16::from_le_bytes([bytes[i], bytes[i + 1]]); + if unit == b'\n' as u16 { + let next = i + 2; + if next < bytes.len() && !looks_like_utf16le_fragment(&bytes[next..]) { + return Some(next); + } + } + i += 2; + } + None +} + +fn looks_like_utf16le_fragment(bytes: &[u8]) -> bool { + let pair_count = bytes.len().min(32) / 2; + if pair_count == 0 { + return false; + } + let zero_high_bytes = (0..pair_count) + .filter(|idx| bytes[idx * 2 + 1] == 0) + .count(); + zero_high_bytes * 2 >= pair_count +} + +fn clean_wsl_stderr(stderr: &str) -> String { + let mut cleaned = stderr + .lines() + .filter(|line| !is_wsl_localhost_proxy_warning(line)) + .collect::>() + .join("\n"); + if stderr.ends_with('\n') && !cleaned.is_empty() { + cleaned.push('\n'); + } + cleaned +} + +fn is_wsl_localhost_proxy_warning(line: &str) -> bool { + let lower = line.to_lowercase(); + lower.starts_with("wsl:") + && lower.contains("localhost") + && lower.contains("nat") + && lower.contains("wsl") +} + +pub fn parse_wsl_list_verbose(raw: &str) -> Vec { + raw.lines() + .filter_map(parse_wsl_distribution_line) + .filter(|distro| !is_hidden_wsl_distro(&distro.name)) + .collect() +} + +fn parse_wsl_distribution_line(line: &str) -> Option { + let mut trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let upper = trimmed.to_ascii_uppercase(); + if upper.contains("NAME") && upper.contains("STATE") { + return None; + } + + let is_default = trimmed.starts_with('*'); + if is_default { + trimmed = trimmed.trim_start_matches('*').trim_start(); + } + + let parts = trimmed.split_whitespace().collect::>(); + if parts.is_empty() { + return None; + } + let version_idx = parts.iter().rposition(|value| value.parse::().is_ok()); + let version = version_idx.and_then(|idx| parts[idx].parse::().ok()); + let state_idx = version_idx.and_then(|idx| idx.checked_sub(1)); + let state = state_idx.map(|idx| parts[idx].to_string()); + let name_end_idx = state_idx.unwrap_or(parts.len()); + let name = parts[..name_end_idx].join(" "); + + Some(WslDistribution { + name, + state, + version, + is_default, + }) +} + +fn is_hidden_wsl_distro(name: &str) -> bool { + matches!(name, "docker-desktop" | "docker-desktop-data") +} + +pub fn list_wsl_distributions() -> Result, RemoteError> { + if !cfg!(windows) { + return Err(RemoteError { + code: "wsl_unsupported_platform".to_string(), + message: "本机 WSL 仅支持 Windows。".to_string(), + details: None, + recoverable: false, + suggestion: Some("请在 Windows 上使用本机 WSL,或改用远程服务器 SSH。".to_string()), + }); + } + + let output = ssh::hide_cmd(Command::new(WSL_EXE)) + .args(["--list", "--verbose"]) + .output() + .map_err(|e| RemoteError { + code: "wsl_spawn_failed".to_string(), + message: format!("无法执行 wsl.exe:{e}"), + details: Some("请确认 Windows Subsystem for Linux 已安装并在 PATH 中。".to_string()), + recoverable: false, + suggestion: Some( + "请先安装 WSL 和 Ubuntu,或在终端运行 wsl.exe --list --verbose 验证。".to_string(), + ), + })?; + + if !output.status.success() { + let stderr = clean_wsl_stderr(&decode_wsl_output(&output.stderr)) + .trim() + .to_string(); + return Err(map_wsl_error(&stderr, output.status.code(), None)); + } + + let stdout = decode_wsl_output(&output.stdout); + Ok(parse_wsl_list_verbose(&stdout)) +} + +pub fn build_wsl_args(profile: &RemoteHostProfile, helper_args: &[String]) -> Vec { + let helper_cmd = format!( + "{} --json {}", + ssh::shell_quote(&profile.helper_path), + helper_args + .iter() + .map(|arg| ssh::shell_quote(arg)) + .collect::>() + .join(" ") + ); + build_wsl_shell_args(profile, &helper_cmd) +} + +pub fn build_wsl_stdin_args(profile: &RemoteHostProfile) -> Vec { + let helper_cmd = format!("{} --json serve", ssh::shell_quote(&profile.helper_path)); + build_wsl_shell_args(profile, &helper_cmd) +} + +fn build_wsl_shell_args(profile: &RemoteHostProfile, script: &str) -> Vec { + let distro = profile.distribution.as_deref().unwrap_or_default(); + let mut args = vec!["-d".to_string(), distro.to_string()]; + if !profile.username.trim().is_empty() { + args.extend(["--user".to_string(), profile.username.clone()]); + } + args.extend([ + "--exec".to_string(), + "sh".to_string(), + "-lc".to_string(), + script.to_string(), + ]); + args +} + +pub fn run_helper_json( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, + retries: u32, +) -> Result { + let mut last_error: Option = None; + + for attempt in 0..=retries { + if attempt > 0 { + let delay = Duration::from_secs(2u64.saturating_mul(1 << (attempt - 1))); + std::thread::sleep(delay); + } + + match try_run_wsl(profile, helper_args, timeout_secs) { + Ok(stdout) => match ssh::parse_helper_response::(&stdout) { + Ok(data) => return Ok(data), + Err(e) => { + last_error = Some(e); + break; + } + }, + Err(e) => { + let recoverable = e.recoverable; + last_error = Some(e); + if !recoverable { + break; + } + } + } + } + + Err(last_error.unwrap_or_else(|| RemoteError { + code: "wsl_unknown".to_string(), + message: "未知 WSL 错误".to_string(), + details: None, + recoverable: false, + suggestion: Some("请查看日志或在终端运行 wsl.exe 验证。".to_string()), + })) +} + +pub fn run_helper_json_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json( + profile, + helper_args, + ssh::DEFAULT_CMD_TIMEOUT_SECS, + ssh::DEFAULT_RETRIES, + ) +} + +pub fn run_helper_json_stdin_with_retry( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json_stdin( + profile, + helper_args, + ssh::DEFAULT_CMD_TIMEOUT_SECS, + ssh::DEFAULT_RETRIES, + ) +} + +fn run_helper_json_stdin( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, + retries: u32, +) -> Result { + let mut last_error: Option = None; + + for attempt in 0..=retries { + if attempt > 0 { + let delay = Duration::from_secs(2u64.saturating_mul(1 << (attempt - 1))); + std::thread::sleep(delay); + } + + match try_run_wsl_stdin(profile, helper_args, timeout_secs) { + Ok(stdout) => match ssh::parse_helper_response::(&stdout) { + Ok(data) => return Ok(data), + Err(e) => { + last_error = Some(e); + break; + } + }, + Err(e) => { + let recoverable = e.recoverable; + last_error = Some(e); + if !recoverable { + break; + } + } + } + } + + Err(last_error.unwrap_or_else(|| RemoteError { + code: "wsl_unknown".to_string(), + message: "未知 WSL 错误".to_string(), + details: None, + recoverable: false, + suggestion: Some("请查看日志或在终端运行 wsl.exe 验证。".to_string()), + })) +} + +pub fn run_helper_json_slow( + profile: &RemoteHostProfile, + helper_args: &[String], +) -> Result { + run_helper_json( + profile, + helper_args, + ssh::SLOW_CMD_TIMEOUT_SECS, + ssh::DEFAULT_RETRIES, + ) +} + +pub fn detect_remote_platform( + profile: &RemoteHostProfile, +) -> Result<(String, String), RemoteError> { + let script = "printf '%s %s\\n' \"$(uname -s)\" \"$(uname -m)\""; + let stdout = run_wsl_shell_script(profile, script, ssh::DEFAULT_CMD_TIMEOUT_SECS)?; + let mut parts = stdout.split_whitespace(); + let os = parts.next().unwrap_or_default().to_ascii_lowercase(); + let arch = parts.next().unwrap_or_default().to_string(); + if os.is_empty() || arch.is_empty() { + return Err(RemoteError { + code: "wsl_platform_parse_failed".to_string(), + message: "无法识别 WSL 发行版的平台信息".to_string(), + details: Some(stdout), + recoverable: false, + suggestion: Some("请确认该 WSL 发行版可以执行 uname。".to_string()), + }); + } + Ok((os, arch)) +} + +pub fn run_helper_install(profile: &RemoteHostProfile) -> Result { + let helper_path = ssh::shell_quote(&profile.helper_path); + let repo = ssh::resolve_helper_release_repo()?; + let helper_version = env!("CARGO_PKG_VERSION"); + let script = format!( + r#"set -e +HELPER_PATH={helper_path} +HELPER_DIR=$(dirname "$HELPER_PATH") +mkdir -p "$HELPER_DIR" + +download() {{ + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + echo "WSL 发行版需要 curl 或 wget 来下载 helper。请手动安装。" >&2 + exit 1 + fi +}} + +ARCH_RAW=$(uname -m) +case "$ARCH_RAW" in + x86_64|amd64) ARCH=x86_64 ;; + aarch64|arm64) ARCH=aarch64 ;; + *) + echo "不支持的架构: $ARCH_RAW(仅支持 x86_64/aarch64)" >&2 + exit 1 + ;; +esac + +OS_RAW=$(uname -s) +case "$OS_RAW" in + Linux) OS=linux ;; + *) + echo "不支持的操作系统: $OS_RAW(仅支持 Linux)" >&2 + exit 1 + ;; +esac + +API_URL="https://api.github.com/repos/{repo}/releases/tags/v{helper_version}" +BINARY_NAME="csswitch-helper-${{OS}}-${{ARCH}}" +API_JSON=$(mktemp) +download "$API_URL" "$API_JSON" + +if command -v jq >/dev/null 2>&1; then + DOWNLOAD_URL=$(jq -r ".assets[] | select(.name==\"$BINARY_NAME\") | .browser_download_url" "$API_JSON" 2>/dev/null || true) +elif command -v python3 >/dev/null 2>&1; then + DOWNLOAD_URL=$(python3 -c " +import json,sys +data=json.load(open('$API_JSON')) +for a in data.get('assets',[]): + if a.get('name')=='$BINARY_NAME': + print(a['browser_download_url']) + break +" 2>/dev/null || true) +else + DOWNLOAD_URL=$(awk -v name="\"$BINARY_NAME\"" ' + $0 ~ name {{ found=1 }} + found && /browser_download_url/ {{ + if (match($0, /https:[^"]+/)) {{ + print substr($0, RSTART, RLENGTH) + exit + }} + }} + ' "$API_JSON" || true) +fi +rm -f "$API_JSON" + +if [ -z "$DOWNLOAD_URL" ]; then + echo "无法从 GitHub Releases 获取 $BINARY_NAME 下载链接。" >&2 + echo "手动安装: wget -O $HELPER_PATH && chmod +x $HELPER_PATH" >&2 + exit 1 +fi + +TMP=$(mktemp) +download "$DOWNLOAD_URL" "$TMP" +chmod +x "$TMP" +mv "$TMP" "$HELPER_PATH" +"$HELPER_PATH" --json status +"#, + helper_path = helper_path, + repo = repo, + helper_version = helper_version, + ); + run_wsl_shell_script(profile, &script, ssh::SLOW_CMD_TIMEOUT_SECS) +} + +pub fn install_helper_from_stdin( + profile: &RemoteHostProfile, + helper_bytes: &[u8], +) -> Result { + let helper_path = ssh::shell_quote(&profile.helper_path); + let script = format!( + concat!( + "set -e; ", + "helper_path={helper_path}; ", + "helper_dir=$(dirname \"$helper_path\"); ", + "mkdir -p \"$helper_dir\"; ", + "helper_tmp=$(mktemp); ", + "cat > \"$helper_tmp\"; ", + "chmod +x \"$helper_tmp\"; ", + "mv \"$helper_tmp\" \"$helper_path\"; ", + "\"$helper_path\" --json status" + ), + helper_path = helper_path + ); + run_wsl_shell_script_with_stdin(profile, &script, helper_bytes, ssh::SLOW_CMD_TIMEOUT_SECS) +} + +fn try_run_wsl( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, +) -> Result { + let args = build_wsl_args(profile, helper_args); + let mut command = ssh::hide_cmd(Command::new(WSL_EXE)); + command.args(&args); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + let child = command.spawn().map_err(|e| RemoteError { + code: "wsl_spawn_failed".to_string(), + message: format!("无法启动 wsl.exe:{e}"), + details: None, + recoverable: false, + suggestion: Some("请确认 Windows Subsystem for Linux 已安装。".to_string()), + })?; + collect_wsl_output(profile, child, timeout_secs) +} + +fn try_run_wsl_stdin( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, +) -> Result { + let args = build_wsl_stdin_args(profile); + let payload = ssh::helper_stdin_payload(helper_args)?; + let mut command = ssh::hide_cmd(Command::new(WSL_EXE)); + command.args(&args); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|e| RemoteError { + code: "wsl_spawn_failed".to_string(), + message: format!("无法启动 wsl.exe:{e}"), + details: None, + recoverable: false, + suggestion: Some("请确认 Windows Subsystem for Linux 已安装。".to_string()), + })?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(&payload).map_err(|e| RemoteError { + code: "wsl_stdin_failed".to_string(), + message: format!("写入 WSL 命令 stdin 失败:{e}"), + details: None, + recoverable: true, + suggestion: Some("请确认 WSL 发行版、Linux 用户和 Helper 路径权限正确。".to_string()), + })?; + } + collect_wsl_output(profile, child, timeout_secs) +} + +fn run_wsl_shell_script( + profile: &RemoteHostProfile, + script: &str, + timeout_secs: u64, +) -> Result { + run_wsl_shell_script_with_stdin(profile, script, &[], timeout_secs) +} + +fn run_wsl_shell_script_with_stdin( + profile: &RemoteHostProfile, + script: &str, + stdin_bytes: &[u8], + timeout_secs: u64, +) -> Result { + let args = build_wsl_shell_args(profile, script); + + let mut command = ssh::hide_cmd(Command::new(WSL_EXE)); + command.args(&args); + command.stdin(if stdin_bytes.is_empty() { + Stdio::null() + } else { + Stdio::piped() + }); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|e| RemoteError { + code: "wsl_spawn_failed".to_string(), + message: format!("无法启动 wsl.exe:{e}"), + details: None, + recoverable: false, + suggestion: Some("请确认 Windows Subsystem for Linux 已安装。".to_string()), + })?; + + if !stdin_bytes.is_empty() { + if let Some(mut stdin) = child.stdin.take() { + if let Err(write_error) = stdin.write_all(stdin_bytes) { + drop(stdin); + let stderr = ssh::wait_with_timeout(child, Duration::from_secs(timeout_secs)) + .ok() + .flatten() + .map(|output| clean_wsl_stderr(&decode_wsl_output(&output.stderr))) + .unwrap_or_default(); + return Err(RemoteError { + code: "wsl_stdin_failed".to_string(), + message: wsl_stdin_failed_message(&write_error, &stderr), + details: if stderr.trim().is_empty() { + None + } else { + Some(stderr) + }, + recoverable: true, + suggestion: Some( + "请确认 WSL 发行版、Linux 用户和 Helper 路径权限正确。".to_string(), + ), + }); + } + } + } + + collect_wsl_output(profile, child, timeout_secs) +} + +fn wsl_stdin_failed_message(write_error: &std::io::Error, stderr: &str) -> String { + let stderr = clean_wsl_stderr(stderr); + if stderr.trim().is_empty() { + format!("写入 WSL 命令 stdin 失败:{write_error}") + } else { + format!( + "写入 WSL 命令 stdin 失败:{write_error};WSL 错误:{}", + stderr.trim() + ) + } +} + +fn collect_wsl_output( + profile: &RemoteHostProfile, + child: std::process::Child, + timeout_secs: u64, +) -> Result { + let output = ssh::wait_with_timeout(child, Duration::from_secs(timeout_secs)).map_err(|e| { + RemoteError { + code: "wsl_io_error".to_string(), + message: format!("WSL 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), + } + })?; + + let Some(output) = output else { + return Err(RemoteError { + code: "wsl_timeout".to_string(), + message: format!("WSL 命令执行超时({}秒)", timeout_secs), + details: profile.distribution.clone(), + recoverable: true, + suggestion: Some("该 WSL 发行版可能正在启动或命令卡住,请稍后重试。".to_string()), + }); + }; + + if !output.status.success() { + let stderr = clean_wsl_stderr(&decode_wsl_output(&output.stderr)) + .trim() + .to_string(); + return Err(map_wsl_error(&stderr, output.status.code(), Some(profile))); + } + + const MAX_OUTPUT_SIZE: usize = 1024 * 1024; + if output.stdout.len() > MAX_OUTPUT_SIZE { + return Err(RemoteError { + code: "output_too_large".to_string(), + message: format!("WSL 输出过大({} 字节)", output.stdout.len()), + details: None, + recoverable: false, + suggestion: Some("请在 WSL 中查看 Helper 日志排查问题。".to_string()), + }); + } + + String::from_utf8(output.stdout).map_err(|_| RemoteError { + code: "invalid_utf8".to_string(), + message: "WSL 返回了无效的 UTF-8 数据".to_string(), + details: None, + recoverable: false, + suggestion: Some("请检查 WSL 命令输出。".to_string()), + }) +} + +fn map_wsl_error( + stderr: &str, + exit_code: Option, + profile: Option<&RemoteHostProfile>, +) -> RemoteError { + let lower = stderr.to_lowercase(); + if lower.contains("helper not installed") { + return RemoteError { + code: "helper_not_found".to_string(), + message: "WSL Helper 未安装或路径不正确".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请点击“安装 / 更新 Helper”,或检查 Helper 路径。".to_string()), + }; + } + if lower.contains("wslregisterdistribution failed") + || lower.contains("windows subsystem for linux has no installed distributions") + || lower.contains("wsl 2 requires an update") + { + return RemoteError { + code: "wsl_not_installed".to_string(), + message: "未找到可用的 WSL 环境".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请先安装 Windows Subsystem for Linux 和 Ubuntu。".to_string()), + }; + } + if lower.contains("there is no distribution") + || lower.contains("distribution") && lower.contains("not found") + || lower.contains("the specified distribution") + { + let distro = profile + .and_then(|p| p.distribution.as_deref()) + .unwrap_or("所选发行版"); + return RemoteError { + code: "wsl_distribution_not_found".to_string(), + message: format!("找不到 WSL 发行版:{distro}"), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请点击“重新扫描”后选择列表中的发行版。".to_string()), + }; + } + if lower.contains("user") && (lower.contains("not found") || lower.contains("does not exist")) { + let user = profile.map(|p| p.username.as_str()).unwrap_or("所选用户"); + return RemoteError { + code: "wsl_user_not_found".to_string(), + message: format!("WSL 用户不存在或无法启动:{user}"), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some( + "请确认该 Linux 用户存在,或在 WSL 中运行 whoami 查看用户名。".to_string(), + ), + }; + } + if lower.contains("no such file") + || lower.contains("not found") + || stderr.contains("没有那个文件或目录") + { + return RemoteError { + code: "helper_not_found".to_string(), + message: "WSL Helper 未安装或路径不正确".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请点击“安装 / 更新 Helper”,或检查 Helper 路径。".to_string()), + }; + } + if lower.contains("permission denied") { + return RemoteError { + code: "permission_denied".to_string(), + message: "WSL 内权限不足".to_string(), + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请确认 WSL 用户对 Helper 路径有读写和执行权限。".to_string()), + }; + } + + RemoteError { + code: exit_code + .map(|code| format!("wsl_exit_{code}")) + .unwrap_or_else(|| "wsl_failed".to_string()), + message: if stderr.trim().is_empty() { + "WSL 命令执行失败".to_string() + } else { + stderr.trim().to_string() + }, + details: Some(stderr.to_string()), + recoverable: false, + suggestion: Some("请在终端运行 wsl.exe 验证该发行版和用户是否可用。".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::remote::types::{RemoteAuthMethod, RemoteSshAdvancedOptions, RemoteTargetKind}; + + fn wsl_profile() -> RemoteHostProfile { + RemoteHostProfile { + id: "wsl".to_string(), + name: "Ubuntu".to_string(), + kind: RemoteTargetKind::Wsl, + host: String::new(), + port: 0, + distribution: Some("Ubuntu".to_string()), + username: "zhawei".to_string(), + auth_method: RemoteAuthMethod::Recommended { + use_saved_keys: true, + use_default_key_files: true, + allow_password: true, + allow_verification_code: true, + remember_connection: true, + strict: false, + }, + helper_path: "~/.csswitch/bin/csswitch-helper".to_string(), + last_connected: None, + ssh_options: RemoteSshAdvancedOptions::default(), + transient_password: None, + } + } + + #[test] + fn parses_wsl_list_verbose_output() { + let raw = " NAME STATE VERSION\n* Ubuntu 22.04 LTS Running 2\n Debian Stopped 2\n docker-desktop Running 2\n"; + let distros = parse_wsl_list_verbose(raw); + assert_eq!( + distros.iter().map(|d| d.name.as_str()).collect::>(), + vec!["Ubuntu 22.04 LTS", "Debian"] + ); + assert!(distros[0].is_default); + assert_eq!(distros[0].state.as_deref(), Some("Running")); + assert_eq!(distros[0].version, Some(2)); + } + + #[test] + fn decodes_utf16le_wsl_output() { + let raw = + " NAME STATE VERSION\n* Ubuntu Running 2\n"; + let bytes = raw + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>(); + let decoded = decode_wsl_output(&bytes); + assert!(decoded.contains("Ubuntu")); + } + + #[test] + fn decodes_mixed_wsl_warning_and_utf8_process_stderr() { + let warning = "wsl: 检测到 localhost 代理配置,但未镜像到 WSL。NAT 模式下的 WSL 不支持 localhost 代理。\r\n"; + let mut bytes = warning + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>(); + bytes.extend_from_slice( + "mkdir: cannot create directory '': No such file or directory\n".as_bytes(), + ); + + let decoded = decode_wsl_output(&bytes); + + assert!(decoded.contains("mkdir: cannot create directory")); + assert!(decoded.contains("No such file or directory")); + } + + #[test] + fn builds_wsl_helper_args() { + let profile = wsl_profile(); + let args = build_wsl_args(&profile, &["status".to_string()]); + assert_eq!( + args, + vec![ + "-d", + "Ubuntu", + "--user", + "zhawei", + "--exec", + "sh", + "-lc", + "~/.csswitch/bin/csswitch-helper --json status" + ] + ); + } + + #[test] + fn stdin_helper_args_do_not_put_payload_on_wsl_command_line() { + let profile = wsl_profile(); + let args = build_wsl_stdin_args(&profile); + let joined = args.join(" "); + assert!(joined.contains("--json serve")); + assert!(!joined.contains("sk-secret")); + assert!(!joined.contains("config set")); + } + + #[test] + fn maps_helper_not_installed_to_helper_not_found() { + let err = map_wsl_error("Helper not installed", Some(127), None); + + assert_eq!(err.code, "helper_not_found"); + assert!(err.message.contains("Helper")); + } + + #[test] + fn maps_real_wsl_not_installed_to_wsl_not_installed() { + let err = map_wsl_error("WslRegisterDistribution failed with error", Some(1), None); + + assert_eq!(err.code, "wsl_not_installed"); + } + + #[test] + fn wsl_stdin_failed_message_includes_stderr_when_available() { + let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe ended"); + let message = wsl_stdin_failed_message(&err, "user zhawei not found\n"); + + assert!(message.contains("pipe ended")); + assert!(message.contains("user zhawei not found")); + } + + #[test] + fn wsl_stdin_failed_message_filters_wsl_localhost_proxy_warning() { + let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe ended"); + let stderr = "wsl: 检测到 localhost 代理配置,但未镜像到 WSL。NAT 模式下的 WSL 不支持 localhost 代理。\nmkdir: cannot create directory '': No such file or directory\n"; + let message = wsl_stdin_failed_message(&err, stderr); + + assert!(!message.contains("localhost")); + assert!(message.contains("mkdir: cannot create directory")); + } +} diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs new file mode 100644 index 0000000..002df74 --- /dev/null +++ b/desktop/src-tauri/src/remote_commands.rs @@ -0,0 +1,918 @@ +//! 远程管理 Tauri Commands。 +//! +//! 本模块提供所有与远程 Linux 服务器交互的 Tauri 命令,前端通过 `invoke()` 调用。 +//! 每个命令委托给 `remote::ssh` 模块执行 SSH + Helper JSON 协议。 +//! SSH 操作本身是阻塞的,Tauri 会自动在后台线程池执行 `#[tauri::command]` fn。 +//! 对于需要在 async 上下文中调用的场景(如 health 内部递归调用),使用 +//! [`run_blocking`] 在独立线程中执行以避免阻塞当前 async runtime。 +//! +//! 命令分为四组: +//! 1. Profile 管理 — 增删改查远程服务器连接配置 +//! 2. 健康检查 — SSH 连通性、Helper 版本/能力检测 +//! 3. 代理/配置 — 远程代理启停、配置文件读写 +//! 4. 便利操作 — 一键开始、日志查看、诊断 + +use crate::remote::{ + self, RemoteAuthMethod, RemoteHealth, RemoteHostProfile, RemoteTargetKind, + REQUIRED_CAPABILITIES, +}; +use crate::{config, templates}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::Manager; + +// P1-8 修复:health check 结果缓存,减少频繁 SSH 连接 +lazy_static::lazy_static! { + static ref HEALTH_CACHE: Arc>> = + Arc::new(Mutex::new(HashMap::new())); +} + +const HEALTH_CACHE_TTL_SECS: u64 = 5; +/// 缓存最大条目数,防止管理大量远程服务器时内存泄漏。 +const HEALTH_CACHE_MAX_ENTRIES: usize = 32; + +// ============================================================================ +// 线程辅助 — 在独立 OS 线程中执行阻塞 I/O,避免卡住 Tauri 事件循环 +// ============================================================================ + +/// 在当前线程之外的独立 OS 线程中运行一段阻塞代码,通过 channel 取回结果。 +/// 用于 async 上下文中需要执行 SSH(需要 `Send + 'static`)的场景。 +/// 当前所有远程命令已改为 sync fn(由 Tauri 运行时自动分派到线程池), +/// 此函数预留给将来可能的持久会话模式(serve)或高频轮询场景。 +#[allow(dead_code)] +fn run_blocking(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv().unwrap_or(Err("后台任务线程异常退出".to_string())) +} + +// ============================================================================ +// 1. Profile 管理(纯本地 I/O,无需 spawn) +// ============================================================================ + +/// 列出所有远程服务器 Profile。 +#[tauri::command] +pub fn remote_list_profiles() -> Result, String> { + remote::load_profiles() +} + +/// 保存(新增或更新)一个远程服务器 Profile。 +#[tauri::command] +pub fn remote_save_profile(profile: RemoteHostProfile) -> Result { + remote::upsert_profile(profile) +} + +/// 删除指定 ID 的远程服务器 Profile。同时清理关联的健康检查缓存。 +#[tauri::command] +pub fn remote_delete_profile(id: String) -> Result { + let existing = remote::load_profiles()? + .into_iter() + .find(|profile| profile.id == id); + // P1-8 修复:删除 profile 时同步清理健康检查缓存 + { + let mut cache = HEALTH_CACHE.lock().unwrap(); + cache.remove(&id); + } + let deleted = remote::delete_profile(&id)?; + if deleted { + let _ = + remote::credentials::delete_secret(&id, remote::credentials::CredentialKind::Password); + if let Some(RemoteHostProfile { + auth_method: RemoteAuthMethod::KeyFile { path, .. }, + .. + }) = existing + { + let _ = remote::credentials::delete_secret( + &id, + remote::credentials::CredentialKind::KeyPassword(&path), + ); + } + } + Ok(deleted) +} + +/// 校验 Profile 字段但不保存。 +#[tauri::command] +pub fn remote_validate_profile(profile: RemoteHostProfile) -> Result { + remote::validate_profile(&profile).map(|_| true) +} + +#[tauri::command] +pub fn remote_list_wsl_distributions() -> Result, String> { + remote::wsl::list_wsl_distributions().map_err(|e| e.message) +} + +/// 保存远程登录信息到系统安全存储。不会写入 remote-hosts.json。 +#[tauri::command] +pub fn remote_save_login_secret( + profile_id: String, + kind: String, + key_path: Option, + secret: String, +) -> Result<(), String> { + if profile_id.trim().is_empty() { + return Err("远程服务器 ID 不能为空".to_string()); + } + let credential_kind = + remote::credentials::credential_kind_from_parts(&kind, key_path.as_deref())?; + remote::credentials::save_secret(&profile_id, credential_kind, &secret) +} + +/// 删除系统安全存储中的远程登录信息。不存在时视为已删除。 +#[tauri::command] +pub fn remote_delete_login_secret( + profile_id: String, + kind: String, + key_path: Option, +) -> Result<(), String> { + if profile_id.trim().is_empty() { + return Err("远程服务器 ID 不能为空".to_string()); + } + let credential_kind = + remote::credentials::credential_kind_from_parts(&kind, key_path.as_deref())?; + remote::credentials::delete_secret(&profile_id, credential_kind) +} + +#[tauri::command] +pub fn remote_auth_prompt_respond( + session_id: String, + request_id: String, + secret: Option, + cancelled: bool, + remember: bool, +) -> Result<(), String> { + remote::askpass::respond(&session_id, &request_id, secret, cancelled, remember) +} + +// ============================================================================ +// 2. 健康检查(SSH,阻塞 I/O) +// ============================================================================ + +/// 检查远程服务器健康状态:SSH 连通性 + Helper 版本/能力。 +/// SSH 是阻塞 I/O,Tauri 自动在后台线程执行此命令。 +/// P1-8 修复:使用缓存减少频繁 SSH 连接(TTL 5秒)。 +#[tauri::command] +pub fn remote_check_health(profile: RemoteHostProfile) -> Result { + // P1-8 修复:先检查缓存 + { + let cache = HEALTH_CACHE.lock().unwrap(); + if let Some((cached_health, cached_time)) = cache.get(&profile.id) { + if cached_time.elapsed().as_secs() < HEALTH_CACHE_TTL_SECS { + // 缓存未过期,直接返回 + return Ok(cached_health.clone()); + } + } + } + + // 缓存过期或不存在,执行实际检查 + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let status_result = + remote::transport::run_helper_json_with_retry::(&profile, &["status".to_string()]); + let health = health_from_status_result(status_result, now); + + // P1-8 修复:更新缓存(带上限保护) + cache_health(&profile.id, &health); + + Ok(health) +} + +fn remote_check_health_uncached(profile: &RemoteHostProfile) -> RemoteHealth { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let status_result = + remote::transport::run_helper_json_with_retry::(profile, &["status".to_string()]); + health_from_status_result(status_result, now) +} + +fn cache_health(profile_id: &str, health: &RemoteHealth) { + let mut cache = HEALTH_CACHE.lock().unwrap(); + // 缓存已满时,移除最旧的条目(简单 FIFO 策略) + if cache.len() >= HEALTH_CACHE_MAX_ENTRIES && !cache.contains_key(profile_id) { + if let Some(oldest_key) = cache + .iter() + .min_by_key(|(_, (_, t))| *t) + .map(|(k, _)| k.clone()) + { + cache.remove(&oldest_key); + } + } + cache.insert( + profile_id.to_string(), + (health.clone(), std::time::Instant::now()), + ); +} + +fn helper_ready_for_profile(health: &RemoteHealth) -> bool { + let has_required = REQUIRED_CAPABILITIES + .iter() + .chain(["sandbox"].iter()) + .all(|req| health.capabilities.iter().any(|cap| cap.as_str() == *req)); + let version_matches = health.helper_version.as_deref() == Some(health.desktop_version.as_str()); + + health.reachable + && health.helper_installed + && health.compatible + && version_matches + && health.platform.as_deref() == Some("linux") + && has_required +} + +fn install_helper_from_github(profile: &RemoteHostProfile) -> Result<(), String> { + remote::transport::run_helper_install(profile) + .map(|_| ()) + .map_err(|e| format!("GitHub Release 安装失败:{}", e.message)) +} + +fn bundled_helper_candidates(app: &tauri::AppHandle, arch: &str) -> Vec { + let filename = format!("csswitch-helper-linux-{arch}"); + let mut candidates = Vec::new(); + if let Ok(res) = app.path().resource_dir() { + candidates.push(res.join("helper-assets").join(&filename)); + } + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("helper-assets") + .join(&filename), + ); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("helper-assets").join(&filename)); + } + } + candidates +} + +fn bundled_helper_path(app: &tauri::AppHandle, arch: &str) -> Option { + bundled_helper_candidates(app, arch) + .into_iter() + .find(|path| path.is_file()) +} + +fn install_helper_from_bundle( + app: &tauri::AppHandle, + profile: &RemoteHostProfile, + arch: &str, +) -> Result<(), String> { + let path = bundled_helper_path(app, arch) + .ok_or_else(|| format!("安装包内没有适用于 linux/{arch} 的 Helper 资源"))?; + let bytes = + fs::read(&path).map_err(|e| format!("读取内置 Helper 失败({}):{e}", path.display()))?; + remote::transport::install_helper_from_stdin(profile, &bytes) + .map(|_| ()) + .map_err(|e| e.message) +} + +fn install_or_update_helper( + app: &tauri::AppHandle, + profile: &RemoteHostProfile, + arch: &str, +) -> Result<(), String> { + match profile.kind { + RemoteTargetKind::Wsl => { + let bundle_result = install_helper_from_bundle(app, profile, arch); + if let Err(bundle_err) = bundle_result { + install_helper_from_github(profile).map_err(|github_err| { + format!( + "自动安装 Helper 失败。内置上传失败:{bundle_err};GitHub 下载失败:{github_err}" + ) + })?; + } + Ok(()) + } + RemoteTargetKind::Ssh => { + let github_result = install_helper_from_github(profile); + if let Err(github_err) = github_result { + install_helper_from_bundle(app, profile, arch).map_err(|bundle_err| { + format!( + "自动安装 Helper 失败。GitHub 下载失败:{github_err};内置上传失败:{bundle_err}" + ) + })?; + } + Ok(()) + } + } +} + +/// 安装/升级远程 Helper。 +/// 通过 SSH 执行安装脚本:从 GitHub Releases 下载 helper 二进制到远程服务器。 +#[tauri::command] +pub fn remote_install_helper( + app: tauri::AppHandle, + profile: RemoteHostProfile, +) -> Result { + remote::validate_profile(&profile)?; + let (os, arch) = remote::transport::detect_remote_platform(&profile) + .map_err(|e| format!("连接目标失败:{}", e.message))?; + if os != "linux" { + return Err(format!( + "远程 Helper 目前仅支持 Linux,当前服务器是 {os}/{arch}。" + )); + } + if arch != "x86_64" && arch != "aarch64" { + return Err(format!("远程 Helper 暂不支持 {arch} 架构。")); + } + install_or_update_helper(&app, &profile, &arch)?; + let health = remote_check_health_uncached(&profile); + cache_health(&profile.id, &health); + Ok(health) +} + +#[tauri::command] +pub fn remote_prepare_helper( + app: tauri::AppHandle, + profile: RemoteHostProfile, +) -> Result { + remote::validate_profile(&profile)?; + + let initial = remote_check_health_uncached(&profile); + if helper_ready_for_profile(&initial) { + cache_health(&profile.id, &initial); + return Ok(initial); + } + + let (os, arch) = remote::transport::detect_remote_platform(&profile) + .map_err(|e| format!("连接目标失败:{}", e.message))?; + if os != "linux" { + return Err(format!( + "远程 Helper 目前仅支持 Linux,当前服务器是 {os}/{arch}。" + )); + } + if arch != "x86_64" && arch != "aarch64" { + return Err(format!("远程 Helper 暂不支持 {arch} 架构。")); + } + + install_or_update_helper(&app, &profile, &arch)?; + + let health = remote_check_health_uncached(&profile); + cache_health(&profile.id, &health); + if helper_ready_for_profile(&health) { + Ok(health) + } else { + Err(health.last_error.unwrap_or_else(|| { + "Helper 已安装但能力不完整,请重新安装最新版 CSSwitch 后重试。".to_string() + })) + } +} + +// ============================================================================ +// 3. 配置(SSH,阻塞 I/O)// ============================================================================ + +/// 读取远程服务器上的配置。 +#[tauri::command] +pub fn remote_get_config(profile: RemoteHostProfile) -> Result { + remote::transport::run_helper_json_with_retry::( + &profile, + &["config".to_string(), "get".to_string()], + ) + .map_err(|e| e.message) +} + +/// 写入远程配置。 +#[tauri::command] +pub fn remote_set_config(profile: RemoteHostProfile, config_json: String) -> Result<(), String> { + remote::transport::run_helper_json_stdin_with_retry::( + &profile, + &["config".to_string(), "set".to_string(), config_json], + ) + .map(|_| ()) + .map_err(|e| e.message) +} + +/// 保存 Provider Key 到远程配置。 +/// 返回掩码后的 key(仅末 4 位可见)。 +#[tauri::command] +pub fn remote_save_provider_key( + profile: RemoteHostProfile, + provider: String, + key: String, +) -> Result { + let result: Value = remote::transport::run_helper_json_stdin_with_retry::( + &profile, + &["config".to_string(), "save-key".to_string(), provider, key], + ) + .map_err(|e| e.message)?; + + Ok(result["masked"].as_str().unwrap_or("••••").to_string()) +} + +// ============================================================================ +// 4. 代理(SSH,阻塞 I/O) +// ============================================================================ + +fn remote_active_config_for_start( + provider: &str, + proxy_port: u16, + sandbox_port: Option, + secret: &str, +) -> Result<(config::Config, String), String> { + let cfg = config::load_from(&config::default_dir()).map_err(|e| e.to_string())?; + let active = cfg + .active_profile() + .cloned() + .ok_or("没有生效的配置 Profile。请先在本地配置里选择当前模型来源。")?; + let adapter = templates::adapter_for(&active.template_id).to_string(); + if !provider.is_empty() && provider != adapter && provider != active.template_id { + return Err(format!( + "远程启动来源不匹配:当前 Profile 是 {},但启动请求是 {provider}。", + active.template_id + )); + } + if active.api_key.trim().is_empty() { + return Err("当前 Profile 未填写 API Key。请填写后重试。".into()); + } + if adapter == "relay" { + if active.base_url.trim().is_empty() + || !(active.base_url.starts_with("http://") || active.base_url.starts_with("https://")) + { + return Err("relay 配置需要 http(s):// 开头的 base_url。".into()); + } + if active.model.trim().is_empty() { + return Err("relay 配置需要选择或填写模型。".into()); + } + } + + let remote_cfg = config::Config { + schema_version: config::CURRENT_SCHEMA_VERSION, + profiles: vec![active.clone()], + active_id: active.id.clone(), + proxy_port, + sandbox_port: sandbox_port.unwrap_or(cfg.sandbox_port), + secret: secret.to_string(), + mode: cfg.mode, + pending_notice: None, + }; + Ok((remote_cfg, adapter)) +} + +/// 启动远程代理。 +#[tauri::command] +pub fn remote_start_proxy( + profile: RemoteHostProfile, + provider: String, + port: u16, + secret: String, +) -> Result { + if port == 8765 { + return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); + } + if port == 0 { + return Err("端口不能为 0。".into()); + } + + let (remote_cfg, adapter) = remote_active_config_for_start(&provider, port, None, &secret)?; + let config_json = serde_json::to_string(&remote_cfg).map_err(|e| e.to_string())?; + + stop_remote_proxy(&profile).map_err(|e| format!("停止旧远程代理失败:{}", e.message))?; + + remote::transport::run_helper_json_stdin_with_retry::( + &profile, + &["config".to_string(), "set".to_string(), config_json], + ) + .map_err(|e| format!("同步当前 Profile 到服务器失败:{}", e.message))?; + + remote::transport::run_helper_json_with_retry::( + &profile, + &[ + "proxy".to_string(), + "start".to_string(), + adapter, + port.to_string(), + secret, + ], + ) + .map_err(|e| e.message) +} + +/// 停止远程代理。 +fn stop_remote_proxy(profile: &RemoteHostProfile) -> Result { + remote::transport::run_helper_json_with_retry::( + profile, + &["proxy".to_string(), "stop".to_string()], + ) +} + +#[tauri::command] +pub fn remote_stop_proxy(profile: RemoteHostProfile) -> Result<(), String> { + stop_remote_proxy(&profile) + .map(|_| ()) + .map_err(|e| e.message) +} + +fn stop_remote_sandbox(profile: &RemoteHostProfile) -> Result { + remote::transport::run_helper_json_with_retry::( + profile, + &["sandbox".to_string(), "stop".to_string()], + ) +} + +/// 停止远程沙箱与代理。 +#[tauri::command] +pub fn remote_stop_all(profile: RemoteHostProfile) -> Result { + let sandbox_profile = profile.clone(); + let sandbox_thread = std::thread::spawn(move || stop_remote_sandbox(&sandbox_profile)); + let proxy_res = stop_remote_proxy(&profile); + let sandbox_res = sandbox_thread.join().unwrap_or_else(|_| { + Err(remote::RemoteError { + code: "sandbox_stop_thread_panic".to_string(), + message: "停止远程沙箱线程异常退出".to_string(), + details: None, + recoverable: false, + suggestion: None, + }) + }); + + match (sandbox_res, proxy_res) { + (Ok(sandbox), Ok(proxy)) => Ok(json!({ + "ok": true, + "sandbox": sandbox, + "proxy": proxy, + })), + (Err(sandbox_err), Ok(_proxy)) => Err(format!( + "远程代理已停;但停止远程沙箱失败:{}", + sandbox_err.message + )), + (Ok(_), Err(proxy_err)) => Err(format!( + "远程沙箱已停;但停止远程代理失败:{}", + proxy_err.message + )), + (Err(sandbox_err), Err(proxy_err)) => Err(format!( + "停止远程沙箱失败:{};停止远程代理失败:{}", + sandbox_err.message, proxy_err.message + )), + } +} + +/// 查询远程代理状态。 +#[tauri::command] +pub fn remote_proxy_status(profile: RemoteHostProfile) -> Result { + remote::transport::run_helper_json_with_retry::( + &profile, + &["proxy".to_string(), "status".to_string()], + ) + .map_err(|e| e.message) +} + +/// 验证远程代理上的 Key 有效性(慢速:需经代理→上游往返)。 +#[tauri::command] +pub fn remote_verify_key( + profile: RemoteHostProfile, + port: u16, + secret: String, +) -> Result { + remote::transport::run_helper_json_slow::( + &profile, + &["verify".to_string(), port.to_string(), secret], + ) + .map_err(|e| e.message) +} + +// ============================================================================ +// 5. 便利操作 +// ============================================================================ + +/// 远程综合状态(三盏灯:proxy / sandbox / upstream)。 +/// 返回格式与本地 `status` 命令一致,前端 `refreshStatus()` 无需修改。 +#[tauri::command] +pub fn remote_status(profile: RemoteHostProfile) -> Result { + let status: Value = + remote::transport::run_helper_json_with_retry::(&profile, &["status".to_string()]) + .map_err(|e| e.message)?; + + let proxy_running = status["proxy_running"].as_bool().unwrap_or(false); + let upstream_reachable = if proxy_running { + status["upstream_reachable"].as_bool().unwrap_or(false) + || status["proxy_healthy"].as_bool().unwrap_or(false) + } else { + false + }; + + Ok(json!({ + "proxy": if proxy_running { "green" } else { "amber" }, + "sandbox": if status["sandbox_running"].as_bool().unwrap_or(false) { "green" } else { "amber" }, + "upstream": if upstream_reachable { "green" } else { "amber" }, + "remote": true, + })) +} + +/// 查看远程日志。 +#[tauri::command] +pub fn remote_logs( + profile: RemoteHostProfile, + name: String, + lines: Option, +) -> Result { + let mut args = vec!["logs".to_string(), name]; + if let Some(n) = lines { + args.push(n.to_string()); + } + remote::transport::run_helper_json_with_retry::(&profile, &args).map_err(|e| e.message) +} + +/// 远程诊断。 +#[tauri::command] +pub fn remote_doctor(profile: RemoteHostProfile) -> Result { + remote::transport::run_helper_json_with_retry::(&profile, &["doctor".to_string()]) + .map_err(|e| e.message) +} + +fn remote_tunnel_hint(profile: &RemoteHostProfile, sandbox_port: u16) -> String { + if matches!(profile.kind, RemoteTargetKind::Wsl) { + return "本机 WSL 目标无需 SSH 隧道;直接打开本机地址即可。".to_string(); + } + let mut parts = vec!["ssh".to_string()]; + if let RemoteAuthMethod::KeyFile { path, .. } = &profile.auth_method { + parts.push("-i".to_string()); + parts.push(path.clone()); + } + parts.push("-p".to_string()); + parts.push(profile.port.to_string()); + parts.push("-N".to_string()); + parts.push("-L".to_string()); + parts.push(format!("{sandbox_port}:127.0.0.1:{sandbox_port}")); + parts.push(format!("{}@{}", profile.username, profile.host)); + parts.join(" ") +} + +/// 远程一键开始:同步当前 Profile → 起代理 → 起沙箱。 +#[tauri::command] +pub fn remote_one_click( + profile: RemoteHostProfile, + provider: String, + proxy_port: u16, + sandbox_port: u16, +) -> Result { + // 端口校验(与本地的 set_config 保持一致) + if proxy_port == 8765 || sandbox_port == 8765 { + return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); + } + if proxy_port == 0 || sandbox_port == 0 { + return Err("端口不能为 0。".into()); + } + if proxy_port == sandbox_port { + return Err("代理端口与沙箱端口不能相同。".into()); + } + + let secret = config::new_id(); + let (remote_cfg, adapter) = + remote_active_config_for_start(&provider, proxy_port, Some(sandbox_port), &secret)?; + let config_json = serde_json::to_string(&remote_cfg).map_err(|e| e.to_string())?; + + stop_remote_sandbox(&profile).map_err(|e| format!("停止旧远程沙箱失败:{}", e.message))?; + + stop_remote_proxy(&profile).map_err(|e| format!("停止旧远程代理失败:{}", e.message))?; + + remote::transport::run_helper_json_stdin_with_retry::( + &profile, + &["config".to_string(), "set".to_string(), config_json], + ) + .map_err(|e| format!("同步当前 Profile 到服务器失败:{}", e.message))?; + + let proxy_result = remote::transport::run_helper_json_with_retry::( + &profile, + &[ + "proxy".to_string(), + "start".to_string(), + adapter, + proxy_port.to_string(), + secret.clone(), + ], + ) + .map_err(|e| format!("启动远程代理失败:{}", e.message))?; + + let proxy_url = format!("http://127.0.0.1:{proxy_port}/{secret}"); + let sandbox_result = match remote::transport::run_helper_json_with_retry::( + &profile, + &[ + "sandbox".to_string(), + "start".to_string(), + sandbox_port.to_string(), + proxy_url.clone(), + ], + ) { + Ok(result) => result, + Err(err) => { + let _ = stop_remote_proxy(&profile); + return Err(format!("启动远程沙箱失败:{}", err.message)); + } + }; + + let local_url = sandbox_result["url"] + .as_str() + .map(String::from) + .unwrap_or_else(|| format!("http://127.0.0.1:{sandbox_port}")); + + let remote_url = if matches!(profile.kind, RemoteTargetKind::Wsl) { + local_url.clone() + } else { + format!("http://{}:{sandbox_port}", profile.host) + }; + + Ok(json!({ + "ok": true, + "proxy_port": proxy_port, + "sandbox_port": sandbox_port, + "proxy_url": proxy_url, + "local_url": local_url, + "remote_url": remote_url, + "tunnel_hint": remote_tunnel_hint(&profile, sandbox_port), + "proxy": proxy_result, + "sandbox": sandbox_result, + })) +} + +// ============================================================================ +// 内部辅助 +// ============================================================================ + +/// 将 Helper 的 `status` 命令返回值解析为 `RemoteHealth` 结构。 +fn health_from_status_result( + status_result: Result, + now: i64, +) -> RemoteHealth { + match status_result { + Ok(status) => parse_health_from_status(&status, now), + Err(e) if e.code == "helper_not_found" => RemoteHealth { + reachable: true, + helper_installed: false, + helper_version: None, + desktop_version: env!("CARGO_PKG_VERSION").to_string(), + compatible: false, + platform: None, + arch: None, + capabilities: vec![], + proxy_running: false, + sandbox_running: false, + last_error: Some(format!("Helper 不存在或无法执行:{}", e.message)), + last_check: now, + }, + Err(e) => RemoteHealth { + reachable: false, + helper_installed: false, + helper_version: None, + desktop_version: env!("CARGO_PKG_VERSION").to_string(), + compatible: false, + platform: None, + arch: None, + capabilities: vec![], + proxy_running: false, + sandbox_running: false, + last_error: Some(format!("无法连接到目标。请检查连接配置:{}", e.message)), + last_check: now, + }, + } +} + +fn parse_health_from_status(status: &Value, now: i64) -> RemoteHealth { + let capabilities: Vec = status["capabilities"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let helper_version = status["version"].as_str().map(String::from); + let desktop_version = env!("CARGO_PKG_VERSION").to_string(); + let version_matches = helper_version.as_deref() == Some(desktop_version.as_str()); + + // 兼容性检查:所需能力是否齐全,且 helper 与桌面端同版本。 + let has_required_capabilities = REQUIRED_CAPABILITIES + .iter() + .all(|req| capabilities.iter().any(|c| c == *req)); + let compatible = has_required_capabilities && version_matches; + let last_error = if !version_matches { + Some("Helper 版本与桌面端不一致,需要更新 Helper。".to_string()) + } else if !has_required_capabilities { + Some("Helper 能力不完整,需要更新 Helper。".to_string()) + } else { + None + }; + + RemoteHealth { + reachable: true, + helper_installed: true, + helper_version, + desktop_version, + compatible, + platform: status["platform"].as_str().map(String::from), + arch: status["arch"].as_str().map(String::from), + capabilities, + proxy_running: status["proxy_running"].as_bool().unwrap_or(false), + sandbox_running: status["sandbox_running"].as_bool().unwrap_or(false), + last_error, + last_check: now, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote_error(code: &str, message: &str) -> remote::RemoteError { + remote::RemoteError { + code: code.to_string(), + message: message.to_string(), + details: None, + recoverable: false, + suggestion: None, + } + } + + #[test] + fn health_from_helper_missing_keeps_server_reachable() { + let health = + health_from_status_result(Err(remote_error("helper_not_found", "missing helper")), 123); + + assert!(health.reachable); + assert!(!health.helper_installed); + assert_eq!(health.last_check, 123); + } + + #[test] + fn helper_status_is_compatible_when_version_matches_and_capabilities_complete() { + let status = serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "platform": "linux", + "arch": "x86_64", + "capabilities": ["proxy", "config", "logs", "doctor", "verify", "proxy-bundle-v2", "sandbox"], + "proxy_running": false, + "sandbox_running": false + }); + + let health = parse_health_from_status(&status, 123); + + assert!(health.compatible); + assert!(helper_ready_for_profile(&health)); + assert_eq!( + health.helper_version.as_deref(), + Some(env!("CARGO_PKG_VERSION")) + ); + assert_eq!(health.desktop_version, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn helper_status_requires_current_proxy_bundle_capability() { + let status = serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "platform": "linux", + "arch": "x86_64", + "capabilities": ["proxy", "config", "logs", "doctor", "verify", "sandbox"], + "proxy_running": false, + "sandbox_running": false + }); + + let health = parse_health_from_status(&status, 123); + + assert!(!health.compatible); + assert!(!helper_ready_for_profile(&health)); + } + + #[test] + fn helper_status_is_incompatible_when_version_differs() { + let status = serde_json::json!({ + "version": "0.0.0", + "platform": "linux", + "arch": "x86_64", + "capabilities": ["proxy", "config", "logs", "doctor", "verify", "sandbox"], + "proxy_running": false, + "sandbox_running": false + }); + + let health = parse_health_from_status(&status, 123); + + assert!(!health.compatible); + assert!(!helper_ready_for_profile(&health)); + assert_eq!( + health.last_error.as_deref(), + Some("Helper 版本与桌面端不一致,需要更新 Helper。") + ); + } + + #[test] + fn health_from_auth_error_marks_server_unreachable() { + let health = + health_from_status_result(Err(remote_error("ssh_auth_failed", "bad password")), 123); + + assert!(!health.reachable); + assert!(!health.helper_installed); + assert_eq!(health.last_check, 123); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index c454aa2..e727258 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -40,7 +40,8 @@ ], "resources": { "../../proxy": "proxy", - "../../scripts": "scripts" + "../../scripts": "scripts", + "helper-assets": "helper-assets" }, "macOS": { "signingIdentity": "-" diff --git a/desktop/src/index.html b/desktop/src/index.html index 008b987..c50878b 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -21,6 +21,133 @@ + +

+
+ + +
+
+ + + + + + + + + + + +
官方模式:用你自己真实的 Claude Science 与订阅,本工具不插手你的官方登录。
diff --git a/desktop/src/main.js b/desktop/src/main.js index a5f0156..7f6ba65 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -1,83 +1,89 @@ -// CSSwitch 桌面面板前端。只调用后端 Tauri command,绝不碰任何密钥落盘逻辑。 -// 后端只把 key 的【掩码】回显给这里;完整 key 永不进前端。 -// -// ── Tauri 参数键约定(务必遵守)────────────────────────────────────────────── -// 本项目所有命令都是裸 `#[tauri::command]`(无 rename_all)。tauri-macros 默认 -// `ArgumentCase::Camel`,会把 Rust 蛇形【顶层参数名】转成 lowerCamelCase 交给 JS: -// template_id→templateId、base_url→baseUrl、api_format→apiFormat、skip_verify→skipVerify。 -// 所以 invoke 顶层 args 用【小驼峰】。而 serde 结构体入参(`req`=FetchModelsReq、 -// `cfg`=UiSettings)内部字段按结构体字段名(蛇形):proxy_port/sandbox_port、 -// template_id/base_url/key/profile_id。核对表见任务报告。 -// -// 预览兜底:在普通浏览器(没有 Tauri 后端)里打开时用 mockInvoke 返回假数据, -// 让界面能完整渲染。真实 app 里 window.__TAURI__ 存在,走真后端,此兜底不生效。 +// CSSwitch desktop frontend. The UI is driven by the v2 profile schema: +// named profiles + active_id. Full API keys never enter this script; the +// backend only returns masked key tails. const PREVIEW = !window.__TAURI__; -const invoke = PREVIEW - ? (cmd, args) => mockInvoke(cmd, args) - : window.__TAURI__.core.invoke; +const invoke = PREVIEW ? (cmd, args) => mockInvoke(cmd, args) : window.__TAURI__.core.invoke; -// ── 预览兜底 mock(仅浏览器预览用;node --check 只验语法,真实 app 走真后端) ── const MOCK_TEMPLATES = [ - { id: "deepseek", name: "DeepSeek", category: "cn_official", api_format: "anthropic", adapter: "deepseek", base_url: "https://api.deepseek.com/anthropic", base_url_editable: false, requires_model_override: false, builtin_models: ["claude-opus-4-8", "claude-haiku-4-5"], icon: "deepseek", icon_color: "#1E88E5", website_url: "https://platform.deepseek.com" }, - { id: "glm", name: "智谱 GLM", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://open.bigmodel.cn/api/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["glm-5.2", "glm-4.7", "glm-4.6", "glm-4.5-air"], icon: "glm", icon_color: "#2E6BE6", website_url: "https://open.bigmodel.cn" }, - { id: "xiaomi", name: "小米 MiMo", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.xiaomimimo.com/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["mimo-v2.5-pro"], icon: "xiaomi", icon_color: "#FF6900", website_url: "https://xiaomimimo.com" }, - { id: "siliconflow", name: "硅基流动", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.siliconflow.cn", base_url_editable: true, requires_model_override: true, builtin_models: ["deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "deepseek-ai/DeepSeek-V3.2", "zai-org/GLM-5.2"], icon: "siliconflow", icon_color: "#7C3AED", website_url: "https://siliconflow.cn" }, - { id: "kimi", name: "Kimi(Moonshot)", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.moonshot.cn/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"], icon: "kimi", icon_color: "#16182F", website_url: "https://platform.moonshot.cn" }, - { id: "minimax", name: "MiniMax", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.minimaxi.com/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"], icon: "minimax", icon_color: "#E1341E", website_url: "https://platform.minimaxi.com" }, - { id: "openrouter", name: "OpenRouter", category: "custom", api_format: "anthropic", adapter: "relay", base_url: "https://openrouter.ai/api", base_url_editable: true, requires_model_override: true, builtin_models: ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.8", "anthropic/claude-opus-4.8-fast"], icon: "openrouter", icon_color: "#6467F2", website_url: "https://openrouter.ai" }, - { id: "qwen", name: "通义千问", category: "cn_official", api_format: "openai_chat", adapter: "qwen", base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", base_url_editable: false, requires_model_override: false, builtin_models: ["qwen-max", "qwen-plus", "qwen-turbo"], icon: "qwen", icon_color: "#615CED", website_url: "https://dashscope.aliyun.com" }, - { id: "custom-openai", name: "自定义 OpenAI", category: "custom", api_format: "openai_chat", adapter: "openai-custom", base_url: "", base_url_editable: true, requires_model_override: true, builtin_models: [], icon: "custom", icon_color: "#2563EB", website_url: "" }, - { id: "custom-openai-responses", name: "自定义 OpenAI Responses", category: "custom", api_format: "openai_responses", adapter: "openai-responses", base_url: "", base_url_editable: true, requires_model_override: true, builtin_models: [], icon: "custom", icon_color: "#0F766E", website_url: "" }, - { id: "custom", name: "自定义 Anthropic", category: "custom", api_format: "anthropic", adapter: "relay", base_url: "", base_url_editable: true, requires_model_override: true, builtin_models: [], icon: "custom", icon_color: "#6B7280", website_url: "" }, + { id: "deepseek", name: "DeepSeek", category: "cn_official", api_format: "anthropic", adapter: "deepseek", base_url: "https://api.deepseek.com/anthropic", base_url_editable: false, requires_model_override: false, builtin_models: ["claude-opus-4-8", "claude-haiku-4-5"], website_url: "https://platform.deepseek.com", icon: "deepseek", icon_color: "#1E88E5" }, + { id: "glm", name: "智谱 GLM", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://open.bigmodel.cn/api/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["glm-5.2", "glm-4.7", "glm-4.6"], website_url: "https://open.bigmodel.cn", icon: "glm", icon_color: "#2E6BE6" }, + { id: "kimi", name: "Kimi(Moonshot)", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.moonshot.cn/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], website_url: "https://platform.moonshot.cn", icon: "kimi", icon_color: "#16182F" }, + { id: "minimax", name: "MiniMax", category: "cn_official", api_format: "anthropic", adapter: "relay", base_url: "https://api.minimaxi.com/anthropic", base_url_editable: true, requires_model_override: true, builtin_models: ["MiniMax-M3", "MiniMax-M2.7"], website_url: "https://platform.minimaxi.com", icon: "minimax", icon_color: "#E1341E" }, + { id: "qwen", name: "通义千问", category: "cn_official", api_format: "openai_chat", adapter: "qwen", base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", base_url_editable: false, requires_model_override: false, builtin_models: ["qwen-max", "qwen-plus", "qwen-turbo"], website_url: "https://dashscope.aliyun.com", icon: "qwen", icon_color: "#615CED" }, + { id: "custom", name: "自定义", category: "custom", api_format: "anthropic", adapter: "relay", base_url: "", base_url_editable: true, requires_model_override: true, builtin_models: [], website_url: "", icon: "custom", icon_color: "#6B7280" }, ]; + const mockStore = { schema_version: 2, active_id: "", proxy_port: 18991, sandbox_port: 8990, mode: "proxy", - profiles: [ - { id: "p-demo1", name: "我的 GLM", template_id: "glm", category: "cn_official", api_format: "anthropic", base_url: "https://open.bigmodel.cn/api/anthropic", model: "glm-4.6", key: "••••••1234", icon: "glm", icon_color: "#2E6BE6", website_url: "https://open.bigmodel.cn", sort_index: 1, notes: "" }, - ], + profiles: [], + remoteProfiles: [], }; -function mockMask(k) { return k ? "••••" + String(k).slice(-4) : ""; } + +function mockMask(key) { + return key ? "••••" + String(key).slice(-4) : ""; +} + function mockInvoke(cmd, args) { args = args || {}; switch (cmd) { case "get_config": return Promise.resolve({ - schema_version: mockStore.schema_version, active_id: mockStore.active_id, - proxy_port: mockStore.proxy_port, sandbox_port: mockStore.sandbox_port, - mode: mockStore.mode, templates: MOCK_TEMPLATES, + schema_version: 2, + active_id: mockStore.active_id, + proxy_port: mockStore.proxy_port, + sandbox_port: mockStore.sandbox_port, + mode: mockStore.mode, + templates: MOCK_TEMPLATES, profiles: mockStore.profiles.map((p) => ({ ...p })), }); case "list_templates": return Promise.resolve(MOCK_TEMPLATES); + case "set_settings": + case "set_config": + mockStore.proxy_port = args.cfg.proxy_port; + mockStore.sandbox_port = args.cfg.sandbox_port; + return Promise.resolve(null); + case "set_mode": + mockStore.mode = args.mode; + return Promise.resolve(null); case "create_profile": { - const t = MOCK_TEMPLATES.find((x) => x.id === args.templateId) || {}; + const tpl = MOCK_TEMPLATES.find((t) => t.id === args.templateId) || MOCK_TEMPLATES[0]; const id = "p-" + Math.random().toString(16).slice(2, 10); mockStore.profiles.push({ - id, name: args.name || t.name || "新配置", template_id: args.templateId, - category: t.category || "custom", api_format: t.api_format || "anthropic", - base_url: args.baseUrl || t.base_url || "", model: args.model || "", - key: mockMask(args.key || ""), icon: t.icon, icon_color: t.icon_color, - website_url: t.website_url, sort_index: mockStore.profiles.length + 1, notes: "", + id, + name: args.name || tpl.name, + template_id: tpl.id, + category: tpl.category, + api_format: tpl.api_format, + base_url: args.baseUrl || tpl.base_url || "", + model: args.model || "", + key: mockMask(args.key || ""), + icon: tpl.icon, + icon_color: tpl.icon_color, + website_url: tpl.website_url, + notes: "", }); return Promise.resolve(id); } case "update_profile_metadata": { const p = mockStore.profiles.find((x) => x.id === args.id); - if (!p) return Promise.reject("找不到 profile:" + args.id); - p.name = args.name; p.notes = args.notes || ""; + if (!p) return Promise.reject("找不到配置"); + p.name = args.name; + p.notes = args.notes || ""; return Promise.resolve(null); } case "update_profile_connection": { const p = mockStore.profiles.find((x) => x.id === args.id); - if (!p) return Promise.reject("找不到 profile:" + args.id); + if (!p) return Promise.reject("找不到配置"); if (args.baseUrl != null) p.base_url = args.baseUrl; + if (args.apiFormat != null) p.api_format = args.apiFormat; if (args.model != null) p.model = args.model; if (args.key) p.key = mockMask(args.key); - return Promise.resolve({ validated: true }); + return Promise.resolve(null); } case "clear_profile_key": { const p = mockStore.profiles.find((x) => x.id === args.id); @@ -90,26 +96,71 @@ function mockInvoke(cmd, args) { return Promise.resolve(null); case "set_active_profile": { const p = mockStore.profiles.find((x) => x.id === args.id); - if (!p) return Promise.reject("找不到 profile:" + args.id); + if (!p) return Promise.reject("找不到配置"); mockStore.active_id = args.id; - return Promise.resolve({ committed: true, active_id: args.id, hint: "(预览:已设为当前)" }); + return Promise.resolve({ committed: true, active_id: args.id, hint: "预览模式:已设为当前。" }); } case "fetch_models": - return Promise.resolve({ models: [{ id: "glm-4.6", supports_tools: true }, { id: "glm-5", supports_tools: null }], source: "live", error_kind: null, upstream_status: 200 }); - case "set_settings": - if (args.cfg) { mockStore.proxy_port = args.cfg.proxy_port; mockStore.sandbox_port = args.cfg.sandbox_port; } - return Promise.resolve(null); - case "set_mode": - mockStore.mode = args.mode; - return Promise.resolve(null); - case "one_click_login": - return Promise.resolve({ url: "http://127.0.0.1:8990", msg: "(预览模式:假装已就绪)", action: "started" }); + return Promise.resolve({ models: [{ id: "glm-5.2", supports_tools: true }, { id: "glm-4.7", supports_tools: null }], source: "preview" }); case "status": return Promise.resolve({ proxy: "amber", sandbox: "amber", upstream: "amber" }); + case "one_click_login": + return Promise.resolve({ url: "http://127.0.0.1:8990", msg: "预览模式:假装已启动。" }); + case "stop_all": + case "open_url": + case "open_official": + case "open_release_page": + case "report_bug": + case "open_logs": + case "quit_app": + return Promise.resolve(null); + case "run_doctor": + return Promise.resolve("预览模式:后端未运行。"); case "app_version": return Promise.resolve("0.0.0-preview"); - case "run_doctor": - return Promise.resolve("(预览模式:后端未运行,这里是占位文本)"); + case "remote_list_profiles": + return Promise.resolve(mockStore.remoteProfiles.map((p) => ({ ...p }))); + case "remote_list_wsl_distributions": + return Promise.resolve([ + { name: "Ubuntu", state: "Running", version: 2, isDefault: true }, + { name: "Debian", state: "Stopped", version: 2, isDefault: false }, + ]); + case "remote_save_profile": { + const p = args.profile; + const i = mockStore.remoteProfiles.findIndex((x) => x.id === p.id); + if (i >= 0) mockStore.remoteProfiles[i] = p; + else mockStore.remoteProfiles.unshift(p); + return Promise.resolve(p); + } + case "remote_delete_profile": + mockStore.remoteProfiles = mockStore.remoteProfiles.filter((p) => p.id !== args.id); + return Promise.resolve(true); + case "remote_save_login_secret": + case "remote_delete_login_secret": + return Promise.resolve(null); + case "remote_check_health": + return Promise.resolve({ reachable: true, helperInstalled: false, compatible: false, platform: "linux", arch: "x86_64", proxyRunning: false, sandboxRunning: false, lastError: "预览模式" }); + case "remote_prepare_helper": + return Promise.resolve({ reachable: true, helperInstalled: true, compatible: true, platform: "linux", arch: "x86_64", proxyRunning: false, sandboxRunning: false }); + case "remote_status": + return Promise.resolve({ proxy: "amber", sandbox: "amber", upstream: "amber", remote: true }); + case "remote_start_proxy": + return Promise.resolve({ ok: true, port: args.port }); + case "remote_one_click": + return Promise.resolve({ + ok: true, + proxy_port: args.proxyPort, + sandbox_port: args.sandboxPort, + local_url: "http://127.0.0.1:" + args.sandboxPort, + tunnel_hint: "ssh -N -L " + args.sandboxPort + ":127.0.0.1:" + args.sandboxPort + " user@host", + }); + case "remote_stop_proxy": + case "remote_stop_all": + return Promise.resolve(null); + case "remote_logs": + return Promise.resolve({ content: "预览模式:无日志" }); + case "remote_doctor": + return Promise.resolve({ checks: [{ name: "预览模式", ok: true }] }); default: return Promise.resolve(null); } @@ -117,149 +168,118 @@ function mockInvoke(cmd, args) { const $ = (id) => document.getElementById(id); const els = {}; -let statusTimer = null; let busy = false; -let mode = "proxy"; // "proxy" 第三方 | "official" 官方 -// 当前配置快照(get_config 结果)。全 key 绝不在此,只有掩码。 -let state = { profiles: [], templates: [], active_id: "", proxy_port: 18991, sandbox_port: 8990 }; -let pendingSkipActivateId = null; // set_active 校验含糊时,允许「跳过验证」再切 -let pendingConfirm = null; // 危险操作(清 key / 删除)的「再点一次确认」态 +let mode = "proxy"; +let target = "local"; +let currentProfile = null; +let remoteProfiles = []; +let wslDistributions = []; +let statusTimer = null; +let editingConnId = null; +let editingMetaId = null; +let pendingSkipActivateId = null; +let pendingConfirm = null; +let pendingAuthPrompt = null; +let refreshingStatus = false; +let lastRemoteStatusAt = 0; + +let state = { + profiles: [], + templates: [], + active_id: "", + proxy_port: 18991, + sandbox_port: 8990, +}; const CAT_LABELS = { official: "官方", cn_official: "国内", custom: "自定义" }; -// ── 模型能力(三态,纯函数,无 DOM):native 映射 / relay 跟随 / relay 固定。── -const CAP = { NATIVE: "native", FOLLOW: "follow", FIXED: "fixed" }; -function isNativeAdapter(a) { return a === "deepseek" || a === "qwen"; } -function modelCapability(t) { - if (!t) return CAP.FIXED; // 未知模板:最保守,要求填模型 - if (isNativeAdapter(t.adapter)) return CAP.NATIVE; - return t.requires_model_override ? CAP.FIXED : CAP.FOLLOW; -} -// 来源提示:据「地址是否可编辑 + 模型能力」生成,不能只看 category -// (OpenRouter 的 category 是 custom,但地址只读、模型可跟随;只看 category 会误导)。 -function sourceHint(t) { - if (!t) return "选择来源后按提示填写。"; - // 真·自定义(可编辑且无预设地址)才叫「自定义端点」;预设虽可编辑但有官方默认,另行描述。 - if (t.base_url_editable && !t.base_url && t.api_format === "openai_chat") { - return "自定义 OpenAI Chat Completions 兼容端点:填 base root、key 与模型,经代理转换协议。"; - } - if (t.base_url_editable && !t.base_url && t.api_format === "openai_responses") { - return "自定义 OpenAI Responses 兼容端点:填 base root、key 与模型,经代理转换协议。"; - } - if (t.base_url_editable && !t.base_url) return "自定义 Anthropic 兼容端点:填地址与 key,用「获取模型」列出并选一个。"; - const cap = modelCapability(t); - if (cap === CAP.NATIVE) { - // deepseek 是原生 Anthropic 透传;qwen 经代理做 Anthropic↔OpenAI 转换,别都叫「直连」。 - return t.adapter === "qwen" - ? "官方端点(经代理转换协议):填 API Key 即可,地址与模型都已内置。" - : "官方原生端点(无需转换):填 API Key 即可,地址与模型都已内置。"; - } - // 预设地址可编辑:默认已填好官方地址,套餐/区域端点可改(如小米 token plan)。 - const addr = t.base_url_editable ? "地址已预填官方默认(套餐 / 区域端点可改)" : "地址已预设"; - if (cap === CAP.FOLLOW) return `填 API Key 即可,${addr},模型默认跟随 Science。`; - return `填 API Key 并选一个模型,${addr}。`; -} -const MODEL_HINT = { - native: "由 Science 选择器 + 内置映射自动选择(opus 深度 / haiku 快速)。", - follow: "留空=跟随 Science 选择器(保留 opus/haiku 各档);选一个=固定用于所有请求。", - fixed: "该来源需选一个模型(不认 claude-*,将用于所有请求含后台任务)。", -}; +async function call(cmd, args) { + return await invoke(cmd, args); +} -// 据能力渲染模型字段。native:只读信息 + 隐藏下拉/获取按钮,但把既有 model 留在隐藏下拉里 -// (避免保存时被空值覆盖,守「零运行语义变化」);relay:走下拉。 -function applyModelCapability(t, ui, currentModel) { - const cap = modelCapability(t); - const listId = ui.sel.getAttribute("list"); - const dl = listId && document.getElementById(listId); - if (cap === CAP.NATIVE) { - // native:控件隐藏,保留 profile 既有 model(connSave/wizSave 读回原值不清空),不写回任何默认/壳。 - ui.info.textContent = MODEL_HINT.native; - ui.info.hidden = false; - ui.sel.hidden = true; - ui.sel.value = currentModel || ""; - if (dl) dl.innerHTML = ""; - if (ui.fetchBtn) ui.fetchBtn.hidden = true; - ui.hint.textContent = ""; - return cap; - } - // relay(FIXED):input + datalist 候选(内置精选 + 可自填);预填旗舰默认或既有值。 - ui.info.hidden = true; - ui.sel.hidden = false; - if (ui.fetchBtn) ui.fetchBtn.hidden = false; - const builtin = ((t && t.builtin_models) || []).slice(); - if (currentModel && !builtin.includes(currentModel)) builtin.unshift(currentModel); - const models = builtin.map((id) => ({ id, supports_tools: null })); - renderModelOptions(ui.sel, models, "内置"); - ui.sel.value = currentModel || (builtin[0] || ""); - ui.hint.textContent = MODEL_HINT.fixed; - return cap; +function escapeHtml(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); } function setMsg(text, kind) { - // 去掉常驻「就绪。」:空消息或纯 idle 时整条反馈栏不占位,有真实反馈(结果/错误/自检)才冒出来。 - const t = text && text !== "就绪。" ? text : ""; - els.msg.textContent = t; + if (!els.msg) return; + els.msg.textContent = text || ""; els.msg.className = "msg" + (kind ? " " + kind : ""); - els.msg.parentElement.hidden = !t; - // 表单视图里反馈区可能落在折叠线以下:给出结果(ok/err)时滚到可见; - // 中性提示(无 kind,多为打开表单时)不滚,避免把页面拽到底部。 - if (t && kind && els.panel && els.panel.classList.contains("view-form")) { - els.msg.scrollIntoView({ block: "nearest" }); - } + const feedback = els.msg.closest(".feedback"); + if (feedback) feedback.hidden = !text; +} + +function setMsgHtml(html, kind) { + if (!els.msg) return; + els.msg.innerHTML = html || ""; + els.msg.className = "msg" + (kind ? " " + kind : ""); + const feedback = els.msg.closest(".feedback"); + if (feedback) feedback.hidden = !html; } -function setLight(el, s) { - const cls = { green: "g", amber: "a", red: "r" }[s] || "a"; +function setLight(el, value) { + if (!el) return; + const cls = { green: "g", amber: "a", red: "r" }[value] || "a"; el.className = "lt " + cls; } function setBusy(on) { busy = on; [ - els.oneClickBtn, els.stopBtn, els.newBtn, - els.wizSaveBtn, els.wizFetchBtn, els.wizCancelBtn, - els.connSaveBtn, els.connFetchBtn, els.connClearBtn, els.connCancelBtn, - els.metaSaveBtn, els.metaCancelBtn, els.skipActivateBtn, - // 端口输入也纳入忙碌禁用:忙碌中改端口会与在途操作竞态(修 P1-c 前端侧)。 - els.proxyPort, els.sandboxPort, - ].forEach((b) => b && (b.disabled = on)); - // 模式切换按钮同样禁用:忙碌中切官方会与「一键开始」竞态(修 P1-b 前端侧)。 - if (els.modeSeg) els.modeSeg.querySelectorAll(".seg-btn").forEach((b) => (b.disabled = on)); - // 松开忙碌时,把 requires_model_override 的保存门控交回门(避免 setBusy(false) 覆盖门控)。 - if (!on) { refreshWizGate(); refreshConnGate(); } + "oneClickBtn", "stopBtn", "newBtn", "skipActivateBtn", + "wizFetchBtn", "wizSaveBtn", "wizCancelBtn", + "connFetchBtn", "connSaveBtn", "connClearBtn", "connCancelBtn", + "metaSaveBtn", "metaCancelBtn", "manageProfilesBtn", "addProfileBtn", + "saveProfileBtn", "testProfileBtn", + ].forEach((id) => { + if (els[id]) els[id].disabled = on; + }); + if (!on) { + refreshWizGate(); + refreshConnGate(); + } } -async function call(cmd, args) { - return await invoke(cmd, args); +function templateById(id) { + return (state.templates || []).find((t) => t.id === id) || null; } -function escapeHtml(s) { - return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) - ); +function activeLocalProfile() { + return (state.profiles || []).find((p) => p.id === state.active_id) || null; } -function tplById(id) { - return (state.templates || []).find((t) => t.id === id) || null; +function adapterForProfile(profile) { + const tpl = templateById(profile && profile.template_id); + return (tpl && tpl.adapter) || profile?.template_id || "relay"; +} + +function defaultModel(tpl) { + return (tpl && tpl.builtin_models && tpl.builtin_models[0]) || ""; } -// ── 视图切换:列表 / 新建向导 / 连接编辑 / 改名。一次只显示一个表单(列表隐去减少高度)。── -function showView(v) { - els.listSec.hidden = v !== "list"; - els.advSec.hidden = v !== "list"; - els.wizSec.hidden = v !== "wizard"; - els.connSec.hidden = v !== "conn"; - els.metaSec.hidden = v !== "meta"; - els.panel.classList.toggle("view-form", v !== "list"); - if (v === "list") hideSkip(); +function showView(view) { + els.panel.classList.toggle("view-form", view !== "list"); + els.listSec.hidden = view !== "list"; + els.advSec.hidden = view !== "list"; + els.wizSec.hidden = view !== "wizard"; + els.connSec.hidden = view !== "conn"; + els.metaSec.hidden = view !== "meta"; + if (view === "list") hideSkip(); } -function cancelForm() { showView("list"); setMsg("就绪。"); } -function showSkip() { els.skipActivateBtn.hidden = false; } -function hideSkip() { els.skipActivateBtn.hidden = true; pendingSkipActivateId = null; } +function hideSkip() { + pendingSkipActivateId = null; + if (els.skipActivateBtn) els.skipActivateBtn.hidden = true; +} + +function showSkip(id) { + pendingSkipActivateId = id; + els.skipActivateBtn.hidden = false; +} -// 危险操作「再点一次确认」(避免依赖 window.confirm,Tauri webview 里不可靠)。 -function confirmAction(token, promptText, fn) { +function confirmAction(token, prompt, fn) { if (pendingConfirm && pendingConfirm.token === token) { clearTimeout(pendingConfirm.timer); pendingConfirm = null; @@ -269,17 +289,19 @@ function confirmAction(token, promptText, fn) { if (pendingConfirm) clearTimeout(pendingConfirm.timer); pendingConfirm = { token, - timer: setTimeout(() => { pendingConfirm = null; setMsg("已取消。"); }, 4000), + timer: setTimeout(() => { + pendingConfirm = null; + setMsg("已取消。"); + }, 4000), }; - setMsg(promptText + " —— 再点一次同一按钮确认(4 秒内)。", "err"); + setMsg(prompt + "。4 秒内再点一次确认。", "err"); } -// ── 加载配置 + 渲染列表 ── async function loadConfig() { try { const cfg = await call("get_config"); state.profiles = cfg.profiles || []; - state.templates = cfg.templates || []; + state.templates = cfg.templates || await call("list_templates"); state.active_id = cfg.active_id || ""; state.proxy_port = cfg.proxy_port ?? 18991; state.sandbox_port = cfg.sandbox_port ?? 8990; @@ -288,46 +310,39 @@ async function loadConfig() { applyMode(cfg.mode === "official" ? "official" : "proxy"); renderList(); showView("list"); - // 一次性迁移提示(#9 甲):后端 get_config 读后已清盘,只会出现一次。 if (cfg.pending_notice) setMsg(cfg.pending_notice, "ok"); } catch (e) { setMsg("读取配置失败:" + e, "err"); } } -// 列表里模型摘要:无显式 model 时按三能力给准确措辞(native 内置映射 / relay 跟随 / 需指定), -// 取代旧「(透传)」字样(三能力语义下不再有「透传」)。 -function modelSummary(p) { - if (p.model) return escapeHtml(p.model); - const cap = modelCapability(tplById(p.template_id)); - if (cap === CAP.NATIVE) return "内置映射"; - if (cap === CAP.FOLLOW) return "跟随 Science"; - return "未选模型"; +function modelSummary(profile) { + if (profile.model) return escapeHtml(profile.model); + const tpl = templateById(profile.template_id); + return tpl && tpl.requires_model_override ? "未选模型" : "内置映射"; } function renderList() { - const list = els.profileList; - const ps = state.profiles || []; - if (!ps.length) { - list.innerHTML = '
还没有配置。点右上「+ 新建」加一条第三方来源。
'; + const profiles = state.profiles || []; + if (!profiles.length) { + els.profileList.innerHTML = '
还没有配置。点右上「+ 新建」加一条第三方来源。
'; return; } - list.innerHTML = ps.map((p) => { + els.profileList.innerHTML = profiles.map((p) => { const active = p.id === state.active_id; - const catLabel = CAT_LABELS[p.category] || p.category || ""; - const keyMask = p.key ? escapeHtml(p.key) : "未填 key"; - const modelTxt = modelSummary(p); - const dotStyle = p.icon_color ? ' style="background:' + escapeHtml(p.icon_color) + '"' : ""; + const cat = CAT_LABELS[p.category] || p.category || ""; + const key = p.key ? escapeHtml(p.key) : "未填 key"; + const dot = p.icon_color ? ' style="background:' + escapeHtml(p.icon_color) + '"' : ""; return ( '
' + '
' + - '" + + '" + '' + escapeHtml(p.name) + "" + - '' + escapeHtml(catLabel) + "" + + '' + escapeHtml(cat) + "" + (active ? '当前生效' : "") + "
" + '
' + escapeHtml(p.base_url || "(未填地址)") + "
" + - '
模型:' + modelTxt + " · Key:" + keyMask + "
" + + '
模型:' + modelSummary(p) + " · Key:" + key + "
" + '
' + (active ? "" : '') + '' + @@ -340,134 +355,49 @@ function renderList() { }).join(""); } -// ── 模式(第三方 / 官方)── -function applyMode(m) { - mode = m === "official" ? "official" : "proxy"; +function applyMode(nextMode) { + mode = nextMode === "official" ? "official" : "proxy"; els.panel.classList.toggle("mode-official", mode === "official"); - els.modeSeg.querySelectorAll(".seg-btn").forEach((b) => - b.classList.toggle("active", b.dataset.mode === mode) - ); - els.oneClickBtn.textContent = - mode === "official" ? "打开官方 Claude Science ↗" : "⚡ 一键开始"; + els.modeSeg.querySelectorAll(".seg-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.mode === mode); + }); + els.oneClickBtn.textContent = mode === "official" ? "打开官方 Claude Science ↗" : "⚡ 一键开始"; } -async function switchMode(m) { - if (m === mode) return; - if (busy) return; // 忙碌中不切模式(防与「一键开始」竞态;按钮亦已禁用,此为双保险)。修 P1-b +async function switchMode(nextMode) { + if (nextMode === mode) return; setBusy(true); try { - await call("set_mode", { mode: m }); + await call("set_mode", { mode: nextMode }); + applyMode(nextMode); + setMsg(nextMode === "official" ? "已切到官方模式。" : "已切到第三方模式。", "ok"); + await refreshStatus(); } catch (e) { setMsg("切换模式失败:" + e, "err"); - setBusy(false); - return; - } - applyMode(m); - setBusy(false); - showView("list"); - setMsg( - mode === "official" - ? "已切到官方模式:第三方代理/沙箱已停,点上方按钮打开你真实的 Claude Science。" - : "已切到第三方模式:选一条配置「设为当前」后点「一键开始」。" - ); - await refreshStatus(); -} - -async function openOfficial() { - setBusy(true); - setMsg("正在打开官方 Claude Science…"); - try { - await call("open_official"); - setMsg("已打开官方 Claude Science(走你自己的官方登录与订阅)。", "ok"); - } catch (e) { - setMsg("打开失败:" + e, "err"); } finally { setBusy(false); } } -// hero 按钮按当前模式分派。 -async function heroClick() { - if (mode === "official") await openOfficial(); - else await oneClick(); -} - -// ── 端口设置(替旧 set_config;纯端口,不含 provider/连接)── async function persistPorts() { - if (busy) return; // 忙碌中不改端口(防与在途操作竞态;输入亦已禁用,此为双保险)。修 P1-c - const p = parseInt(els.proxyPort.value, 10) || 18991; - const s = parseInt(els.sandboxPort.value, 10) || 8990; - const changed = p !== state.proxy_port || s !== state.sandbox_port; - // 本次端口提交全程置忙:仅靠开头的 `if (busy) return` 只挡「已在忙时进入」,挡不住本函数在途 - // 时其它操作(切模式/一键/连接编辑)启动。置忙 + 禁用控件才能保证操作顺序符合用户预期。修 GPT 三轮 P2 - setBusy(true); + const cfg = { + proxy_port: parseInt(els.proxyPort.value, 10) || 18991, + sandbox_port: parseInt(els.sandboxPort.value, 10) || 8990, + }; try { - await call("set_settings", { cfg: { proxy_port: p, sandbox_port: s } }); - state.proxy_port = p; - state.sandbox_port = s; - // 后端在端口变化时会拆掉旧代理/沙箱(否则会复用指向旧端口的死链路),如实告知需重开。修 P1-c - if (changed) { - setMsg("端口已保存。改端口会重置正在运行的代理/沙箱,请重新「一键开始」。", "ok"); - await refreshStatus(); + try { + await call("set_settings", { cfg }); + } catch (e) { + await call("set_config", { cfg }); } + state.proxy_port = cfg.proxy_port; + state.sandbox_port = cfg.sandbox_port; + setMsg("端口设置已保存。", "ok"); } catch (e) { - // 出错=端口未落盘(校验不过 / 停旧沙箱失败):把输入框还原成实际生效值,避免显示未保存的数字。 - els.proxyPort.value = state.proxy_port; - els.sandboxPort.value = state.sandbox_port; - setMsg(String(e), "err"); - } finally { - setBusy(false); - } -} - -// ── 模型下拉渲染(requires_override=false 时首项「跟随 Science 选择器」;按 supports_tools 标注)── -// 候选填进 input 关联的 (下拉建议);input 的值由调用方另设,用户可自由改。 -function renderModelOptions(sel, models, sourceLabel) { - const listId = sel.getAttribute("list"); - const dl = listId && document.getElementById(listId); - if (!dl) return; - dl.innerHTML = ""; - for (const m of models || []) { - const o = document.createElement("option"); - o.value = m.id; - const tag = m.supports_tools === true ? " ·工具✓" : m.supports_tools === false ? " ·无工具" : ""; - const src = sourceLabel ? " [" + sourceLabel + "]" : ""; - o.label = m.id + tag + src; - dl.appendChild(o); - } -} - -// fetch_models 返回体 → 刷新 datalist 候选 + 提示(向导与连接编辑共用)。 -// requiresOverride 保留形参(调用点仍传),但 datalist 无「跟随」空项,故此处不用。 -function applyFetchResult(sel, requiresOverride, r) { - void requiresOverride; - const models = (r && r.models) || []; - const src = r && r.source; - // unsupported(端点不提供发现,4xx)与 builtin(200 但空)都铺内置,标「内置」;network/未知标「未验证」。 - const srcLabel = src === "live" ? "实时" : src === "builtin" || src === "unsupported" ? "内置" : "未验证"; - const prev = sel.value; - renderModelOptions(sel, models, srcLabel); - if (prev) sel.value = prev; // 保留用户已填/已选值,拉列表只刷新候选、绝不清空输入 - if (src === "unsupported") { - // 端点未提供 /v1/models(如 Kimi):内置模型可直接选,绝不表述成 key 无效。 - setMsg("该端点未提供模型列表,已用内置模型(可直接选择保存)。", "ok"); - } else if (r && r.error_kind === "network") { - setMsg("未能连上上游验证,已铺内置模型(标「未验证」)。可仍试保存或重试。", "err"); - } else { - setMsg("已获取 " + models.length + " 个模型(工具✓ 优先)。", "ok"); + setMsg("保存端口失败:" + e, "err"); } } -// ── C2:新建向导 ── -function openWizard() { - hideSkip(); - renderTemplateChips(); - const first = (state.templates || [])[0]; - selectWizTemplate(first ? first.id : ""); - showView("wizard"); - setMsg("选择来源,填 key 即可创建。"); -} - function renderTemplateChips() { els.wizTemplateChips.innerHTML = (state.templates || []).map((t) => { const dot = t.icon_color ? ' style="background:' + escapeHtml(t.icon_color) + '"' : ""; @@ -482,106 +412,73 @@ function renderTemplateChips() { }).join(""); } +function setModelOptions(input, datalist, models, fallbackValue) { + const ids = (models || []).map((m) => typeof m === "string" ? m : m.id).filter(Boolean); + datalist.innerHTML = ids.map((id) => '').join(""); + input.value = fallbackValue || ids[0] || ""; +} + function selectWizTemplate(id) { - els.wizTemplate.value = id; + const tpl = templateById(id) || state.templates[0]; + if (!tpl) return; + els.wizTemplate.value = tpl.id; els.wizTemplateChips.querySelectorAll(".chip").forEach((c) => { - const on = c.getAttribute("data-tid") === id; - c.classList.toggle("sel", on); - c.setAttribute("aria-pressed", on ? "true" : "false"); + const selected = c.dataset.tid === tpl.id; + c.classList.toggle("sel", selected); + c.setAttribute("aria-pressed", selected ? "true" : "false"); }); - onWizTemplate(); -} - -function onWizTemplate() { - const t = tplById(els.wizTemplate.value); - if (!t) return; - els.wizName.value = t.name; - // 把「新建不自动生效」放进顶部常驻提示(默认窗口下反馈区首屏可能在折叠线下,见 #6)。 - els.wizTplHint.textContent = sourceHint(t) + " 新建后需在列表点「设为当前」才生效。"; - if (t.base_url_editable) { - // 预设:预填官方默认地址(仍可改到套餐 / 区域端点);真·自定义:留空 + 占位提示。 - els.wizBase.value = t.base_url || ""; - els.wizBase.readOnly = false; - els.wizBase.placeholder = t.api_format === "openai_chat" || t.api_format === "openai_responses" - ? "https://open.bigmodel.cn/api/paas/v4" - : "https://your-relay/claude"; - els.wizBaseHint.textContent = t.base_url - ? "官方默认地址,可改到 token 套餐 / 区域端点(如小米 token plan)。" - : (t.api_format === "openai_chat" - ? "OpenAI 兼容 base root,代理自动补 /chat/completions 与 /models。" - : t.api_format === "openai_responses" - ? "OpenAI 兼容 base root,代理自动补 /responses 与 /models。" - : "自定义端点根地址(自动补 /v1/messages 与 /v1/models)。"); - } else { - els.wizBase.value = t.base_url; - els.wizBase.readOnly = true; - els.wizBaseHint.textContent = "模板地址已填好(只读)。"; + els.wizName.value = tpl.name; + els.wizBase.value = tpl.base_url || ""; + els.wizBase.disabled = !tpl.base_url_editable; + els.wizTplHint.textContent = tpl.base_url_editable ? "可按你的套餐或区域端点修改地址。" : "该来源使用内置官方地址。"; + els.wizBaseHint.textContent = tpl.base_url_editable ? "" : "地址由适配器内置。"; + setModelOptions(els.wizModel, els.wizModelList, tpl.builtin_models || [], defaultModel(tpl)); + els.wizModelInfo.hidden = !!tpl.requires_model_override; + els.wizModel.hidden = !tpl.requires_model_override; + els.wizModelHint.textContent = tpl.requires_model_override ? "请选择或输入上游真实模型名。" : "该来源使用内置模型映射。"; + if (!tpl.requires_model_override) { + els.wizModelInfo.textContent = "使用内置模型映射,无需手动选择。"; + els.wizModel.value = ""; } - applyModelCapability(t, { - info: els.wizModelInfo, sel: els.wizModel, hint: els.wizModelHint, fetchBtn: els.wizFetchBtn, - }, ""); refreshWizGate(); } function refreshWizGate() { - const t = tplById(els.wizTemplate ? els.wizTemplate.value : ""); - const need = t && t.requires_model_override; - els.wizSaveBtn.disabled = busy || !!(need && !els.wizModel.value.trim()); -} - -function openaiCustomAnthropicBaseMessage(t, base) { - if (t && (t.id === "custom-openai" || t.id === "custom-openai-responses") && (base || "").trim().toLowerCase().includes("/anthropic")) { - return "这个地址看起来是 Anthropic 兼容端点。请改选「自定义 Anthropic」,或填写 OpenAI 兼容 base root(如 https://api.moonshot.cn/v1)。"; - } - return ""; + if (!els.wizSaveBtn || els.wizSec.hidden) return; + const tpl = templateById(els.wizTemplate.value); + const needsModel = tpl && tpl.requires_model_override; + const needsBase = tpl && tpl.base_url_editable; + els.wizSaveBtn.disabled = busy || + !els.wizName.value.trim() || + (needsBase && !els.wizBase.value.trim()) || + (needsModel && !els.wizModel.value.trim()); } -async function wizFetch() { - const t = tplById(els.wizTemplate.value); - if (!t) return; - const base = t.base_url_editable ? els.wizBase.value.trim() : t.base_url; - if (!base) { setMsg("请先填写 base_url。", "err"); return; } - const baseErr = openaiCustomAnthropicBaseMessage(t, base); - if (baseErr) { setMsg(baseErr, "err"); return; } - const key = els.wizKey.value.trim(); - if (!key) { setMsg("请先填 key 再获取模型。", "err"); return; } - setBusy(true); - setMsg("获取模型中:起临时代理探 /v1/models…"); - try { - const r = await call("fetch_models", { req: { template_id: t.id, base_url: base, key } }); - applyFetchResult(els.wizModel, t.requires_model_override, r); - } catch (e) { - setMsg("获取模型失败:" + e, "err"); - } finally { - setBusy(false); - refreshWizGate(); - } +function openWizard() { + hideSkip(); + renderTemplateChips(); + selectWizTemplate((state.templates[0] || {}).id || ""); + els.wizKey.value = ""; + showView("wizard"); + setMsg("选择来源,填 key 即可创建。"); } -async function wizSave() { - const t = tplById(els.wizTemplate.value); - if (!t) { setMsg("模板未加载。", "err"); return; } - const name = els.wizName.value.trim() || t.name; - const model = els.wizModel.value.trim(); - if (t.requires_model_override && !model) { - setMsg("该来源需要选一个模型才能创建。", "err"); - return; - } - const args = { templateId: t.id, name, key: els.wizKey.value.trim(), model }; - if (t.base_url_editable) { - const base = els.wizBase.value.trim(); - if (!base) { setMsg("请先填写 base_url。", "err"); return; } - const baseErr = openaiCustomAnthropicBaseMessage(t, base); - if (baseErr) { setMsg(baseErr, "err"); return; } - args.baseUrl = base; - } +async function createProfile() { + const tpl = templateById(els.wizTemplate.value); + if (!tpl) return; + const args = { + templateId: tpl.id, + name: els.wizName.value.trim() || tpl.name, + key: els.wizKey.value.trim() || null, + baseUrl: els.wizBase.value.trim() || null, + model: tpl.requires_model_override ? els.wizModel.value.trim() : null, + }; setBusy(true); - setMsg("创建中…"); try { await call("create_profile", args); els.wizKey.value = ""; await loadConfig(); - setMsg("已创建「" + name + "」。可在列表点「设为当前」启用。", "ok"); + setMsg("已创建配置。需要使用时点「设为当前」。", "ok"); } catch (e) { setMsg("创建失败:" + e, "err"); } finally { @@ -589,240 +486,181 @@ async function wizSave() { } } -// ── C3:连接编辑(base_url/model/key)+ 清 key ── -function currentConn() { - const id = els.connSec.dataset.id; - return (state.profiles || []).find((x) => x.id === id) || null; -} - function openConn(id) { - const p = (state.profiles || []).find((x) => x.id === id); + const p = state.profiles.find((x) => x.id === id); if (!p) return; - const t = tplById(p.template_id); - const editable = t ? t.base_url_editable : true; - const active = id === state.active_id; - els.connSec.dataset.id = id; - els.connTitle.textContent = "编辑连接 · " + p.name + (active ? "(当前生效)" : ""); - els.connBase.value = p.base_url || (t ? t.base_url : ""); - els.connBase.readOnly = !editable; - els.connBase.placeholder = t && (t.api_format === "openai_chat" || t.api_format === "openai_responses") - ? "https://open.bigmodel.cn/api/paas/v4" - : "https://your-relay/claude"; - // native(deepseek/qwen)隐藏「获取模型」按钮,别再提示一个不存在的操作(修 #5)。 - els.connBaseHint.textContent = editable - ? (t && t.base_url - ? "官方默认地址,可改到 token 套餐 / 区域端点。" - : (t && t.api_format === "openai_chat" - ? "OpenAI 兼容 base root,代理自动补 /chat/completions。" - : t && t.api_format === "openai_responses" - ? "OpenAI 兼容 base root,代理自动补 /responses。" - : "自定义端点根地址。")) - : (modelCapability(t) === CAP.NATIVE - ? "模板地址(只读),模型由内置映射自动选择。" - : "模板地址(只读)。填 key 后可「获取模型」。"); - applyModelCapability(t, { - info: els.connModelInfo, sel: els.connModel, hint: els.connModelHint, fetchBtn: els.connFetchBtn, - }, p.model || ""); + const tpl = templateById(p.template_id) || {}; + editingConnId = id; + els.connTitle.textContent = "编辑连接:" + p.name; + els.connBase.value = p.base_url || tpl.base_url || ""; + els.connBase.disabled = !tpl.base_url_editable; els.connKey.value = ""; - els.connKey.placeholder = p.key ? "已存:" + p.key + "(留空=不改)" : "粘贴 key(只存本地)"; + setModelOptions(els.connModel, els.connModelList, tpl.builtin_models || [], p.model || defaultModel(tpl)); + els.connModelInfo.hidden = !!tpl.requires_model_override; + els.connModel.hidden = !tpl.requires_model_override; + els.connModelHint.textContent = tpl.requires_model_override ? "请选择或输入上游真实模型名。" : "该来源使用内置模型映射。"; + if (!tpl.requires_model_override) { + els.connModelInfo.textContent = "使用内置模型映射,无需手动选择。"; + els.connModel.value = ""; + } showView("conn"); + setMsg("留空 key 表示不修改已存 key。"); refreshConnGate(); - setMsg(active - ? "编辑当前生效配置:保存会先校验→切换,失败自动回退到原配置(不谎报生效)。" - : "编辑连接后点「保存连接」。"); } function refreshConnGate() { - const p = currentConn(); - const t = p ? tplById(p.template_id) : null; - const need = t && t.requires_model_override; - els.connSaveBtn.disabled = busy || !!(need && !els.connModel.value.trim()); + if (!els.connSaveBtn || els.connSec.hidden || !editingConnId) return; + const p = state.profiles.find((x) => x.id === editingConnId); + const tpl = templateById(p && p.template_id); + const needsModel = tpl && tpl.requires_model_override; + const needsBase = tpl && tpl.base_url_editable; + els.connSaveBtn.disabled = busy || + (needsBase && !els.connBase.value.trim()) || + (needsModel && !els.connModel.value.trim()); } -async function connFetch() { - const p = currentConn(); - if (!p) return; - const t = tplById(p.template_id); - const editable = t ? t.base_url_editable : true; - const base = editable ? els.connBase.value.trim() : (t ? t.base_url : els.connBase.value.trim()); - if (!base) { setMsg("请先填写 base_url。", "err"); return; } - const baseErr = openaiCustomAnthropicBaseMessage(t, base); - if (baseErr) { setMsg(baseErr, "err"); return; } +async function saveConn() { + const p = state.profiles.find((x) => x.id === editingConnId); + const tpl = templateById(p && p.template_id); + if (!p || !tpl) return; setBusy(true); - setMsg("获取模型中:起临时代理探 /v1/models…"); try { - const key = els.connKey.value.trim(); // 有新 key 带上;空则后端用已存 key(profileId) - const r = await call("fetch_models", { - req: { template_id: p.template_id, base_url: base, key, profile_id: p.id }, + await call("update_profile_connection", { + id: p.id, + baseUrl: els.connBase.value.trim(), + apiFormat: tpl.api_format, + model: tpl.requires_model_override ? els.connModel.value.trim() : "", + key: els.connKey.value.trim() || null, }); - applyFetchResult(els.connModel, t ? t.requires_model_override : true, r); + await loadConfig(); + setMsg("连接已保存。", "ok"); } catch (e) { - setMsg("获取模型失败:" + e, "err"); + setMsg("保存连接失败:" + e, "err"); } finally { setBusy(false); - refreshConnGate(); } } -async function connSave() { - const p = currentConn(); - if (!p) { setMsg("配置不存在。", "err"); return; } - const t = tplById(p.template_id); - const req = t ? t.requires_model_override : true; - const model = els.connModel.value.trim(); - if (req && !model) { setMsg("该来源需要选一个模型才能保存。", "err"); return; } - const editable = t ? t.base_url_editable : true; - const base = editable ? els.connBase.value.trim() : (t ? t.base_url : els.connBase.value.trim()); - // 可编辑地址的模板都是中转/自定义端点,必须带 base_url;清空后保存会得到不可用连接(激活必失败)。 - // 保存前就拦(后端也有同款守卫兜底,修 P2)。 - if (editable && !base) { setMsg("中转 / 自定义端点必须填写连接地址(base_url)。", "err"); return; } - const baseErr = openaiCustomAnthropicBaseMessage(t, base); - if (baseErr) { setMsg(baseErr, "err"); return; } - const active = p.id === state.active_id; - // key 留空=不改(后端语义);base_url/model 照传。api_format 不在此改(保留模板值)。 - const args = { id: p.id, baseUrl: base, model, key: els.connKey.value.trim() }; +function openMeta(id) { + const p = state.profiles.find((x) => x.id === id); + if (!p) return; + editingMetaId = id; + els.metaName.value = p.name || ""; + els.metaNotes.value = p.notes || ""; + showView("meta"); + setMsg("修改名称或备注不会触发代理。"); +} + +async function saveMeta() { setBusy(true); - setMsg(active ? "校验中→切换中…(保存当前生效配置的新连接)" : "保存连接中…"); try { - const r = await call("update_profile_connection", args); - els.connKey.value = ""; + await call("update_profile_metadata", { + id: editingMetaId, + name: els.metaName.value.trim() || "未命名", + notes: els.metaNotes.value.trim() || null, + }); await loadConfig(); - // 非 active:后端如实回传 validated,连不通/native 也保存,但据实说明未校验(修 P2-d truthful-save)。 - if (active) { - setMsg("已保存并应用新连接。", "ok"); - } else if (r && r.validated) { - setMsg("已保存连接(已通过上游校验)。", "ok"); - } else { - setMsg("已保存连接(未能连通上游校验,激活时会再验)。", "ok"); - } + setMsg("已保存。", "ok"); } catch (e) { - // 后端错误文案已如实说明回滚/代理状态(可能是「已回滚到原配置」或「回滚未成功:代理当前已停」), - // 前端不再盲目追加「仍在用原配置运行」,避免与「代理已停」相互矛盾。修 GPT 三轮 P2 - setMsg("连接未保存:" + e, "err"); + setMsg("保存失败:" + e, "err"); } finally { setBusy(false); - await refreshStatus(); } } -// 清 key(行内 / 连接表单都可触发):二次确认后 clear_profile_key。 -function clearKey(id) { - const p = (state.profiles || []).find((x) => x.id === id); - const nm = p ? p.name : id; - confirmAction("clearkey:" + id, "将清除「" + nm + "」的 API key(需重填才能用)", () => doClearKey(id)); -} -async function doClearKey(id) { - const wasActive = id === state.active_id; +async function activateProfile(id, skipVerify) { setBusy(true); - setMsg("清除 key 中…"); try { - await call("clear_profile_key", { id }); + const result = await call("set_active_profile", { id, skipVerify: !!skipVerify }); + if (result && result.committed === false) { + showSkip(id); + setMsg(result.hint || "校验未通过,未切换。", "err"); + return; + } await loadConfig(); - setMsg( - wasActive - ? "已清除 key(该配置是当前生效,链路已断,请重新填 key 再「设为当前」)。" - : "已清除 key。", - "ok" - ); + setMsg((result && result.hint) || "已设为当前。", "ok"); + await refreshStatus(); } catch (e) { - setMsg("清除失败:" + e, "err"); + setMsg("切换失败:" + e, "err"); } finally { setBusy(false); - await refreshStatus(); } } -// ── C4:改名/备注 + 删除 + 设为当前 ── -function openMeta(id) { - const p = (state.profiles || []).find((x) => x.id === id); - if (!p) return; - els.metaSec.dataset.id = id; - els.metaName.value = p.name; - els.metaNotes.value = p.notes || ""; - showView("meta"); - setMsg("改名 / 备注不影响运行中的代理。"); -} -async function metaSave() { - const id = els.metaSec.dataset.id; - const name = els.metaName.value.trim(); - if (!name) { setMsg("名称不能为空。", "err"); return; } - const notes = els.metaNotes.value.trim(); +async function clearProfileKey(id) { setBusy(true); - setMsg("保存中…"); try { - await call("update_profile_metadata", { id, name, notes }); + await call("clear_profile_key", { id }); await loadConfig(); - setMsg("已保存。", "ok"); + setMsg("Key 已清除。", "ok"); } catch (e) { - setMsg("保存失败:" + e, "err"); + setMsg("清除失败:" + e, "err"); } finally { setBusy(false); } } -function del(id) { - const p = (state.profiles || []).find((x) => x.id === id); - const nm = p ? p.name : id; - confirmAction("delete:" + id, "将删除配置「" + nm + "」", () => doDelete(id)); -} -async function doDelete(id) { - const wasActive = id === state.active_id; +async function deleteProfile(id) { setBusy(true); - setMsg("删除中…"); try { await call("delete_profile", { id }); await loadConfig(); - setMsg( - wasActive - ? "已删除。删掉的是当前生效配置,请重新选择一条并「设为当前」。" - : "已删除。", - "ok" - ); + setMsg("配置已删除。", "ok"); } catch (e) { setMsg("删除失败:" + e, "err"); } finally { setBusy(false); - await refreshStatus(); } } -// 设为当前:走后端切换事务(校验→起正式→健康才提交)。 -// 返回体 committed:true=已生效;committed:false=未生效(可能可 skip);抛错=回滚/中止。 -async function activate(id, skipVerify) { - hideSkip(); +async function fetchModelsFor(kind) { + const isConn = kind === "conn"; + const p = isConn ? state.profiles.find((x) => x.id === editingConnId) : null; + const tpl = templateById(isConn ? p?.template_id : els.wizTemplate.value); + if (!tpl) return; + const base = (isConn ? els.connBase.value : els.wizBase.value).trim(); + const key = (isConn ? els.connKey.value : els.wizKey.value).trim(); + const hint = isConn ? els.connModelHint : els.wizModelHint; + const input = isConn ? els.connModel : els.wizModel; + const list = isConn ? els.connModelList : els.wizModelList; setBusy(true); - setMsg(skipVerify ? "跳过验证,切换中…" : "校验中→切换中…"); + hint.textContent = "正在获取模型…"; try { - const r = await call("set_active_profile", { id, skipVerify: !!skipVerify }); - if (r && r.committed) { - await loadConfig(); - setMsg(r.hint || "已设为当前生效。", "ok"); - } else { - await loadConfig(); // 反映未变(仍是原 active) - setMsg((r && r.hint) || "校验未通过,未切换。", "err"); - if (r && r.can_skip) { pendingSkipActivateId = id; showSkip(); } - } + const res = await call("fetch_models", { + req: { + template_id: tpl.id, + base_url: base, + key, + profile_id: p ? p.id : null, + }, + }); + setModelOptions(input, list, res.models || [], input.value); + hint.textContent = "已获取模型列表" + (res.source ? "(" + res.source + ")" : "") + "。"; } catch (e) { - await loadConfig(); - setMsg("设为当前失败:" + e, "err"); + hint.textContent = "获取失败:" + e; } finally { setBusy(false); - await refreshStatus(); } } -// ── 一键开始:读 active profile。无生效则引导先建/选一条(不再对旧 provider 槽落未提交输入)。── async function oneClick() { + if (mode === "official") { + await openOfficial(); + return; + } + if (target === "remote") { + await remoteOneClick(); + return; + } if (!state.active_id) { - setMsg("还没有「当前生效」的配置。请先「+ 新建」或在列表点「设为当前」选一条,再一键开始。", "err"); + setMsg("还没有当前生效的配置。请先「+ 新建」或在列表点「设为当前」。", "err"); return; } setBusy(true); setMsg("一键开始:起代理 → 起沙箱 → 探活…"); try { const r = await call("one_click_login"); - // 透传后端据实回传的 msg(已重开 / 已用新配置重启 / 沿用原对话 / 已启动 / 打开失败请手动打开)。 - setMsg((r.msg || "已就绪,正在打开面板…") + "\n" + (r.url || ""), "ok"); + setMsg((r.msg || "已就绪。") + "\n" + (r.url || ""), "ok"); await refreshStatus(); } catch (e) { setMsg("一键开始失败:" + e, "err"); @@ -831,12 +669,28 @@ async function oneClick() { } } +async function openOfficial() { + setBusy(true); + try { + await call("open_official"); + setMsg("已打开官方 Claude Science。", "ok"); + } catch (e) { + setMsg("打开失败:" + e, "err"); + } finally { + setBusy(false); + } +} + async function stopAll() { setBusy(true); - setMsg("停止中…"); try { - await call("stop_all"); - setMsg("已停止代理与沙箱。", "ok"); + if (target === "remote" && currentProfile) { + await call("remote_stop_all", { profile: currentProfile }); + setMsg("远程代理与沙箱已停止。", "ok"); + } else { + await call("stop_all"); + setMsg("已停止代理与沙箱。", "ok"); + } await refreshStatus(); } catch (e) { setMsg("停止失败:" + e, "err"); @@ -845,31 +699,60 @@ async function stopAll() { } } +async function refreshStatus() { + const remoteStatus = target === "remote" && currentProfile; + const now = Date.now(); + if (refreshingStatus || (remoteStatus && now - lastRemoteStatusAt < 10000)) return; + refreshingStatus = true; + if (remoteStatus) lastRemoteStatusAt = now; + try { + const s = remoteStatus + ? await call("remote_status", { profile: currentProfile }) + : await call("status"); + setLight(els.ltProxy, s.proxy); + setLight(els.ltSandbox, s.sandbox); + setLight(els.ltUpstream, s.upstream); + els.brandDot.className = "dot" + (s.proxy === "green" ? "" : " amber"); + } catch (e) { + [els.ltProxy, els.ltSandbox, els.ltUpstream].forEach((el) => setLight(el, "amber")); + } finally { + refreshingStatus = false; + } +} + async function openBrowser() { try { - await call("open_url"); + await call("open_url", {}); + } catch (e) { + setMsg("打开浏览器失败:" + e, "err"); + } +} + +async function openLocalUrl(url) { + try { + await call("open_url", url ? { url } : {}); } catch (e) { setMsg("打开浏览器失败:" + e, "err"); } } async function runDoctor() { - setMsg("自检中…"); + setMsg(target === "remote" ? "远程自检中…" : "自检中…"); try { - const out = await call("run_doctor"); - setMsg(out, out.includes("失败 0") ? "ok" : null); + const out = target === "remote" && currentProfile + ? await call("remote_doctor", { profile: currentProfile }) + : await call("run_doctor"); + setMsg(typeof out === "string" ? out : JSON.stringify(out.checks || out, null, 2), "ok"); } catch (e) { setMsg("自检失败:" + e, "err"); } } -// 简单 semver 比较:a 是否比 b 新。 function isNewer(a, b) { - const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0); - const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const x = pa[i] || 0, y = pb[i] || 0; - if (x !== y) return x > y; + const aa = String(a).split(".").map((n) => parseInt(n, 10) || 0); + const bb = String(b).split(".").map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(aa.length, bb.length); i++) { + if ((aa[i] || 0) !== (bb[i] || 0)) return (aa[i] || 0) > (bb[i] || 0); } return false; } @@ -879,120 +762,693 @@ async function checkUpdate() { let cur = ""; try { cur = await call("app_version"); } catch (e) {} try { - const resp = await fetch( - "https://api.github.com/repos/SuperJJ007/CSSwitch/releases/latest", - { headers: { Accept: "application/vnd.github+json" } } - ); + const resp = await fetch("https://api.github.com/repos/SuperJJ007/CSswitch/releases/latest", { + headers: { Accept: "application/vnd.github+json" }, + }); if (!resp.ok) throw new Error("HTTP " + resp.status); const data = await resp.json(); - const latest = (data.tag_name || "").replace(/^v/, ""); - if (!latest) throw new Error("无版本信息"); - if (isNewer(latest, cur)) { - setMsg("发现新版本 v" + latest + "(当前 v" + cur + ")。正在打开下载页…", "ok"); - try { await call("open_release_page"); } catch (_) {} + const latest = String(data.tag_name || "").replace(/^v/, ""); + if (latest && isNewer(latest, cur)) { + setMsg("发现新版本 v" + latest + "。正在打开下载页。", "ok"); + await call("open_release_page"); } else { setMsg("已是最新版本(v" + cur + ")。", "ok"); } } catch (e) { - setMsg("无法自动检查更新(多为网络或代理限制)。已打开 Releases 页,请手动查看。", "err"); + setMsg("无法自动检查更新,已打开 Releases 页。", "err"); try { await call("open_release_page"); } catch (_) {} } } -async function refreshStatus() { +async function switchTarget(nextTarget) { + if (nextTarget === target) return; + target = nextTarget === "remote" ? "remote" : "local"; + els.panel.classList.toggle("target-remote", target === "remote"); + els.targetSeg.querySelectorAll(".seg-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.target === target); + }); + if (target === "remote") { + await loadRemoteProfiles(); + setMsg("已切换到远程 / WSL。"); + } else { + setMsg("已切换到本地模式。"); + } + await refreshStatus(); +} + +function remoteProfileKind(profile) { + return profile && profile.kind === "wsl" ? "wsl" : "ssh"; +} + +function remoteProfileDetail(profile) { + if (remoteProfileKind(profile) === "wsl") { + return "WSL · " + escapeHtml(profile.username || "?") + "@" + escapeHtml(profile.distribution || profile.name || "?"); + } + return "SSH · " + escapeHtml(profile.username || "?") + "@" + escapeHtml(profile.host || "?") + ":" + escapeHtml(profile.port || 22); +} + +function remoteProfileOptionLabel(profile) { + const suffix = remoteProfileKind(profile) === "wsl" + ? "WSL " + (profile.distribution || "") + : (profile.username || "?") + "@" + (profile.host || "?") + ":" + (profile.port || 22); + return profile.name + " (" + suffix + ")"; +} + +async function loadRemoteProfiles() { try { - const s = await call("status"); - setLight(els.ltProxy, s.proxy); - setLight(els.ltSandbox, s.sandbox); - setLight(els.ltUpstream, s.upstream); - els.brandDot.className = "dot" + (s.proxy === "green" ? "" : " amber"); + remoteProfiles = await call("remote_list_profiles"); + els.profileSelect.innerHTML = '' + remoteProfiles.map((p) => + '" + ).join(""); + if (currentProfile && remoteProfiles.some((p) => p.id === currentProfile.id)) { + els.profileSelect.value = currentProfile.id; + currentProfile = remoteProfiles.find((p) => p.id === currentProfile.id) || currentProfile; + } else { + currentProfile = null; + } + updateRemoteHealthUI(); + } catch (e) { + setMsg("加载远程 / WSL 目标失败:" + e, "err"); + } +} + +async function onProfileChange() { + const id = els.profileSelect.value; + currentProfile = remoteProfiles.find((p) => p.id === id) || null; + updateRemoteHealthUI(); + if (currentProfile) await checkRemoteHealth(); +} + +function updateRemoteHealthUI() { + if (!currentProfile) { + els.remoteHealthDot.className = "lt a"; + els.remoteHealthText.textContent = "未连接"; + return; + } + els.remoteHealthDot.className = "lt a"; + els.remoteHealthText.textContent = "已选:" + currentProfile.name + " · " + (remoteProfileKind(currentProfile) === "wsl" ? "WSL" : "SSH"); +} + +async function checkRemoteHealth() { + if (!currentProfile) return; + els.remoteHealthDot.className = "lt a pulsing"; + els.remoteHealthText.textContent = "连接中…"; + try { + const h = await call("remote_check_health", { profile: currentProfile }); + if (h.reachable && h.helperInstalled && h.compatible) { + els.remoteHealthDot.className = "lt g"; + els.remoteHealthText.textContent = "已连接 | " + (h.platform || "?") + " " + (h.arch || "?"); + } else if (h.reachable) { + els.remoteHealthDot.className = "lt a"; + els.remoteHealthText.textContent = h.lastError || "已连接,Helper 需要安装或升级"; + } else { + els.remoteHealthDot.className = "lt r"; + els.remoteHealthText.textContent = h.lastError || "连接失败"; + } + } catch (e) { + els.remoteHealthDot.className = "lt r"; + els.remoteHealthText.textContent = "检查失败:" + e; + } +} + +async function openProfileModal() { + await loadRemoteProfiles(); + const list = document.getElementById("remoteProfileList"); + list.innerHTML = remoteProfiles.length + ? remoteProfiles.map((p) => ( + '
' + + '
' + escapeHtml(p.name) + '
' + + '
' + remoteProfileDetail(p) + "
" + + '
' + + '编辑' + + '删除' + + "
" + + "
" + )).join("") + : '
暂无远程 / WSL 目标。点击「+ 添加」。
'; + els.profileModal.style.display = "flex"; +} + +function closeProfileModal() { + els.profileModal.style.display = "none"; +} + +async function scanWslDistributions() { + els.scanWslBtn.disabled = true; + els.wslDistroHint.textContent = "正在扫描 WSL 发行版…"; + try { + wslDistributions = await call("remote_list_wsl_distributions"); + renderWslDistributionOptions(els.editProfileDistribution.value); + els.wslDistroHint.textContent = wslDistributions.length + ? "已找到 " + wslDistributions.length + " 个发行版。" + : "未找到发行版,请先安装 WSL 发行版。"; } catch (e) { - [els.ltProxy, els.ltSandbox, els.ltUpstream].forEach((l) => setLight(l, "amber")); + els.wslDistroHint.textContent = "扫描失败:" + e; + } finally { + els.scanWslBtn.disabled = false; + } +} + +function renderWslDistributionOptions(selected) { + const options = wslDistributions.map((d) => { + const label = d.name + (d.isDefault ? " · 默认" : "") + (d.state ? " · " + d.state : ""); + return '"; + }).join(""); + els.editProfileDistribution.innerHTML = '' + options; + if (selected && !wslDistributions.some((d) => d.name === selected)) { + els.editProfileDistribution.innerHTML += '"; + } + els.editProfileDistribution.value = selected || ""; +} + +function currentEditProfileKind() { + return els.profileEditModal.dataset.kind === "wsl" ? "wsl" : "ssh"; +} + +function setProfileEditKind(kind) { + const nextKind = kind === "wsl" ? "wsl" : "ssh"; + els.profileEditModal.dataset.kind = nextKind; + els.editProfileKindSeg.querySelectorAll(".seg-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.kind === nextKind); + }); + const isWsl = nextKind === "wsl"; + els.wslDistroGroup.style.display = isWsl ? "" : "none"; + els.sshHostGroup.style.display = isWsl ? "none" : ""; + els.sshPortGroup.style.display = isWsl ? "none" : ""; + els.editProfileNameLabel.textContent = isWsl ? "名称(可选,默认使用发行版名)" : "名称"; + els.editProfileName.placeholder = isWsl ? "Ubuntu" : "我的服务器"; + els.editProfileUsername.placeholder = isWsl ? "WSL Linux 用户,如 zhawei" : "root"; + els.editProfileKindHint.textContent = isWsl + ? "本机 WSL 通过 wsl.exe 进入 Linux,不需要服务器地址和 SSH 端口。" + : "SSH 连接远程 Linux 服务器,认证方式复用密码 / 密钥设置。"; +} + +async function openProfileEdit(id) { + const p = id ? remoteProfiles.find((x) => x.id === id) : null; + const kind = remoteProfileKind(p); + els.profileEditModal.dataset.editId = id || ""; + els.profileEditTitle.textContent = p ? "编辑目标" : "添加目标"; + setProfileEditKind(kind); + els.editProfileName.value = p ? p.name : ""; + els.editProfileHost.value = p ? p.host : ""; + els.editProfilePort.value = p ? p.port : 22; + els.editProfileDistribution.value = p && p.distribution ? p.distribution : ""; + renderWslDistributionOptions(els.editProfileDistribution.value); + els.editProfileUsername.value = p ? p.username : ""; + const auth = p && p.authMethod ? p.authMethod : { type: "recommended" }; + els.editProfileAuth.value = authSelectValue(auth); + els.profileEditModal.dataset.passwordAuth = authSelectValue(auth) === "password" ? "1" : "0"; + els.editProfilePassword.value = ""; + els.editProfileRememberPassword.checked = auth.type !== "password" || auth.savePassword !== false; + els.editProfileKeyPath.value = auth.path || "~/.ssh/id_ed25519"; + els.editProfileHelperPath.value = p ? p.helperPath : "~/.csswitch/bin/csswitch-helper"; + els.profileEditMsg.textContent = ""; + toggleAuthFields(); + els.profileEditModal.style.display = "flex"; + if (kind === "wsl" && !wslDistributions.length) await scanWslDistributions(); +} + +function closeProfileEdit() { + els.profileEditModal.style.display = "none"; +} + +function toggleAuthFields() { + const method = els.editProfileAuth.value; + els.passwordGroup.style.display = method === "password" ? "" : "none"; + els.keyFileGroup.style.display = method === "key_file" ? "" : "none"; + els.editProfilePassword.placeholder = els.profileEditModal.dataset.passwordAuth === "1" + ? "留空则连接时再输入" + : "请输入服务器密码"; + if (method !== "password") { + els.editProfilePassword.value = ""; + } +} + +function authSelectValue(auth) { + if (!auth || !auth.type) return "recommended"; + if (auth.type === "keyFile") return "key_file"; + if (auth.type === "password") return "password"; + if (auth.type === "sshAgent") return "saved_keys"; + if (auth.type === "recommended" && auth.allowPassword === false && auth.useDefaultKeyFiles === false) { + return "saved_keys"; + } + return "recommended"; +} + +function buildAuthMethodFromForm() { + const method = els.editProfileAuth.value; + if (method === "password") { + return { + type: "password", + savePassword: !!els.editProfileRememberPassword.checked, + allowVerificationCode: true, + rememberConnection: true, + }; + } + if (method === "key_file") { + const path = els.editProfileKeyPath.value.trim(); + if (!path) throw new Error("请填写密钥路径。"); + return { + type: "keyFile", + path, + saveKeyPassword: true, + allowPasswordFallback: true, + allowVerificationCode: true, + rememberConnection: true, + }; + } + if (method === "saved_keys") { + return { + type: "recommended", + useSavedKeys: true, + useDefaultKeyFiles: false, + allowPassword: false, + allowVerificationCode: false, + rememberConnection: true, + }; + } + return { + type: "recommended", + useSavedKeys: true, + useDefaultKeyFiles: true, + allowPassword: true, + allowVerificationCode: true, + rememberConnection: true, + }; +} + +function passwordSecretFromForm(requirePassword) { + if (els.editProfileAuth.value !== "password") return null; + const secret = els.editProfilePassword.value; + if (!secret && requirePassword) { + throw new Error("请填写服务器密码。"); + } + return secret ? { kind: "password", keyPath: null, secret } : null; +} + +function passwordRequiredForCurrentProfile(authMethod) { + return authMethod.type === "password" && els.profileEditModal.dataset.passwordAuth !== "1"; +} + +function withTransientPassword(profile, loginSecret) { + if (!loginSecret || loginSecret.kind !== "password" || !loginSecret.secret) return profile; + return { ...profile, transientPassword: loginSecret.secret }; +} + +function stripTransientPassword(profile) { + const { transientPassword, ...persistedProfile } = profile; + return persistedProfile; +} + +async function rememberPasswordAfterConnection(profileId, authMethod, loginSecret) { + if (!authMethod || authMethod.type !== "password" || profileId === "_test_") return; + const passwordSecret = loginSecret || { kind: "password", keyPath: null, secret: "" }; + if (authMethod.savePassword === false) { + await deleteRemoteLoginSecret(profileId, passwordSecret).catch(() => {}); + return; + } + if (loginSecret) { + await saveRemoteLoginSecret(profileId, loginSecret); + } +} + +async function saveRemoteLoginSecret(profileId, loginSecret) { + if (!loginSecret) return; + await call("remote_save_login_secret", { + profileId, + kind: loginSecret.kind, + keyPath: loginSecret.keyPath, + secret: loginSecret.secret, + }); +} + +async function deleteRemoteLoginSecret(profileId, loginSecret) { + if (!loginSecret) return; + await call("remote_delete_login_secret", { + profileId, + kind: loginSecret.kind, + keyPath: loginSecret.keyPath, + }); +} + +function authPromptCopy(kind) { + if (kind === "keyPassword") { + return { title: "请输入密钥密码", label: "密钥密码", type: "password" }; + } + if (kind === "verificationCode") { + return { title: "请输入验证码", label: "验证码", type: "text" }; + } + if (kind === "password") { + return { title: "请输入密码", label: "服务器密码", type: "password" }; + } + return { title: "请输入登录信息", label: "登录信息", type: "password" }; +} + +function showAuthPrompt(payload) { + pendingAuthPrompt = payload; + const copy = authPromptCopy(payload && payload.kind); + const canRemember = !!(payload && payload.rememberAllowed && payload.profileId !== "_test_"); + els.authPromptTitle.textContent = copy.title; + els.authPromptLabel.textContent = copy.label; + els.authPromptInput.type = copy.type; + els.authPromptInput.value = ""; + els.authPromptMsg.textContent = ""; + els.authPromptMsg.className = ""; + els.authPromptRemember.checked = false; + els.authPromptRememberRow.style.display = canRemember ? "flex" : "none"; + els.authPromptModal.style.display = "flex"; + if (els.remoteHealthText) els.remoteHealthText.textContent = "需要输入登录信息"; + setTimeout(() => els.authPromptInput.focus(), 0); +} + +function closeAuthPrompt() { + els.authPromptModal.style.display = "none"; + pendingAuthPrompt = null; +} + +async function submitAuthPrompt() { + if (!pendingAuthPrompt) return; + const secret = els.authPromptInput.value; + if (!secret) { + els.authPromptMsg.textContent = "请输入内容。"; + els.authPromptMsg.className = "msg err"; + return; + } + const prompt = pendingAuthPrompt; + try { + await call("remote_auth_prompt_respond", { + sessionId: prompt.sessionId, + requestId: prompt.requestId, + secret, + cancelled: false, + remember: !!els.authPromptRemember.checked && els.authPromptRememberRow.style.display !== "none", + }); + closeAuthPrompt(); + } catch (e) { + els.authPromptMsg.textContent = "提交失败:" + e; + els.authPromptMsg.className = "msg err"; + } +} + +async function cancelAuthPrompt() { + if (!pendingAuthPrompt) { + closeAuthPrompt(); + return; + } + const prompt = pendingAuthPrompt; + try { + await call("remote_auth_prompt_respond", { + sessionId: prompt.sessionId, + requestId: prompt.requestId, + secret: null, + cancelled: true, + remember: false, + }); + } catch (e) {} + closeAuthPrompt(); +} + +function wireAuthPromptListener() { + if (PREVIEW || !window.__TAURI__.event || !window.__TAURI__.event.listen) return; + window.__TAURI__.event.listen("remote-auth-prompt", (event) => { + showAuthPrompt(event.payload || {}); + }).catch((e) => setMsg("登录输入窗口准备失败:" + e, "err")); + window.__TAURI__.event.listen("remote-auth-prompt-close", (event) => { + const payload = event.payload || {}; + if (pendingAuthPrompt && pendingAuthPrompt.sessionId === payload.sessionId) { + closeAuthPrompt(); + } + }).catch(() => {}); +} + +function newRemoteId() { + return globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : "r-" + Date.now().toString(16); +} + +function buildRemoteProfileFromForm(profileId, nameFallback) { + const kind = currentEditProfileKind(); + const distribution = els.editProfileDistribution.value.trim(); + const username = els.editProfileUsername.value.trim(); + const host = kind === "wsl" ? "" : els.editProfileHost.value.trim(); + const port = kind === "wsl" ? 0 : (parseInt(els.editProfilePort.value, 10) || 22); + const name = els.editProfileName.value.trim() || nameFallback || distribution || host || "未命名"; + return { + id: profileId, + name, + kind, + host, + port, + distribution: kind === "wsl" ? distribution : null, + username, + authMethod: buildAuthMethodFromForm(), + helperPath: els.editProfileHelperPath.value.trim() || "~/.csswitch/bin/csswitch-helper", + }; +} + +function validateRemoteProfileForm(profile) { + if (!profile.username) throw new Error("请填写用户名。"); + if (profile.kind === "wsl") { + if (!profile.distribution) throw new Error("请选择 WSL 发行版。"); + return; + } + if (!profile.host) throw new Error("服务器地址和用户名不能为空。"); +} + +async function saveProfile() { + const editId = els.profileEditModal.dataset.editId; + const profileId = editId || newRemoteId(); + let profile; + let loginSecret; + try { + profile = buildRemoteProfileFromForm(profileId); + loginSecret = passwordSecretFromForm(passwordRequiredForCurrentProfile(profile.authMethod)); + validateRemoteProfileForm(profile); + } catch (e) { + els.profileEditMsg.textContent = e.message || String(e); + els.profileEditMsg.className = "msg err"; + return; + } + try { + els.profileEditMsg.textContent = "正在准备 Helper…"; + els.profileEditMsg.className = "msg"; + const profileForConnection = withTransientPassword(profile, loginSecret); + await call("remote_prepare_helper", { profile: profileForConnection }); + await rememberPasswordAfterConnection(profile.id, profile.authMethod, loginSecret); + await call("remote_save_profile", { profile: stripTransientPassword(profileForConnection) }); + els.editProfilePassword.value = ""; + closeProfileEdit(); + await openProfileModal(); + await loadRemoteProfiles(); + } catch (e) { + els.profileEditMsg.textContent = "保存失败:" + e; + els.profileEditMsg.className = "msg err"; + } +} + +async function testProfileConnection() { + const editId = els.profileEditModal.dataset.editId; + let profile; + let loginSecret; + try { + profile = buildRemoteProfileFromForm(editId || "_test_", "test"); + loginSecret = passwordSecretFromForm(passwordRequiredForCurrentProfile(profile.authMethod)); + validateRemoteProfileForm(profile); + } catch (e) { + els.profileEditMsg.textContent = e.message || String(e); + els.profileEditMsg.className = "msg err"; + return; + } + els.testProfileBtn.disabled = true; + els.profileEditMsg.textContent = "正在测试连接并准备 Helper…"; + try { + const profileForConnection = withTransientPassword(profile, loginSecret); + const h = await call("remote_prepare_helper", { profile: profileForConnection }); + await rememberPasswordAfterConnection(profile.id, profile.authMethod, loginSecret); + const ready = h.reachable && h.helperInstalled && h.compatible; + els.profileEditMsg.textContent = ready ? "连接成功,Helper 已就绪。" : (h.lastError || "连接成功,但 Helper 未就绪"); + els.profileEditMsg.className = ready ? "msg ok" : "msg err"; + } catch (e) { + els.profileEditMsg.textContent = "连接失败:" + e; + els.profileEditMsg.className = "msg err"; + } finally { + els.testProfileBtn.disabled = false; + } +} + +async function remoteOneClick() { + if (!currentProfile) { + setMsg("请先选择远程 / WSL 目标。", "err"); + return; + } + const active = activeLocalProfile(); + if (!active) { + setMsg("请先在本地配置里选择一条当前生效的模型来源。", "err"); + return; + } + setBusy(true); + try { + const proxyPort = parseInt(els.proxyPort.value, 10) || 18991; + const sandboxPort = parseInt(els.sandboxPort.value, 10) || 8990; + const r = await call("remote_one_click", { + profile: currentProfile, + provider: adapterForProfile(active), + proxyPort, + sandboxPort, + }); + const localUrl = (r && r.local_url) || ("http://127.0.0.1:" + sandboxPort); + const tunnelHint = r && r.tunnel_hint ? "\n端口转发:" + r.tunnel_hint : ""; + setMsgHtml( + "远程代理与沙箱已启动。
本地访问:" + + '' + escapeHtml(localUrl) + "" + + escapeHtml(tunnelHint), + "ok", + ); + await refreshStatus(); + } catch (e) { + setMsg("远程一键开始失败:" + e, "err"); + } finally { + setBusy(false); } } function wire() { [ - "oneClickBtn", "stopBtn", "ltProxy", "ltSandbox", "ltUpstream", - "msg", "brandDot", "openBrowserBtn", "doctorBtn", "updateBtn", "verLabel", - "reportBtn", "logsBtn", "quitBtn", "modeSeg", "proxyPort", "sandboxPort", "advSec", - "listSec", "profileList", "newBtn", "skipActivateBtn", - "wizSec", "wizTemplate", "wizTemplateChips", "wizTplLabel", "wizTplHint", "wizName", "wizBase", "wizBaseHint", - "wizFetchBtn", "wizModelInfo", "wizModel", "wizModelHint", "wizKey", "wizSaveBtn", "wizCancelBtn", - "connSec", "connTitle", "connBase", "connBaseHint", "connFetchBtn", - "connModelInfo", "connModel", "connModelHint", "connKey", "connSaveBtn", "connClearBtn", "connCancelBtn", - "metaSec", "metaName", "metaNotes", "metaSaveBtn", "metaCancelBtn", - ].forEach((id) => (els[id] = $(id))); + "oneClickBtn", "stopBtn", "ltProxy", "ltSandbox", "ltUpstream", "msg", "brandDot", + "openBrowserBtn", "doctorBtn", "updateBtn", "verLabel", "reportBtn", "logsBtn", "quitBtn", + "modeSeg", "targetSeg", "proxyPort", "sandboxPort", "advSec", "listSec", "profileList", + "newBtn", "skipActivateBtn", "wizSec", "wizTemplate", "wizTemplateChips", "wizTplHint", + "wizName", "wizBase", "wizBaseHint", "wizFetchBtn", "wizModelInfo", "wizModel", + "wizModelList", "wizModelHint", "wizKey", "wizSaveBtn", "wizCancelBtn", "connSec", + "connTitle", "connBase", "connBaseHint", "connFetchBtn", "connModelInfo", "connModel", + "connModelList", "connModelHint", "connKey", "connSaveBtn", "connClearBtn", "connCancelBtn", + "metaSec", "metaName", "metaNotes", "metaSaveBtn", "metaCancelBtn", "profileSelect", + "manageProfilesBtn", "remoteHealthDot", "remoteHealthText", "profileModal", "addProfileBtn", + "closeProfileModal", "profileEditModal", "profileEditTitle", "editProfileName", + "editProfileHost", "editProfilePort", "editProfileUsername", "editProfileAuth", + "editProfileKindSeg", "editProfileKindHint", "editProfileNameLabel", "sshHostGroup", "sshPortGroup", + "wslDistroGroup", "editProfileDistribution", "scanWslBtn", "wslDistroHint", + "editProfilePassword", "editProfileRememberPassword", "editProfileKeyPath", "editProfileHelperPath", "passwordGroup", + "keyFileGroup", "testProfileBtn", "saveProfileBtn", "cancelProfileEditBtn", "profileEditMsg", "authPromptModal", + "authPromptTitle", "authPromptLabel", "authPromptInput", "authPromptRememberRow", + "authPromptRemember", "authPromptSubmitBtn", "authPromptCancelBtn", "authPromptMsg", + ].forEach((id) => { els[id] = $(id); }); els.panel = document.querySelector(".panel"); - els.modeSeg.querySelectorAll(".seg-btn").forEach((b) => - b.addEventListener("click", () => switchMode(b.dataset.mode)) - ); - + els.modeSeg.querySelectorAll(".seg-btn").forEach((b) => b.addEventListener("click", () => switchMode(b.dataset.mode))); + els.targetSeg.querySelectorAll(".seg-btn").forEach((b) => b.addEventListener("click", () => switchTarget(b.dataset.target))); els.proxyPort.addEventListener("change", persistPorts); els.sandboxPort.addEventListener("change", persistPorts); + els.newBtn.addEventListener("click", openWizard); + els.skipActivateBtn.addEventListener("click", () => pendingSkipActivateId && activateProfile(pendingSkipActivateId, true)); - // 列表行内操作(事件委托;忙碌时忽略)。 els.profileList.addEventListener("click", (e) => { if (busy) return; - const btn = e.target.closest("[data-act]"); + const button = e.target.closest("[data-act]"); const row = e.target.closest("[data-id]"); - if (!btn || !row) return; - const id = row.getAttribute("data-id"); - const act = btn.getAttribute("data-act"); - if (act === "activate") activate(id, false); - else if (act === "editconn") openConn(id); - else if (act === "editmeta") openMeta(id); - else if (act === "clearkey") clearKey(id); - else if (act === "delete") del(id); - }); - - els.newBtn.addEventListener("click", openWizard); - els.skipActivateBtn.addEventListener("click", () => { - const id = pendingSkipActivateId; - if (id) activate(id, true); + if (!button || !row) return; + const id = row.dataset.id; + const act = button.dataset.act; + if (act === "activate") activateProfile(id, false); + if (act === "editconn") openConn(id); + if (act === "editmeta") openMeta(id); + if (act === "clearkey") confirmAction("clear:" + id, "确定清除这条配置的 key", () => clearProfileKey(id)); + if (act === "delete") confirmAction("delete:" + id, "确定删除这条配置", () => deleteProfile(id)); }); els.wizTemplateChips.addEventListener("click", (e) => { - if (busy) return; - const chip = e.target.closest(".chip"); - if (chip) selectWizTemplate(chip.getAttribute("data-tid")); + const chip = e.target.closest("[data-tid]"); + if (chip) selectWizTemplate(chip.dataset.tid); }); - els.wizModel.addEventListener("input", refreshWizGate); // input:键入即刷新保存门(#9 P1-b) - els.wizFetchBtn.addEventListener("click", wizFetch); - els.wizSaveBtn.addEventListener("click", wizSave); - els.wizCancelBtn.addEventListener("click", cancelForm); + [els.wizName, els.wizBase, els.wizModel].forEach((el) => el.addEventListener("input", refreshWizGate)); + els.wizFetchBtn.addEventListener("click", () => fetchModelsFor("wiz")); + els.wizSaveBtn.addEventListener("click", createProfile); + els.wizCancelBtn.addEventListener("click", () => { showView("list"); setMsg(""); }); - els.connModel.addEventListener("input", refreshConnGate); // input:键入即刷新保存门(#9 P1-b) - els.connFetchBtn.addEventListener("click", connFetch); - els.connSaveBtn.addEventListener("click", connSave); - els.connClearBtn.addEventListener("click", () => clearKey(els.connSec.dataset.id)); - els.connCancelBtn.addEventListener("click", cancelForm); + [els.connBase, els.connModel].forEach((el) => el.addEventListener("input", refreshConnGate)); + els.connFetchBtn.addEventListener("click", () => fetchModelsFor("conn")); + els.connSaveBtn.addEventListener("click", saveConn); + els.connClearBtn.addEventListener("click", () => editingConnId && confirmAction("clear:" + editingConnId, "确定清除这条配置的 key", () => clearProfileKey(editingConnId))); + els.connCancelBtn.addEventListener("click", () => { showView("list"); setMsg(""); }); + els.metaSaveBtn.addEventListener("click", saveMeta); + els.metaCancelBtn.addEventListener("click", () => { showView("list"); setMsg(""); }); - els.metaSaveBtn.addEventListener("click", metaSave); - els.metaCancelBtn.addEventListener("click", cancelForm); - - els.oneClickBtn.addEventListener("click", heroClick); + els.oneClickBtn.addEventListener("click", oneClick); els.stopBtn.addEventListener("click", stopAll); els.openBrowserBtn.addEventListener("click", openBrowser); + els.msg.addEventListener("click", (e) => { + const link = e.target.closest("[data-url]"); + if (!link) return; + e.preventDefault(); + openLocalUrl(link.dataset.url); + }); els.doctorBtn.addEventListener("click", runDoctor); els.updateBtn.addEventListener("click", checkUpdate); - els.reportBtn.addEventListener("click", () => - call("report_bug").catch((e) => setMsg("打开反馈页失败:" + e, "err")) - ); - els.logsBtn.addEventListener("click", () => - call("open_logs").catch((e) => setMsg("打开日志失败:" + e, "err")) - ); + els.reportBtn.addEventListener("click", () => call("report_bug").catch((e) => setMsg("打开反馈页失败:" + e, "err"))); + els.logsBtn.addEventListener("click", () => { + if (target === "remote" && currentProfile) { + call("remote_logs", { profile: currentProfile, name: "proxy", lines: 80 }) + .then((out) => setMsg((out && out.content) || "日志为空。", "ok")) + .catch((e) => setMsg("获取日志失败:" + e, "err")); + } else { + call("open_logs").catch((e) => setMsg("打开日志失败:" + e, "err")); + } + }); els.quitBtn.addEventListener("click", () => call("quit_app").catch(() => {})); + + els.profileSelect.addEventListener("change", onProfileChange); + els.manageProfilesBtn.addEventListener("click", openProfileModal); + els.addProfileBtn.addEventListener("click", () => { closeProfileModal(); openProfileEdit(null); }); + els.closeProfileModal.addEventListener("click", closeProfileModal); + els.saveProfileBtn.addEventListener("click", saveProfile); + els.cancelProfileEditBtn.addEventListener("click", closeProfileEdit); + els.testProfileBtn.addEventListener("click", testProfileConnection); + els.editProfileAuth.addEventListener("change", toggleAuthFields); + els.editProfileKindSeg.addEventListener("click", async (e) => { + const button = e.target.closest("[data-kind]"); + if (!button) return; + setProfileEditKind(button.dataset.kind); + if (button.dataset.kind === "wsl" && !wslDistributions.length) await scanWslDistributions(); + }); + els.scanWslBtn.addEventListener("click", scanWslDistributions); + els.authPromptSubmitBtn.addEventListener("click", submitAuthPrompt); + els.authPromptCancelBtn.addEventListener("click", cancelAuthPrompt); + els.authPromptInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") submitAuthPrompt(); + if (e.key === "Escape") cancelAuthPrompt(); + }); + wireAuthPromptListener(); + document.querySelectorAll(".modal-overlay").forEach((overlay) => { + overlay.addEventListener("click", (e) => { + if (e.target !== overlay) return; + if (overlay === els.authPromptModal) { + cancelAuthPrompt(); + } else { + overlay.style.display = "none"; + } + }); + }); + document.getElementById("remoteProfileList").addEventListener("click", async (e) => { + const action = e.target.closest("[data-action]"); + if (!action) return; + const id = action.dataset.id; + if (action.dataset.action === "edit") openProfileEdit(id); + if (action.dataset.action === "delete") { + confirmAction("remote-delete:" + id, "确定删除这个远程服务器", async () => { + await call("remote_delete_profile", { id }); + await openProfileModal(); + await loadRemoteProfiles(); + }); + } + }); } window.addEventListener("DOMContentLoaded", async () => { wire(); await loadConfig(); - try { els.verLabel.textContent = "v" + (await call("app_version")); } catch (e) {} + try { els.verLabel.textContent = "v" + await call("app_version"); } catch (e) {} await refreshStatus(); if (PREVIEW) { - setMsg("预览模式:仅看界面,按钮不连后端(真实 app 里会连进程管家)。"); + setMsg("预览模式:只展示界面,不连接后端。"); } else { statusTimer = setInterval(refreshStatus, 2500); } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 35f3752..a424347 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -130,3 +130,54 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px; .btn:focus-visible,.hero:focus-visible,.chip:focus-visible,.abtn:focus-visible, .seg-btn:focus-visible,input:focus-visible,select:focus-visible,.link:focus-visible{ outline:2px solid var(--accent);outline-offset:2px} + +/* ---- 远程服务器管理 ---- */ +.remote-only{display:none !important} +.panel.target-remote .remote-only{ + display:block !important; + width:100%; + box-sizing:border-box; +} +.panel.target-remote .local-only{display:none !important} + +/* 远程 Profile 列表项 */ +.profile-item{display:flex;align-items:center;justify-content:space-between; + padding:8px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px; + font-size:12px} +.profile-item:hover{background:var(--field)} +.profile-item .pi-name{font-weight:600;color:var(--ink)} +.profile-item .pi-detail{color:var(--sub);font-size:10.5px} +.profile-item .pi-actions{display:flex;gap:4px} +.profile-item .pi-act{font-size:10px;color:var(--sub);cursor:pointer;padding:2px 6px; + border:1px solid var(--line);border-radius:4px} +.profile-item .pi-act:hover{color:var(--accent);border-color:var(--accent)} +.profile-item .pi-act.del:hover{color:var(--red);border-color:var(--red)} + +/* Modal 弹窗 */ +.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%; + background:rgba(0,0,0,.45);z-index:100;display:flex;align-items:center;justify-content:center} +.modal-box{background:var(--panel);border-radius:14px;padding:18px;width:300px; + max-height:90vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.25)} +.auth-prompt-box{width:280px} + +/* 表单字段(Profile 编辑弹窗) */ +.form-fields label{display:block;font-size:11px;color:var(--sub);margin:8px 0 4px} +.form-fields input,.form-fields select{width:100%;height:32px;border:1px solid var(--line); + background:var(--field);border-radius:8px;padding:0 10px;font-size:13px;color:var(--ink); + outline:none;font-family:inherit} +.form-fields input:focus,.form-fields select:focus{border-color:var(--accent)} +.check-row{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:12px;color:var(--sub)} +.check-row input{width:14px;height:14px;margin:0;accent-color:var(--accent)} + +/* 连接状态动画 */ +.pulsing{animation:pulse 1.5s ease-in-out infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} + +/* Toast 通知 */ +.toast{position:fixed;top:20px;right:20px;z-index:200;padding:10px 16px; + border-radius:8px;font-size:12.5px;color:#fff;opacity:0;transition:opacity .3s; + pointer-events:none;max-width:280px;word-break:break-all} +.toast.show{opacity:1} +.toast-info{background:var(--accent)} +.toast-error{background:var(--red)} +.toast-ok{background:var(--green)} diff --git a/test/test_desktop_ui_contract.mjs b/test/test_desktop_ui_contract.mjs new file mode 100644 index 0000000..5a8e062 --- /dev/null +++ b/test/test_desktop_ui_contract.mjs @@ -0,0 +1,611 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; + +const html = readFileSync(new URL("../desktop/src/index.html", import.meta.url), "utf8"); +const main = readFileSync(new URL("../desktop/src/main.js", import.meta.url), "utf8"); +const remoteCommands = readFileSync(new URL("../desktop/src-tauri/src/remote_commands.rs", import.meta.url), "utf8"); +const remoteSsh = readFileSync(new URL("../desktop/src-tauri/src/remote/ssh.rs", import.meta.url), "utf8"); +const remoteWsl = readFileSync(new URL("../desktop/src-tauri/src/remote/wsl.rs", import.meta.url), "utf8"); +const helperCommands = readFileSync(new URL("../desktop/src-tauri/src/cli/commands.rs", import.meta.url), "utf8"); +const libTauri = readFileSync(new URL("../desktop/src-tauri/src/lib_tauri.rs", import.meta.url), "utf8"); +const tauriConf = readFileSync(new URL("../desktop/src-tauri/tauri.conf.json", import.meta.url), "utf8"); +const buildWorkflow = readFileSync(new URL("../.github/workflows/build.yml", import.meta.url), "utf8"); +const tauriBuildRs = readFileSync(new URL("../desktop/src-tauri/build.rs", import.meta.url), "utf8"); +const crossTomlUrl = new URL("../desktop/src-tauri/Cross.toml", import.meta.url); +const crossToml = existsSync(crossTomlUrl) ? readFileSync(crossTomlUrl, "utf8") : ""; + +function remoteStartProxyBody() { + const m = remoteCommands.match(/pub fn remote_start_proxy[\s\S]*?\n}\n\n\/\/\/ 停止远程代理/); + assert.ok(m, "remote_start_proxy body should be discoverable"); + return m[0]; +} + +function workflowJob(name) { + const m = buildWorkflow.match(new RegExp(`\\n ${name}:\\n[\\s\\S]*?(?=\\n [a-zA-Z0-9_-]+:\\n|\\n$)`)); + assert.ok(m, `${name} job should exist`); + return m[0]; +} + +function frontendFunctionBody(name) { + const marker = `async function ${name}(`; + const start = main.indexOf(marker); + assert.notEqual(start, -1, `${name} should exist`); + const braceStart = main.indexOf("{", start); + assert.notEqual(braceStart, -1, `${name} should have a body`); + let depth = 0; + for (let i = braceStart; i < main.length; i += 1) { + if (main[i] === "{") depth += 1; + if (main[i] === "}") { + depth -= 1; + if (depth === 0) return main.slice(start, i + 1); + } + } + assert.fail(`${name} body should close`); +} + +test("desktop profile UI script matches the v2 profile HTML", () => { + assert.match(html, /id="profileList"/); + assert.match(html, /id="newBtn"/); + assert.match(html, /id="wizSec"/); + + assert.doesNotMatch(main, /els\.(provider|keyInput|saveKeyBtn)\b/); + assert.doesNotMatch(main, /save_provider_key/); + + for (const command of [ + "create_profile", + "update_profile_metadata", + "update_profile_connection", + "clear_profile_key", + "delete_profile", + "set_active_profile", + ]) { + assert.match(main, new RegExp(`["']${command}["']`)); + } + + assert.match(main, /newBtn\.addEventListener\(["']click["']/); +}); + +test("remote server modal uses its own list instead of the local profile list", () => { + assert.match(html, /id="remoteProfileList"/); + assert.match(main, /getElementById\(["']remoteProfileList["']\)/); + assert.doesNotMatch(main, /const\s+list\s*=\s*document\.getElementById\(["']profileList["']\)/); +}); + +test("remote start uploads the active local profile before starting helper proxy", () => { + assert.match(remoteCommands, /remote_active_config_for_start/); + assert.match(remoteCommands, /config::load_from\(&config::default_dir\(\)\)/); + assert.match(remoteCommands, /"config"\.to_string\(\),\s*"set"\.to_string\(\)/s); + assert.match(remoteCommands, /serde_json::to_string\(&remote_cfg\)/); +}); + +test("remote start stops stale helper proxy before starting with the new secret", () => { + const body = remoteStartProxyBody(); + assert.match( + body, + /stop_remote_proxy\(&profile\)[\s\S]*"proxy"\.to_string\(\),\s*"start"\.to_string\(\)/, + ); + assert.ok( + body.indexOf("stop_remote_proxy(&profile)") < body.indexOf('"config".to_string()'), + "remote_start_proxy must stop the old configured proxy before writing the new port", + ); +}); + +test("remote one-click frontend calls the full remote stack command", () => { + const body = main.match(/async function remoteOneClick\(\) \{[\s\S]*?\n\}/); + assert.ok(body, "remoteOneClick body should be discoverable"); + assert.match(body[0], /call\(["']remote_one_click["']/); + assert.match(body[0], /proxyPort/); + assert.match(body[0], /sandboxPort/); + assert.doesNotMatch(body[0], /call\(["']remote_start_proxy["']/); +}); + +test("remote profile test prepares helper instead of only checking health", () => { + const body = main.match(/async function testProfileConnection\(\) \{[\s\S]*?\n\}/); + assert.ok(body, "testProfileConnection body should be discoverable"); + assert.match(body[0], /call\(["']remote_prepare_helper["']/); + assert.doesNotMatch(body[0], /call\(["']remote_check_health["']/); +}); + +test("remote profile save prepares helper before saving the server", () => { + const body = main.match(/async function saveProfile\(\) \{[\s\S]*?\n\}/); + assert.ok(body, "saveProfile body should be discoverable"); + assert.match(body[0], /call\(["']remote_prepare_helper["']/); + assert.match(body[0], /call\(["']remote_save_profile["']/); + assert.ok( + body[0].indexOf('call("remote_prepare_helper"') < body[0].indexOf('call("remote_save_profile"'), + "save should prepare helper before persisting the remote server", + ); +}); + +test("remote password login uses a transient password instead of requiring system storage", () => { + assert.match(html, /id="passwordGroup"/); + assert.match(html, /]*id="editProfilePassword")(?=[^>]*type="password")[^>]*>/); + assert.match(html, /id="editProfileRememberPassword"/); + assert.match(html, /id="keyFileGroup"/); + + assert.match(main, /"editProfilePassword"/); + assert.match(main, /"editProfileRememberPassword"/); + assert.match(main, /"passwordGroup"/); + assert.match(main, /function toggleAuthFields\(\)/); + assert.match(main, /passwordGroup\.style\.display\s*=\s*method === "password"/); + assert.match(main, /keyFileGroup\.style\.display\s*=\s*method === "key_file"/); + assert.match(main, /function withTransientPassword\(/); + assert.match(main, /function stripTransientPassword\(/); + assert.match(main, /function rememberPasswordAfterConnection\(/); + assert.match(main, /editProfilePassword\.value\s*=\s*""/); + assert.doesNotMatch(main, /authMethod,\s*password/i); + assert.doesNotMatch(main, /password:\s*els\.editProfilePassword/); + + const saveBody = frontendFunctionBody("saveProfile"); + const testBody = frontendFunctionBody("testProfileConnection"); + const rememberBody = main.match(/async function rememberPasswordAfterConnection\([\s\S]*?\n\}/); + assert.ok(rememberBody, "rememberPasswordAfterConnection body should be discoverable"); + assert.match(saveBody, /withTransientPassword\(profile,\s*loginSecret\)/); + assert.match(saveBody, /stripTransientPassword\(profileForConnection\)/); + assert.match(testBody, /withTransientPassword\(profile,\s*loginSecret\)/); + assert.match(saveBody, /rememberPasswordAfterConnection\(profile\.id,\s*profile\.authMethod,\s*loginSecret\)/); + assert.match(testBody, /rememberPasswordAfterConnection\(profile\.id,\s*profile\.authMethod,\s*loginSecret\)/); + assert.match(rememberBody[0], /saveRemoteLoginSecret/); + assert.match(rememberBody[0], /deleteRemoteLoginSecret/); + assert.ok( + saveBody.indexOf('call("remote_prepare_helper"') < saveBody.indexOf("rememberPasswordAfterConnection"), + "password should be remembered only after SSH connection succeeds", + ); + assert.ok( + testBody.indexOf('call("remote_prepare_helper"') < testBody.indexOf("rememberPasswordAfterConnection"), + "test connection should remember the password only after SSH connection succeeds", + ); +}); + +test("remote one-click does not repeat helper preparation", () => { + const body = main.match(/async function remoteOneClick\(\) \{[\s\S]*?\n\}/); + assert.ok(body, "remoteOneClick body should be discoverable"); + assert.doesNotMatch(body[0], /remote_prepare_helper/); +}); + +test("backend exposes explicit remote helper preparation command", () => { + assert.match(remoteCommands, /pub fn remote_prepare_helper/); + assert.match(main, /case "remote_prepare_helper"/); +}); + +test("remote helper preparation installs only when health is not ready and falls back to bundled upload", () => { + const m = remoteCommands.match(/pub fn remote_prepare_helper[\s\S]*?\n}\n\n\/\/ ============================================================================/); + assert.ok(m, "remote_prepare_helper body should be discoverable"); + const body = m[0]; + assert.match(body, /helper_ready_for_profile/); + assert.match(body, /install_or_update_helper/); +}); + +test("remote helper preparation uses target-specific install ordering", () => { + const start = remoteCommands.indexOf("fn install_or_update_helper"); + assert.notEqual(start, -1, "install_or_update_helper body should be discoverable"); + const end = remoteCommands.indexOf("\n\n/// 安装/升级远程 Helper", start); + assert.notEqual(end, -1, "install_or_update_helper body should end before remote_install_helper"); + const body = remoteCommands.slice(start, end); + + assert.match(body, /RemoteTargetKind::Wsl/); + assert.match(body, /RemoteTargetKind::Ssh/); + + const wslBranch = body.match(/RemoteTargetKind::Wsl[\s\S]*?RemoteTargetKind::Ssh/)[0]; + assert.ok( + wslBranch.indexOf("install_helper_from_bundle") < wslBranch.indexOf("install_helper_from_github"), + "WSL should prefer bundled upload before GitHub download", + ); + + const sshBranch = body.match(/RemoteTargetKind::Ssh[\s\S]*?$/)[0]; + assert.ok( + sshBranch.indexOf("install_helper_from_github") < sshBranch.indexOf("install_helper_from_bundle"), + "SSH should prefer GitHub download before bundled upload", + ); +}); + +test("remote bundled helper upload uses SSH stdin and installs atomically", () => { + assert.match(remoteSsh, /pub fn install_helper_from_stdin/); + assert.match(remoteSsh, /stdin\(Stdio::piped\(\)\)/); + assert.match(remoteSsh, /cat > "\$TMP"/); + assert.match(remoteSsh, /chmod \+x "\$TMP"/); + assert.match(remoteSsh, /mv "\$TMP" "\$HELPER_PATH"/); +}); + +test("remote github helper installer extracts browser download url after matching asset name", () => { + assert.match(remoteSsh, /releases\/tags\/v\{helper_version\}/); + assert.doesNotMatch(remoteSsh, /releases\/latest/); + assert.match(remoteSsh, /BINARY_NAME="csswitch-helper-\$\{\{OS\}\}-\$\{\{ARCH\}\}"/); + assert.match(remoteSsh, /browser_download_url/); + assert.match(remoteSsh, /awk -v name=/); +}); + +test("wsl github helper installer downloads release asset instead of only checking status", () => { + assert.match(remoteWsl, /API_URL="https:\/\/api\.github\.com\/repos\/\{repo\}\/releases\/tags\/v\{helper_version\}"/); + assert.match(remoteWsl, /BINARY_NAME="csswitch-helper-\$\{\{OS\}\}-\$\{\{ARCH\}\}"/); + assert.match(remoteWsl, /browser_download_url/); + const installer = remoteWsl.match(/pub fn run_helper_install[\s\S]*?\n}\n\npub fn install_helper_from_stdin/); + assert.ok(installer, "WSL helper installer should be discoverable"); + assert.doesNotMatch(installer[0], /Helper not installed/); +}); + +test("helper release repo is detected without a static default", () => { + assert.match(remoteSsh, /CSSWITCH_HELPER_RELEASE_REPO/); + assert.doesNotMatch(remoteSsh, /const\s+HELPER_RELEASE_REPO\s*:/); + assert.doesNotMatch(remoteSsh, /"bfzha\/CSswitch"/); + assert.match(remoteSsh, /resolve_helper_release_repo_from/); + assert.match(remoteSsh, /option_env!\("GITHUB_REPOSITORY"\)/); + assert.match(remoteSsh, /git_origin_remote/); + assert.match(remoteSsh, /helper_release_repo_unknown/); +}); + +test("saving remote profile fails before persisting when helper preparation fails", () => { + const body = frontendFunctionBody("saveProfile"); + assert.ok( + body.indexOf('call("remote_prepare_helper"') < body.indexOf('call("remote_save_profile"'), + "helper preparation must happen before profile persistence", + ); +}); + +test("manual helper install command reuses target-specific install ordering", () => { + const m = remoteCommands.match(/pub fn remote_install_helper[\s\S]*?\n}\n\n#\[tauri::command\]\npub fn remote_prepare_helper/); + assert.ok(m, "remote_install_helper body should be discoverable"); + assert.match(m[0], /app: tauri::AppHandle/); + assert.match(m[0], /detect_remote_platform/); + assert.match(m[0], /install_or_update_helper\(&app, &profile, &arch\)/); +}); + +test("github helper installers avoid latest so helper version matches desktop", () => { + assert.doesNotMatch(remoteSsh, /releases\/latest/); + assert.doesNotMatch(remoteWsl, /releases\/latest/); + assert.match(remoteSsh, /releases\/tags\/v\{helper_version\}/); + assert.match(remoteWsl, /releases\/tags\/v\{helper_version\}/); +}); + +test("desktop bundle and release workflow provide linux helper assets for upload fallback", () => { + assert.match(tauriConf, /helper-assets/); + assert.match(buildWorkflow, /csswitch-helper-linux-\$\{\{ matrix\.asset_arch \}\}/); + assert.match(buildWorkflow, /helper-assets/); +}); + +test("desktop release workflow uses the Tauri v2 bundles argument", () => { + assert.doesNotMatch(buildWorkflow, /--bundler\b/); + assert.match(buildWorkflow, /npx tauri build --target \$\{\{ matrix\.target \}\} --bundles nsis/); + assert.match(buildWorkflow, /npx tauri build --target aarch64-apple-darwin --bundles dmg/); +}); + +test("desktop workflow publishes Windows installers for x64 and arm64", () => { + const body = workflowJob("build-windows"); + assert.match(body, /fail-fast: false/); + assert.match(body, /target: x86_64-pc-windows-msvc[\s\S]*artifact: CSSwitch-Windows-x64/); + assert.match(body, /target: aarch64-pc-windows-msvc[\s\S]*artifact: CSSwitch-Windows-arm64/); + assert.match(body, /targets: \$\{\{ matrix\.target \}\}/); + assert.match(body, /npx tauri build --target \$\{\{ matrix\.target \}\} --bundles nsis/); + assert.match(body, /name: \$\{\{ matrix\.artifact \}\}/); + assert.match(body, /target\/\$\{\{ matrix\.target \}\}\/release\/bundle\/nsis\/\*\.exe/); +}); + +test("desktop workflow keeps macOS packaging aligned with upstream arm64 DMG releases", () => { + const body = workflowJob("build-macos"); + assert.match(body, /runs-on: macos-15/); + assert.match(body, /targets: aarch64-apple-darwin/); + assert.match(body, /npx tauri build --target aarch64-apple-darwin --bundles dmg/); + assert.match(body, /name: CSSwitch-macOS-arm64/); + assert.match(body, /target\/aarch64-apple-darwin\/release\/bundle\/dmg\/\*\.dmg/); + assert.doesNotMatch(body, /macos-15-intel/); + assert.doesNotMatch(body, /x86_64-apple-darwin/); + assert.doesNotMatch(body, /universal-apple-darwin/); +}); + +test("release job uploads all public release assets", () => { + const body = workflowJob("release"); + assert.match(body, /CSSwitch-Windows-\*\/\*\.exe/); + assert.match(body, /CSSwitch-macOS-arm64\/\*\.dmg/); + assert.match(body, /csswitch-helper-\*\/\*/); +}); + +test("release job prefers curated release notes when present", () => { + const body = workflowJob("release"); + assert.match(body, /Resolve Release Notes/); + assert.match(body, /docs\/release-notes\/\$\{tag\}\.md/); + assert.match(body, /docs\/release-notes\/\$\{version\}\.md/); + assert.match(body, /body_path: \$\{\{ steps\.release_notes\.outputs\.path \}\}/); + assert.match(body, /generate_release_notes: true/); +}); + +test("linux helper release workflow uses cross for musl target builds", () => { + const body = workflowJob("build-helper"); + assert.match(body, /fail-fast: false/); + assert.match(body, /taiki-e\/install-action@v2/); + assert.match(body, /tool: cross/); + assert.match(body, /CSSWITCH_BUNDLED_PROXY_DIR: \$\{\{ github\.workspace \}\}\/proxy/); + assert.match(body, /cross build --bin csswitch-helper --no-default-features --release --target \$\{\{ matrix\.target \}\}/); + assert.doesNotMatch(body, /cargo build --bin csswitch-helper --no-default-features --release --target \$\{\{ matrix\.target \}\}/); + assert.doesNotMatch(body, /apt-get install -y musl-tools/); +}); + +test("linux helper cross build mounts the bundled proxy directory into the container", () => { + assert.match(crossToml, /\[build\.env\]/); + assert.match(crossToml, /volumes\s*=\s*\[[\s\S]*"CSSWITCH_BUNDLED_PROXY_DIR"[\s\S]*\]/); + assert.match(crossToml, /passthrough\s*=\s*\[[\s\S]*"CSSWITCH_BUNDLED_PROXY_DIR"[\s\S]*\]/); + assert.match(tauriBuildRs, /CSSWITCH_BUNDLED_PROXY_DIR/); +}); + +test("tauri build script validates bundled proxy resources with a clear error", () => { + assert.match(tauriBuildRs, /fn require_bundled_proxy_file/); + assert.match(tauriBuildRs, /csswitch_proxy\.py/); + assert.match(tauriBuildRs, /dsml_shim\.py/); + assert.match(tauriBuildRs, /\.is_file\(\)/); + assert.match(tauriBuildRs, /panic!\([\s\S]*CSSWITCH_BUNDLED_PROXY_DIR/); +}); + +test("linux test workflow installs Tauri system dependencies before cargo tests", () => { + const body = workflowJob("test"); + assert.match(body, /Install Linux desktop dependencies/); + assert.ok( + body.indexOf("Install Linux desktop dependencies") < body.indexOf("Run Tests"), + "Linux desktop dependencies should be installed before Rust tests compile Tauri crates", + ); + for (const pkg of [ + "libwebkit2gtk-4.1-dev", + "build-essential", + "curl", + "wget", + "file", + "libxdo-dev", + "libssl-dev", + "libayatana-appindicator3-dev", + "librsvg2-dev", + ]) { + assert.match(body, new RegExp(pkg.replaceAll(".", "\\."))); + } +}); + +test("macOS desktop builds bundle the linux helper assets too", () => { + const body = workflowJob("build-macos"); + assert.match(body, /needs: build-helper/); + assert.match(body, /Download Linux Helper Assets/); + assert.match(body, /pattern: csswitch-helper-linux-\*/); + assert.match(body, /path: desktop\/src-tauri\/helper-assets/); + assert.match(body, /npx tauri build --target aarch64-apple-darwin --bundles dmg/); + assert.match(buildWorkflow, /CSSwitch-macOS-arm64/); +}); + +test("macOS one-click command keeps app and state names available under cfg", () => { + const m = libTauri.match(/fn one_click_login\([\s\S]*?\n}\n\n\/\/\/ 从/); + assert.ok(m, "one_click_login body should be discoverable"); + const body = m[0]; + assert.match(body, /app: tauri::AppHandle/); + assert.match(body, /state: State<'_, Mutex>/); + assert.doesNotMatch(body, /_app: tauri::AppHandle/); + assert.doesNotMatch(body, /_state: State<'_, Mutex>/); + assert.match(body, /ensure_proxy\(&app, &state, &lifecycle\)/); + assert.match(body, /stop_sandbox_inner\(&app, &mut st\)/); + assert.match(body, /asset_root\(&app\)/); +}); + +test("macOS sandbox stopper keeps app handle name available under cfg", () => { + const m = libTauri.match(/fn stop_sandbox_inner\([\s\S]*?\n}\n\n\/\/ ----------/); + assert.ok(m, "stop_sandbox_inner body should be discoverable"); + const body = m[0]; + assert.match(body, /app: &tauri::AppHandle/); + assert.doesNotMatch(body, /_app: &tauri::AppHandle/); + assert.match(body, /asset_root\(app\)/); +}); + +test("remote one-click backend starts proxy and sandbox and returns access info", () => { + const m = remoteCommands.match(/pub fn remote_one_click[\s\S]*?\n}\n\n\/\/ ==========================================================================/); + assert.ok(m, "remote_one_click body should be discoverable"); + const body = m[0]; + assert.match(body, /remote_active_config_for_start/); + assert.match(body, /stop_remote_proxy\(&profile\)[\s\S]*"proxy"\.to_string\(\),\s*"start"\.to_string\(\)/); + assert.match(body, /stop_remote_sandbox\(&profile\)[\s\S]*"sandbox"\.to_string\(\),\s*"start"\.to_string\(\)/); + assert.match(body, /stop_remote_proxy\(&profile\)/); + assert.ok( + body.indexOf("stop_remote_sandbox(&profile)") < body.indexOf('"config".to_string()'), + "remote_one_click must stop the old sandbox before writing new ports", + ); + assert.ok( + body.indexOf("stop_remote_proxy(&profile)") < body.indexOf('"config".to_string()'), + "remote_one_click must stop the old proxy before writing new ports", + ); + assert.match(body, /proxy_url/); + assert.match(body, /tunnel_hint/); + assert.match(body, /local_url/); +}); + +test("remote one-click keeps the requested proxy port instead of drifting", () => { + const m = remoteCommands.match(/pub fn remote_one_click[\s\S]*?\n}\n\n\/\/ ==========================================================================/); + assert.ok(m, "remote_one_click body should be discoverable"); + const body = m[0]; + assert.doesNotMatch(body, /for candidate_proxy_port in proxy_port\.\.=proxy_port\.saturating_add\(20\)/); + assert.doesNotMatch(body, /selected_proxy_port/); + assert.match(body, /"proxy_port": proxy_port/); + assert.match(body, /启动远程代理失败/); +}); + +test("remote one-click returns the fresh Science URL from sandbox start", () => { + const m = remoteCommands.match(/pub fn remote_one_click[\s\S]*?\n}\n\n\/\/ ==========================================================================/); + assert.ok(m, "remote_one_click body should be discoverable"); + const body = m[0]; + assert.match(body, /sandbox_result\["url"\]\s*\.as_str\(\)/); + assert.match(body, /"local_url": local_url/); +}); + +test("remote one-click frontend renders a clickable local URL without auto-opening", () => { + const body = frontendFunctionBody("remoteOneClick"); + assert.doesNotMatch(body, /await openLocalUrl\(localUrl\)/); + assert.match(body, /setMsgHtml/); + assert.match(body, /data-url=/); + assert.match(main, /function setMsgHtml/); + assert.match(main, /async function openLocalUrl/); + assert.match(main, /call\("open_url", url \? \{ url \} : \{\}\)/); + assert.match(main, /els\.msg\.addEventListener\("click"/); +}); + +test("browser opener reuses open_url and only accepts local sandbox URLs", () => { + const m = libTauri.match(/fn open_url[\s\S]*?\n}\n\n\/\/\/ 运行诊断脚本/); + assert.ok(m, "open_url body should be discoverable"); + const body = m[0]; + assert.match(body, /url: Option/); + assert.match(body, /http:\/\/127\.0\.0\.1:/); + assert.match(body, /http:\/\/localhost:/); + assert.match(body, /http:\/\/\[::1\]:/); + assert.match(body, /只允许打开本地沙箱 URL/); + assert.doesNotMatch(libTauri, /fn open_browser_url/); +}); + +test("remote stop button stops remote sandbox and proxy", () => { + const body = frontendFunctionBody("stopAll"); + assert.match(body, /target === "remote" && currentProfile/); + assert.match(body, /call\("remote_stop_all", \{ profile: currentProfile \}\)/); + assert.doesNotMatch(body, /call\("remote_stop_proxy", \{ profile: currentProfile \}\)/); + assert.match(body, /远程代理与沙箱已停止/); +}); + +test("remote stop-all backend stops sandbox and proxy without serial delay", () => { + const m = remoteCommands.match(/pub fn remote_stop_all[\s\S]*?\n}\n\n\/\/\/ 查询远程代理状态/); + assert.ok(m, "remote_stop_all body should be discoverable"); + const body = m[0]; + assert.match(body, /std::thread::spawn[\s\S]*stop_remote_sandbox/); + assert.match(body, /let proxy_res = stop_remote_proxy\(&profile\)/); + assert.match(body, /远程代理已停;但停止远程沙箱失败/); +}); + +test("remote helper status reports the configured sandbox state", () => { + assert.match(helperCommands, /fn sandbox_is_running/); + assert.match(helperCommands, /fn get_configured_sandbox_port/); + assert.match(helperCommands, /"sandbox_running": sandbox_is_running\(\)/); +}); + +test("remote helper sandbox stop is idempotent before requiring Science", () => { + const m = helperCommands.match(/pub fn cmd_sandbox_stop[\s\S]*?\n}\n\n\/\/\/ `logs/); + assert.ok(m, "cmd_sandbox_stop body should be discoverable"); + const body = m[0]; + assert.match(body, /if !sandbox_is_running\(\)[\s\S]*CliEnvelope::ok/); + assert.match(body, /find_cmd\("claude-science"\)/); + assert.ok( + body.indexOf("if !sandbox_is_running()") < body.indexOf('find_cmd("claude-science")'), + "not-running sandbox should return ok before requiring the binary", + ); +}); + +test("remote helper sandbox start returns a fresh claude-science url", () => { + const m = helperCommands.match(/pub fn cmd_sandbox_start[\s\S]*?\n}\n\n\/\/\/ `sandbox stop/); + assert.ok(m, "cmd_sandbox_start body should be discoverable"); + const body = m[0]; + assert.match(body, /sandbox_fresh_url/); + assert.match(body, /\.args\(\["url", "--data-dir"\]\)/); + assert.match(body, /"url": url/); +}); + +test("remote helper clears stale Science processes and waits for a usable sandbox url", () => { + const m = helperCommands.match(/pub fn cmd_sandbox_start[\s\S]*?\n}\n\n\/\/\/ `sandbox stop/); + assert.ok(m, "cmd_sandbox_start body should be discoverable"); + const body = m[0]; + assert.match(helperCommands, /fn wait_for_sandbox_ready/); + assert.match(helperCommands, /fn terminate_sandbox_processes/); + assert.match(helperCommands, /fn matching_sandbox_pids/); + assert.ok( + body.indexOf("terminate_sandbox_processes") < body.indexOf(".spawn()"), + "sandbox start must clear stale same-data-dir Science processes before spawning", + ); + assert.match(helperCommands, /let url = sandbox_fresh_url/); + assert.match(helperCommands, /http_health\(port, None/); + assert.match(body, /sandbox\.log/); + assert.doesNotMatch( + body, + /stdout\(std::process::Stdio::null\(\)\)[\s\S]*stderr\(std::process::Stdio::null\(\)\)[\s\S]*\.spawn\(\)/, + "serve startup output should not be discarded while diagnosing daemon startup failures", + ); +}); + +test("remote helper sandbox start binds Science to loopback for SSH tunnel access", () => { + const m = helperCommands.match(/pub fn cmd_sandbox_start[\s\S]*?\n}\n\n\/\/\/ `sandbox stop/); + assert.ok(m, "cmd_sandbox_start body should be discoverable"); + const body = m[0]; + assert.match(body, /\.arg\("--host"\)\s*\.arg\("127\.0\.0\.1"\)/); + assert.doesNotMatch(body, /\.arg\("0\.0\.0\.0"\)/); +}); + +test("remote helper carries and installs the managed proxy script", () => { + assert.match( + helperCommands, + /include_str!\(concat!\([\s\S]*env!\("CSSWITCH_BUNDLED_PROXY_DIR"\)[\s\S]*"\/csswitch_proxy\.py"[\s\S]*\)\)/, + ); + assert.match( + helperCommands, + /include_str!\(concat!\([\s\S]*env!\("CSSWITCH_BUNDLED_PROXY_DIR"\)[\s\S]*"\/dsml_shim\.py"[\s\S]*\)\)/, + ); + assert.match( + helperCommands, + /include_str!\(concat!\([\s\S]*env!\("CSSWITCH_BUNDLED_PROXY_DIR"\)[\s\S]*"\/provider_policy\.py"[\s\S]*\)\)/, + ); + assert.match( + helperCommands, + /include_str!\(concat!\([\s\S]*env!\("CSSWITCH_BUNDLED_PROXY_DIR"\)[\s\S]*"\/anthropic_compat\.py"[\s\S]*\)\)/, + ); + assert.match(helperCommands, /fn ensure_managed_proxy_script\(\) -> Result/); + assert.match(helperCommands, /"~\/\.csswitch\/proxy\/csswitch_proxy\.py"/); + assert.match(helperCommands, /dsml_shim\.py/); + assert.match(helperCommands, /provider_policy\.py/); + assert.match(helperCommands, /anthropic_compat\.py/); + assert.match(helperCommands, /BUNDLED_PROXY/); +}); + +test("remote helper status advertises the current proxy bundle capability", () => { + assert.match(helperCommands, /"proxy-bundle-v2"/); + assert.match(remoteCommands, /"proxy-bundle-v2"/); +}); + +test("desktop build requires every Python module imported by the managed proxy", () => { + for (const file of ["csswitch_proxy.py", "dsml_shim.py", "provider_policy.py", "anthropic_compat.py"]) { + assert.match(tauriBuildRs, new RegExp(`"${file.replace(".", "\\.")}"`)); + } +}); + +test("remote helper prefers the self-healed managed proxy bundle after explicit overrides", () => { + const m = helperCommands.match(/fn proxy_script_path[\s\S]*?\n}\n\n\/\/ ==========/); + assert.ok(m, "proxy_script_path body should be discoverable"); + const body = m[0]; + assert.ok( + body.indexOf('std::env::var("CSSWITCH_PROXY_DIR")') < body.indexOf("ensure_managed_proxy_script()"), + "explicit CSSWITCH_PROXY_DIR override should remain first", + ); + assert.doesNotMatch(body, /current_exe\(\)[\s\S]*ensure_managed_proxy_script\(\)/); +}); + +test("remote helper searches user-local binary directories for Science", () => { + const m = helperCommands.match(/fn find_cmd[\s\S]*?\n}/); + assert.ok(m, "find_cmd body should be discoverable"); + const body = m[0]; + assert.match(body, /\.local"\)\.join\("bin"\)/); + assert.match(body, /miniconda3"\)\.join\("bin"\)/); + assert.match(body, /anaconda3"\)\.join\("bin"\)/); +}); + +test("remote helper injects relay profile connection fields into proxy env", () => { + assert.match(helperCommands, /fn proxy_launch_from_config/); + assert.match(helperCommands, /"CSSWITCH_RELAY_KEY"/); + assert.match(helperCommands, /"CSSWITCH_RELAY_BASE_URL"/); + assert.match(helperCommands, /"CSSWITCH_RELAY_MODEL"/); + assert.match(helperCommands, /"CSSWITCH_RELAY_THINKING"/); + assert.doesNotMatch(helperCommands, /_ => "DEEPSEEK_API_KEY"/); +}); + +test("remote helper clears an unhealthy proxy port before spawning a replacement", () => { + assert.match(helperCommands, /fn clear_unhealthy_proxy_port/); + assert.match(helperCommands, /fn stop_recorded_proxy/); + assert.match(helperCommands, /pid_looks_like_recorded_proxy/); + assert.match(helperCommands, /stop_recorded_proxy\(port\)/); + assert.match(helperCommands, /clear_unhealthy_proxy_port\(port\)/); + assert.match(helperCommands, /port_in_use/); +}); + +test("remote helper proxy start detaches and waits for health", () => { + const m = helperCommands.match(/pub fn cmd_proxy_start[\s\S]*?\n}\n\n\/\/\/ `proxy status`/); + assert.ok(m, "cmd_proxy_start body should be discoverable"); + const body = m[0]; + assert.match(body, /proxy\.log/); + assert.match(body, /stdin\(Stdio::null\(\)\)/); + assert.match(body, /process_group\(0\)/); + assert.match(body, /proxy_health\(port, secret\)/); + assert.match(body, /try_wait\(\)/); + assert.match(body, /proxy_start_timeout/); +});