From f934de72f0c79093ab6b904a29ae9170501d741e Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 10:57:06 +0800 Subject: [PATCH 01/35] =?UTF-8?q?feat(desktop):=20=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=BC=96=E8=AF=91=20+=20=E8=BF=9C=E7=A8=8B=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E7=AE=A1=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Windows 编译兼容和基于 SSH 的远程服务器管理,macOS 功能不变。 ## 跨平台基础 (Phase 1) - 新增 fs_ext.rs:条件编译抽象文件权限操作,Unix 设 0600/0700,Windows no-op - 替换所有 std::os::unix::fs 导入为 crate::fs_ext - /dev/urandom → rand::OsRng 跨平台随机数生成 - HOME → dirs::home_dir() 跨平台家目录 - #[cfg(target_os = "macos")] 守卫所有 macOS 本地代码路径 - #[cfg(unix)] 守卫符号链接相关测试 ## 远程服务器管理 (Phase 2-4) - 新增 remote/ 模块:SSH 连接、Profile 存储、JSON 协议类型 - 新增 remote_commands.rs:18 个 Tauri async command - remote/types.rs:RemoteHostProfile, RemoteHealth, RemoteAuthMethod 等 - remote/ssh.rs:SSH 参数构建、命令执行、指数退避重试、结构化错误映射 - remote/store.rs:Profile CRUD 本地持久化 + 字段校验 + 单元测试 ## 远程 Helper CLI (Phase 3) - 新增 csswitch-helper 独立 Rust 二进制(无 Tauri 依赖) - cli/mod.rs:命令路由(pattern match 风格) - cli/commands.rs:代理管理、配置读写、日志查看、Key 验证、诊断 - cli/serve.rs:JSON-line stdin/stdout 持久会话模式 - 支持 --json 标志的单次命令模式和 serve 持久模式 ## 前端适配 (Phase 5) - index.html:本地/远程模式切换器、Profile 管理弹窗、编辑表单 - main.js:远程状态管理、操作分派(saveKey/oneClick/stopAll/refreshStatus) - styles.css:remote-only/local-only 样式、弹窗、连接动画 ## Cargo 配置更新 - autobins=false,显式声明 csswitch 和 csswitch-helper 两个 binary - desktop feature gate(tauri 依赖)使 helper 可独立编译 - 新增 rand=0.8, dirs=5 依赖 Co-Authored-By: Claude --- desktop/src-tauri/Cargo.lock | 162 +++++- desktop/src-tauri/Cargo.toml | 27 +- desktop/src-tauri/src/bin/csswitch-helper.rs | 60 ++ desktop/src-tauri/src/cli/commands.rs | 507 +++++++++++++++++ desktop/src-tauri/src/cli/mod.rs | 93 ++++ desktop/src-tauri/src/cli/serve.rs | 72 +++ desktop/src-tauri/src/cli/types.rs | 98 ++++ desktop/src-tauri/src/config.rs | 45 +- desktop/src-tauri/src/fs_ext.rs | 91 +++ desktop/src-tauri/src/lib.rs | 229 ++++++-- desktop/src-tauri/src/oauth_forge.rs | 24 +- desktop/src-tauri/src/proc.rs | 45 +- desktop/src-tauri/src/remote/mod.rs | 21 + desktop/src-tauri/src/remote/ssh.rs | 551 +++++++++++++++++++ desktop/src-tauri/src/remote/store.rs | 223 ++++++++ desktop/src-tauri/src/remote/types.rs | 165 ++++++ desktop/src-tauri/src/remote_commands.rs | 457 +++++++++++++++ desktop/src/index.html | 74 +++ desktop/src/main.js | 442 ++++++++++++++- desktop/src/styles.css | 35 ++ 20 files changed, 3315 insertions(+), 106 deletions(-) create mode 100644 desktop/src-tauri/src/bin/csswitch-helper.rs create mode 100644 desktop/src-tauri/src/cli/commands.rs create mode 100644 desktop/src-tauri/src/cli/mod.rs create mode 100644 desktop/src-tauri/src/cli/serve.rs create mode 100644 desktop/src-tauri/src/cli/types.rs create mode 100644 desktop/src-tauri/src/fs_ext.rs create mode 100644 desktop/src-tauri/src/remote/mod.rs create mode 100644 desktop/src-tauri/src/remote/ssh.rs create mode 100644 desktop/src-tauri/src/remote/store.rs create mode 100644 desktop/src-tauri/src/remote/types.rs create mode 100644 desktop/src-tauri/src/remote_commands.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 02d7188..9bc9b13 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -752,7 +752,9 @@ version = "0.2.1" dependencies = [ "aes-gcm", "base64 0.22.1", + "dirs 5.0.1", "hkdf", + "rand", "serde", "serde_json", "sha2", @@ -772,13 +774,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 +812,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2622,6 +2645,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 +2752,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 +2797,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" @@ -3407,7 +3471,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3457,7 +3521,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3997,7 +4061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4583,6 +4647,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 +4689,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 +4744,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 +4762,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 +4780,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 +4804,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 +4822,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 +4840,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 +4858,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 +4926,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -4935,6 +5065,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" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a6b0433..fa6f9c8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,30 +1,39 @@ [package] name = "desktop" version = "0.2.1" -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 +autobins = false [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" + +[features] +default = ["desktop"] +desktop = ["tauri"] + [build-dependencies] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = [], optional = true } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +rand = "0.8" +dirs = "5" # 虚拟 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" 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..cfdb67e --- /dev/null +++ b/desktop/src-tauri/src/bin/csswitch-helper.rs @@ -0,0 +1,60 @@ +//! 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] 引入 cli 模块(helper 不依赖 Tauri,无法用 crate:: 引用整个 lib)。 +#[path = "../cli/mod.rs"] +mod cli; + +fn main() { + 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/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs new file mode 100644 index 0000000..10d7810 --- /dev/null +++ b/desktop/src-tauri/src/cli/commands.rs @@ -0,0 +1,507 @@ +//! Helper CLI 的命令实现。 +//! +//! 每个命令返回 `CliEnvelope`,由 `mod.rs` 中的 `dispatch()` 函数调用。 +//! 管理远程服务器上的 `csswitch_proxy.py` 代理进程、`~/.csswitch/config.json` 配置、 +//! Claude Science 沙箱和日志文件。 + +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; + +use serde_json::{json, Value}; + +use super::types::CliEnvelope; + +// ============================================================================ +// 全局状态(进程句柄,仅在 serve 模式下跨请求复用) +// ============================================================================ + +/// 代理子进程句柄(用于 serve 模式跨请求管理代理生命周期)。 +static PROXY_CHILD: Mutex> = Mutex::new(None); + +/// 代理运行时信息(PID、端口)。 +static PROXY_INFO: Mutex> = Mutex::new(None); + +struct ProxyInfo { + pid: u32, + port: u16, + secret: String, +} + +// ============================================================================ +// 路径工具 +// ============================================================================ + +/// 获取 `~/.csswitch` 目录路径。 +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") +} + +/// 获取 `~/.csswitch/logs/` 目录路径。 +fn logs_dir() -> PathBuf { + config_dir().join("logs") +} + +/// 定位 `proxy/csswitch_proxy.py`: +/// 1. `CSSWITCH_PROXY_DIR` 环境变量 +/// 2. Helper 二进制同级目录(部署态) +/// 3. 相对路径(开发态) +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); + } + } + // Helper 二进制同级目录 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("proxy").join("csswitch_proxy.py"); + if p.is_file() { + return Ok(p); + } + let p = dir.join("..").join("proxy").join("csswitch_proxy.py"); + if p.is_file() { + return Ok(p.canonicalize().unwrap_or(p)); + } + } + } + Err("找不到 proxy/csswitch_proxy.py。请设置 CSSWITCH_PROXY_DIR 环境变量。".to_string()) +} + +// ============================================================================ +// 辅助函数 +// ============================================================================ + +/// 从 `~/.csswitch/config.json` 读取指定 provider 的 key。 +fn load_key_from_config(provider: &str) -> Result, String> { + let cfg = config_path(); + if !cfg.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&cfg).map_err(|e| format!("读配置失败:{e}"))?; + let v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("解析配置失败:{e}"))?; + Ok(v.get("providers") + .and_then(|p| p.get(provider)) + .and_then(|p| p.get("key")) + .and_then(|k| k.as_str()) + .filter(|k| !k.is_empty()) + .map(|k| k.to_string())) +} + +/// 通过 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]); + head.lines().next().map_or(false, |line| line.contains("200")) +} + +// ============================================================================ +// 命令实现 +// ============================================================================ + +/// `status` — 返回 Helper 版本、能力列表、代理/沙箱运行状态。 +pub fn cmd_status() -> CliEnvelope { + let capabilities: Vec<&str> = vec!["proxy", "sandbox", "config", "logs", "doctor", "verify"]; + let proxy_running = PROXY_INFO.lock().unwrap().is_some(); + 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": false, + })) +} + +/// `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`。 +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}")), + }; + let dir = config_dir(); + let path = config_path(); + if let Err(e) = fs::create_dir_all(&dir) { + return CliEnvelope::err("config_write_error", &format!("创建配置目录失败:{e}")); + } + let json = match serde_json::to_vec_pretty(&v) { + Ok(j) => j, + Err(e) => return CliEnvelope::err("config_serialize_error", &format!("序列化失败:{e}")), + }; + if let Err(e) = fs::write(&path, &json) { + return CliEnvelope::err("config_write_error", &format!("无法写入配置文件:{e}")); + } + CliEnvelope::ok_empty() +} + +/// `config save-key ` — 保存 provider key。 +pub fn cmd_config_save_key(provider: &str, key: &str) -> CliEnvelope { + let path = config_path(); + let dir = config_dir(); + let _ = fs::create_dir_all(&dir); + + let mut cfg: Value = if path.exists() { + match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or(json!({})), + Err(_) => json!({}), + } + } else { + json!({ + "provider": "deepseek", + "proxy_port": 18991, + "sandbox_port": 8990, + "mode": "proxy", + }) + }; + + // 确保 providers 对象存在 + if cfg.get("providers").is_none() { + cfg["providers"] = json!({}); + } + cfg["providers"][provider] = json!({"key": key}); + + let json_bytes = match serde_json::to_vec_pretty(&cfg) { + Ok(j) => j, + Err(e) => return CliEnvelope::err("config_serialize_error", &format!("序列化失败:{e}")), + }; + if let Err(e) = fs::write(&path, &json_bytes) { + return CliEnvelope::err("config_write_error", &format!("无法写入配置文件:{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 { + // 检查是否已在运行 + { + let info = PROXY_INFO.lock().unwrap(); + if let Some(ref pi) = *info { + if proxy_health(pi.port, &pi.secret) { + return CliEnvelope::err("proxy_already_running", &format!("代理已在端口 {} 上运行", pi.port)); + } + } + } + + // 获取需要注入的 key + let key = match load_key_from_config(provider) { + Ok(Some(k)) => k, + 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 key_env = match provider { + "qwen" => "DASHSCOPE_API_KEY", + _ => "DEEPSEEK_API_KEY", + }; + + // 启代理子进程 + match Command::new(&python) + .arg(&script) + .arg("--provider") + .arg(provider) + .arg("--port") + .arg(port.to_string()) + .arg("--auth-token") + .arg(secret) + .env(key_env, &key) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + let pid = child.id(); + let mut pi = PROXY_CHILD.lock().unwrap(); + *pi = Some(child); + let mut info = PROXY_INFO.lock().unwrap(); + *info = Some(ProxyInfo { + pid, + port, + secret: secret.to_string(), + }); + CliEnvelope::ok(json!({ + "port": port, + "pid": pid, + "message": "代理已启动", + })) + } + 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 stop` — 停止代理进程。 +pub fn cmd_proxy_stop() -> CliEnvelope { + let mut child = PROXY_CHILD.lock().unwrap(); + if let Some(mut c) = child.take() { + // SIGTERM → 等待 3s → SIGKILL + let _ = c.kill(); + let _ = c.wait(); + } + let mut info = PROXY_INFO.lock().unwrap(); + *info = None; + CliEnvelope::ok_empty() +} + +/// `proxy status` — 返回代理运行状态。 +pub fn cmd_proxy_status() -> CliEnvelope { + let info = PROXY_INFO.lock().unwrap(); + match info.as_ref() { + Some(pi) => { + let healthy = proxy_health(pi.port, &pi.secret); + CliEnvelope::ok(json!({ + "running": true, + "pid": pi.pid, + "port": pi.port, + "healthy": healthy, + })) + } + None => { + CliEnvelope::ok(json!({ + "running": false, + "healthy": false, + })) + } + } +} + +/// `sandbox status` — 这里做简单占位,沙箱管理细节待进一步实现。 +pub fn cmd_sandbox_status() -> CliEnvelope { + // 检查 Claude Science 是否在运行(简化实现) + CliEnvelope::ok(json!({ + "running": false, + "message": "沙箱管理暂未实现。请在服务器上手动管理 Claude Science。", + })) +} + +/// `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 info = PROXY_INFO.lock().unwrap(); + let proxy_running = info.is_some(); + checks.push(json!({ + "name": "代理运行状态", + "ok": proxy_running, + "detail": if proxy_running { format!("端口 {}", info.as_ref().unwrap().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 { + if let Ok(path) = std::env::var("PATH") { + for dir in path.split(':') { + let full = PathBuf::from(dir).join(name); + if full.is_file() { + return Some(full.display().to_string()); + } + } + } + None +} diff --git a/desktop/src-tauri/src/cli/mod.rs b/desktop/src-tauri/src/cli/mod.rs new file mode 100644 index 0000000..074f890 --- /dev/null +++ b/desktop/src-tauri/src/cli/mod.rs @@ -0,0 +1,93 @@ +//! Helper CLI 的命令路由与分发。 +//! +//! `dispatch()` 函数解析命令行参数并路由到 `commands` 模块的对应实现。 +//! 模式匹配风格参考 cc-switch-remote 的 `cli/mod.rs`。 + +pub mod commands; +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", _) => CliEnvelope::err_with_hint( + "unsupported", + "沙箱管理暂未实现", + "请在服务器上手动管理 Claude Science(claude-science start/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/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 d71f108..1a5496e 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -12,9 +12,10 @@ use std::collections::BTreeMap; 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, OpenOptionsExt, PermissionsExt}; + use serde::{Deserialize, Serialize}; fn default_provider() -> String { @@ -82,12 +83,11 @@ impl 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 { @@ -120,7 +120,7 @@ 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(()) } @@ -138,7 +138,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); let cfg: Config = serde_json::from_slice(&data).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, @@ -169,11 +169,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() + let mut f = { + use crate::fs_ext::OpenOptionsExt; + fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) - .open(&tmp)?; + .open(&tmp)? + }; f.write_all(&json)?; f.sync_all()?; Ok(()) @@ -187,7 +190,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(()) } @@ -219,7 +222,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」独立子目录,避免并行测试相互踩。 @@ -230,6 +237,7 @@ mod tests { d } + /// 读取文件权限的 Unix mode 位(仅 Unix 平台有意义,Windows 返回 0)。 fn mode_of(p: &Path) -> u32 { fs::metadata(p).unwrap().permissions().mode() & 0o777 } @@ -263,6 +271,8 @@ mod tests { assert_eq!(got.key_for("deepseek").as_deref(), Some("sk-abcdef1234")); } + /// 测试 save_to 后目录和文件权限正确(仅 Unix)。 + #[cfg(unix)] #[test] fn save_sets_dir_0700_and_file_0600() { let d = tmpdir().join(".csswitch"); @@ -271,16 +281,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(); @@ -296,6 +311,8 @@ mod tests { assert_eq!(fs::read(&target).unwrap(), b"ORIGINAL"); } + /// 从符号链接文件读取应被拒绝(仅 Unix)。 + #[cfg(unix)] #[test] fn load_rejects_symlinked_file() { let base = tmpdir(); @@ -308,6 +325,8 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } + /// ~/.csswitch 目录本身是符号链接时 load 应拒绝(仅 Unix)。 + #[cfg(unix)] #[test] fn load_rejects_symlinked_dir() { // ~/.csswitch 本身被换成软链时,load 也必须拒绝(不跟随读到别处)——修 P1-3。 @@ -321,6 +340,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..75fd610 --- /dev/null +++ b/desktop/src-tauri/src/fs_ext.rs @@ -0,0 +1,91 @@ +//! 跨平台文件权限抽象。 +//! +//! Unix: re-export 标准库的 OpenOptionsExt / PermissionsExt,提供真实的 0600/0700 权限。 +//! Windows: 提供同名 trait 的 no-op 实现,权限操作为空操作。 +//! +//! 所有文件使用 `use crate::fs_ext::...` 替代 `use std::os::unix::fs::...`。 + +use std::fs; +use std::io; +use std::path::Path; + +// ---------- 平台条件编译 ---------- + +#[cfg(unix)] +mod imp { + 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 操作无意义。 + 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 { + fs::Permissions::new() // 默认权限(非只读) + } + 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) + } +} + +// ---------- 公开导出 ---------- + +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 d397ddd..e567b59 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,26 +1,39 @@ -//! CSSwitch 菜单栏 app 后端(进程管家)。 +//! CSSwitch 桌面 app 后端(进程管家 + 远程服务器管理)。 //! -//! 职责:管理「翻译代理」与「沙箱 Science」两个子进程的生命周期;读写 +//! 职责:管理「翻译代理」与「沙箱 Science」两个子进程的生命周期(本地 macOS 模式), +//! 或通过 SSH 管理远程 Linux 服务器上的同名服务(远程模式);读写 //! `~/.csswitch/config.json`;把第三方 key 以【环境变量】注入代理子进程(绝不进 argv); -//! 探活;把沙箱 URL 交系统浏览器打开。已验证的越权/翻译逻辑仍留在 Python/Node/shell -//! 里被当作子进程调用,以保住铁律护栏与已验证行为。 +//! 探活;把沙箱 URL 交系统浏览器打开。 +//! +//! 跨平台适配:macOS 代码用 `#[cfg(target_os = "macos")]` 守卫;Windows 不支持本地模式 +//! (缺少 Claude Science.app / zsh / pkill 等),本地操作返回明确错误。 //! //! 铁律相关:key 只在内存与 0600 的 config.json;回显前端只给掩码;沙箱端口/目录护栏 //! 由被调脚本负责(对 8765 与真实目录失败关闭);退 app 默认停代理、保留沙箱。 mod config; +// 虚拟 OAuth 伪造器仅 macOS 本地需要(Windows 远程模式不使用虚拟登录)。 +#[cfg(target_os = "macos")] mod oauth_forge; mod proc; +// 跨平台文件权限抽象:Unix 下提供真实的 0600/0700 权限,Windows 下为 no-op。 +mod fs_ext; +// 远程服务器管理:SSH 连接、Profile 存储、远程命令。 +mod remote; +mod remote_commands; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::time::Duration; +use crate::fs_ext::{open_log_file, set_file_permissions, PermissionsExt}; 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)] @@ -113,36 +126,22 @@ 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 尾巴)。 +/// 跨平台:Unix 用 `O_NOFOLLOW` 防符号链接跟随;Windows 无此概念,仅做普通 open。 +/// 注意:symlink 检查 `config::assert_not_symlink` 本身在所有平台可用 +/// (`std::fs::symlink_metadata` + `is_symlink()` 是跨平台的)。 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)); + let _ = set_file_permissions(parent, 0o700); } // 日志路径不许是符号链接:否则 truncate+写会覆盖链接目标文件(修 P2-1)。 config::assert_not_symlink(&p)?; - let f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - // O_NOFOLLOW:即便在 lstat 与 open 之间被换成软链,也拒绝跟随。 - .custom_flags(libc_o_nofollow()) - .open(&p)?; - // 文件已存在时 mode() 不复位,显式再夹一次。 - let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)); + let f = open_log_file(&p)?; + // 文件已存在时 mode 不复位,显式再夹一次。 + let _ = set_file_permissions(&p, 0o600); Ok(f) } @@ -177,14 +176,40 @@ fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, AppState> { m.lock().unwrap_or_else(|e| e.into_inner()) } -/// 用系统浏览器打开 URL(macOS `open`)。校验退出码:非零视为失败(P2c)。 +/// 用系统浏览器打开 URL。 +/// 跨平台:macOS 用 `open` 命令,Windows 用 `cmd /c start`(或 Tauri opener 插件)。 +/// 校验退出码:非零视为失败(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())); + #[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(()) } @@ -262,8 +287,12 @@ fn ensure_proxy( // 孤儿仍占着端口 → 新代理绑不上(Errno 48)→ 探活超时。 // 收紧(P2 GPT 复审):匹配【本安装的绝对脚本路径】+ 端口,而非仅「脚本名+端口」, // 避免误杀另一个 checkout / 用户手启的同名代理。路径里的正则元字符转义按字面匹配。 - let pat = format!("{}.*--port {port}", ere_escape(&script.to_string_lossy())); - let _ = Command::new("pkill").arg("-f").arg(&pat).status(); + // 跨平台:`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())?; @@ -313,11 +342,20 @@ fn ensure_proxy( /// 停沙箱。返回 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"))] + { + 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) => { @@ -355,6 +393,7 @@ fn stop_sandbox_inner(app: &tauri::AppHandle, st: &mut AppState) -> Result<(), S Some(e) => Err(e), None => Ok(()), } + } // #[cfg(target_os = "macos")] } // ---------- Tauri commands ---------- @@ -418,28 +457,37 @@ fn set_mode( } /// 官方模式:干净地打开用户【真实】的 Claude Science(用户自己的官方登录与订阅)。 +/// 仅 macOS 有效(需本地安装 Claude Science.app);在 Windows / 其他平台上返回明确提示, +/// 引导用户使用远程模式管理服务器上的 Science。 /// /// 铁律:绝不碰/复制真实凭证;用 `open`(系统 LaunchServices 正常启动)而非注入环境变量, /// 并显式抹掉任何 `ANTHROPIC_*`,确保**不用改过的环境变量启动真实实例**(真实实例走它自己的 /// 官方端点,不经本代理)。CSSwitch 只把用户交回官方客户端,不托管其登录。 #[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"); + #[cfg(not(target_os = "macos"))] + { + return Err("本地模式「打开官方 Claude Science」仅支持 macOS。请使用远程模式连接到运行 Science 的 Linux 服务器。".into()); } - // 防御性:即便 `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}")), + #[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}")), + } } } @@ -536,11 +584,19 @@ fn stop_all(app: tauri::AppHandle, state: State<'_, Mutex>) -> Result< 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>, ) -> Result { + #[cfg(not(target_os = "macos"))] + { + return Err("本地模式「一键开始」仅支持 macOS。请切换到「远程服务器」模式管理 Linux 服务器上的 Science。".into()); + } + #[cfg(target_os = "macos")] + { // 1~3. 确保代理在跑且健康(内部已查 key、探活)。带回本次是复用还是重启。 let (pport, secret, proxy_action) = ensure_proxy(&app, &state)?; @@ -690,6 +746,7 @@ fn one_click_login( Err(_) => format!("{started},服务已就绪,请手动打开:{url}"), }; Ok(json!({ "url": url, "msg": msg, "action": "started" })) + } // #[cfg(target_os = "macos")] } /// 从 `claude-science url` 的 stdout 里取**第一条**合法 http(s) URL。 @@ -710,7 +767,14 @@ fn first_http_url(stdout: &str) -> Option { /// 取沙箱 UI 链接:` url --data-dir /.claude-science`,HOME 指向沙箱 HOME。 /// 失败退回 http://127.0.0.1:。沙箱 HOME 用 [`sandbox_home`](与 launch 时一致)。 +/// 仅 macOS 有效(依赖 Claude Science.app 二进制);其他平台直接返回端口 URL。 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() { @@ -729,13 +793,21 @@ fn sandbox_url(port: u16) -> String { } } format!("http://127.0.0.1:{port}") + } // #[cfg(target_os = "macos")] } /// 判断「我们自己的」沙箱 Science 是否在跑(供一键健康分派)。收紧(P2 GPT 复审):优先用 /// Science 二进制按【我们的 data-dir】查 `{"running":true}`,这是强身份——不会被恰好占用 /// `port` 且返回 200 的冒名服务骗过;再叠加端口 /health 确认确实在服务。二进制不在(纯 dev / /// 研究者机器)时退化为仅端口探活(原行为)。 +/// 仅 macOS 有效;非 macOS 退化为纯端口探活(无本地 SCIENCE_BIN)。 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() { @@ -757,6 +829,7 @@ fn sandbox_running_ours(port: u16) -> bool { } } proc::http_health(port, None, 400) + } // #[cfg(target_os = "macos")] } #[tauri::command] @@ -804,8 +877,16 @@ fn open_url(state: State<'_, Mutex>) -> Result<(), String> { open_in_browser(&url) } +/// 运行诊断脚本 `scripts/doctor.sh`。仅 macOS 本地模式有效。 +/// Windows/其他平台上返回明确提示,引导使用远程模式诊断。 #[tauri::command] fn run_doctor(app: tauri::AppHandle) -> Result { + #[cfg(not(target_os = "macos"))] + { + 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"); @@ -826,6 +907,7 @@ fn run_doctor(app: tauri::AppHandle) -> Result { text.push_str(err.trim()); } Ok(text) + } // #[cfg(target_os = "macos")] } /// 当前 app 版本(供前端「检查更新」与页脚版本号用)。 @@ -846,15 +928,33 @@ fn report_bug() -> Result<(), String> { open_in_browser("https://github.com/SuperJJ007/CSswitch/issues/new?template=bug_report.yml") } -/// 在访达里打开日志目录 `~/.csswitch/logs`,方便用户附到 bug 反馈里(先自查有无密钥)。 +/// 在文件管理器中打开日志目录 `~/.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); - Command::new("open") - .arg(&dir) - .status() - .map_err(|e| format!("打开日志目录失败:{e}"))?; + #[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(()) } @@ -877,6 +977,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .manage(Mutex::new(AppState::default())) .invoke_handler(tauri::generate_handler![ + // 本地命令(macOS 本地模式) get_config, set_config, set_mode, @@ -893,7 +994,25 @@ pub fn run() { open_release_page, report_bug, open_logs, - quit_app + 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_check_health, + 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_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| { // 正常桌面应用:进 Dock、走常规应用生命周期(默认 Regular 策略, 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 607c879..b68f08d 100644 --- a/desktop/src-tauri/src/proc.rs +++ b/desktop/src-tauri/src/proc.rs @@ -1,5 +1,6 @@ -//! 进程管家用到的纯 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}; @@ -7,6 +8,9 @@ use std::path::PathBuf; 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 { @@ -150,9 +154,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) @@ -175,11 +181,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() @@ -231,27 +239,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)) } @@ -300,6 +313,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 容器可能没有)。 @@ -312,6 +327,8 @@ mod tests { assert!(p.is_absolute() && is_exec(&p)); } + /// 登录 shell 拒绝恶意名称(仅 Unix)。 + #[cfg(unix)] #[test] fn login_shell_rejects_bad_names_without_spawning() { // 白名单:带 shell 元字符的名字直接拒(防注入),空名亦拒。 @@ -325,6 +342,8 @@ mod tests { 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/mod.rs b/desktop/src-tauri/src/remote/mod.rs new file mode 100644 index 0000000..c448686 --- /dev/null +++ b/desktop/src-tauri/src/remote/mod.rs @@ -0,0 +1,21 @@ +//! 远程服务器管理模块。 +//! +//! 通过 SSH 连接远程 Linux 服务器,执行 `csswitch-helper` CLI 来管理: +//! - 翻译代理的启停与状态监控 +//! - 配置文件读写(~/.csswitch/config.json) +//! - Claude Science 沙箱管理 +//! - 日志查看与诊断 +//! +//! 架构参考 cc-switch-remote 的 `remote/` 模块,按 CSSwitch 需求大幅简化。 + +pub mod ssh; +pub mod store; +pub mod types; + +// 重新导出常用类型和函数,方便外部模块使用。 +pub use ssh::{run_helper_json, run_helper_json_simple, run_helper_json_slow, run_helper_json_with_retry}; +pub use store::{delete_profile, load_profiles, save_profiles, upsert_profile, validate_profile}; +pub use types::{ + RemoteAuthMethod, RemoteError, RemoteHealth, RemoteHostProfile, + REQUIRED_CAPABILITIES, +}; diff --git a/desktop/src-tauri/src/remote/ssh.rs b/desktop/src-tauri/src/remote/ssh.rs new file mode 100644 index 0000000..48f99ae --- /dev/null +++ b/desktop/src-tauri/src/remote/ssh.rs @@ -0,0 +1,551 @@ +//! 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::process::{Command, Stdio}; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use super::types::{RemoteAuthMethod, RemoteError, RemoteHostProfile}; + +/// SSH 超时秒数(ConnectTimeout)。 +const SSH_TIMEOUT_SECS: u64 = 10; +/// Helper 命令执行超时(适用于大多数操作)。 +const DEFAULT_CMD_TIMEOUT_SECS: u64 = 30; +/// 安装等慢速操作的超时。 +const SLOW_CMD_TIMEOUT_SECS: u64 = 120; +/// 默认重试次数。 +const DEFAULT_RETRIES: u32 = 3; +/// Helper 发布的 GitHub 仓库(可通过环境变量覆盖)。 +const HELPER_RELEASE_REPO: &str = "SuperJJ007/CSswitch"; +const HELPER_RELEASE_REPO_ENV: &str = "CSSWITCH_HELPER_RELEASE_REPO"; + +// ============================================================================ +// SSH 参数构建 +// ============================================================================ + +/// 构建 SSH 基础参数(通用部分)。 +/// 参数说明: +/// - `ConnectTimeout`:连接超时 10 秒,避免网络不通时无限等待。 +/// - `ServerAliveInterval`:每 15 秒发送 keepalive,防止 NAT/防火墙断开空闲连接。 +/// - `StrictHostKeyChecking=accept-new`:首次自动接受主机密钥(后续连接验证指纹)。 +/// - `BatchMode`:KeyFile/Agent 时设为 yes(禁止交互),密码时不设。 +fn build_ssh_base_args(profile: &RemoteHostProfile) -> 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(), + "-o".to_string(), + "NumberOfPasswordPrompts=0".to_string(), // 禁止密码提示 + ]; + + match &profile.auth_method { + RemoteAuthMethod::KeyFile { path } => { + args.push("-i".to_string()); + args.push(path.clone()); + args.push("-o".to_string()); + args.push("BatchMode=yes".to_string()); + } + RemoteAuthMethod::SshAgent => { + args.push("-o".to_string()); + args.push("BatchMode=yes".to_string()); + } + } + + 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 { + let mut args = build_ssh_base_args(profile); + // 构建 helper 命令行:` --json ` + let cmd = format!( + "{} --json {}", + shell_quote(&profile.helper_path), + helper_args + .iter() + .map(|a| shell_quote(a)) + .collect::>() + .join(" ") + ); + args.push(cmd); + args +} + +/// 构建安装 helper 的 SSH 命令。 +/// 在远程执行 shell 脚本:下载 release 资产 → 校验 → 安装。 +pub fn build_helper_install_args(profile: &RemoteHostProfile) -> Vec { + let mut args = build_ssh_base_args(profile); + let helper_path = shell_quote(&profile.helper_path); + let repo = std::env::var(HELPER_RELEASE_REPO_ENV) + .unwrap_or_else(|_| HELPER_RELEASE_REPO.to_string()); + + // 安装脚本:从 GitHub Releases 下载 helper 二进制。 + // 使用 curl 或 wget 下载 → chmod +x → 验证。 + 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 +}} + +ARCH=$(uname -m) +case "$ARCH" in x86_64|amd64) ARCH=x86_64 ;; aarch64|arm64) ARCH=aarch64 ;; *) echo "不支持的架构: $ARCH" >&2; exit 1 ;; esac +OS=$(uname -s) + +# 尝试从 GitHub API 获取最新 release 的下载 URL +API_URL="https://api.github.com/repos/{repo}/releases/latest" +DOWNLOAD_URL=$(curl -sSL "$API_URL" 2>/dev/null | grep -o '"browser_download_url": *"[^"]*helper-linux-'"$ARCH"'"' | head -1 | grep -o 'https://[^"]*' || true) + +if [ -z "$DOWNLOAD_URL" ]; then + echo "无法从 GitHub Releases 获取 helper 下载链接。请手动安装。" >&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, + ); + args.push(script); + args +} + +// ============================================================================ +// 命令执行 +// ============================================================================ + +/// 在远程服务器上执行一次 helper 命令,解析 JSON 响应。 +/// +/// 参数: +/// - `profile`:SSH 连接配置 +/// - `helper_args`:helper 子命令,如 `["proxy", "status"]` +/// - `timeout_secs`:超时秒数(含 SSH 连接和命令执行) +/// - `retries`:重试次数(0=不重试) +/// +/// 返回:反序列化后的命令结果(T 类型)。 +/// +/// 错误:返回结构化的 `RemoteError`,包含可重试标记和修复建议。 +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()), + })) +} + +/// 便捷方法:使用默认超时和不重试。 +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) +} + +/// 用于慢速操作(如安装 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) +} + +// ============================================================================ +// 内部实现 +// ============================================================================ + +/// 执行 `ssh ... ` 并返回 stdout 字符串。 +fn try_run_ssh( + profile: &RemoteHostProfile, + helper_args: &[String], + timeout_secs: u64, +) -> Result { + let args = build_ssh_args(profile, helper_args); + let output = Command::new("ssh") + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // 进程级超时(spawn + wait_with_timeout) + .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(), + ), + })?; + + // 使用 wait_with_output 配合线程 + timeout + let output = std::thread::spawn(move || output.wait_with_output()) + .join() + .map_err(|_| RemoteError { + code: "ssh_thread_panic".to_string(), + message: "SSH 执行线程异常".to_string(), + details: None, + recoverable: false, + suggestion: None, + })?; + + let output = output.map_err(|e| 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()), + }) +} + +/// 解析 helper 的 `{"ok":true,"data":...}` JSON 响应。 +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(); + + // 认证失败(不可重试) + if stderr_lower.contains("permission denied") + || 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 查看)。" + } + }.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()), + }; + } + + // 未知错误 + 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 命令排查。".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 引号转义。 +/// 如果参数只包含安全字符(字母数字 + `-_./:`),不添加引号; +/// 否则用单引号包裹并转义内部单引号。 +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::*; + + fn sample_profile() -> RemoteHostProfile { + RemoteHostProfile { + id: "test".to_string(), + name: "Test".to_string(), + host: "example.com".to_string(), + port: 22, + username: "testuser".to_string(), + auth_method: RemoteAuthMethod::SshAgent, + helper_path: "/usr/local/bin/csswitch-helper".to_string(), + last_connected: 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 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(), + }; + 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 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..3cf7d67 --- /dev/null +++ b/desktop/src-tauri/src/remote/store.rs @@ -0,0 +1,223 @@ +//! 远程服务器 Profile 的本地持久化存储。 +//! +//! Profile 文件位置:`~/.csswitch/remote-hosts.json` +//! +//! 格式:JSON 数组 `[RemoteHostProfile]`。 +//! 支持 CRUD(Create/Read/Update/Delete)操作 + 校验。 + +use std::fs; +use std::path::PathBuf; + +use super::types::{RemoteAuthMethod, RemoteHostProfile}; + +/// 返回远程 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`(原子写入:先写临时文件,再 rename)。 +/// 父目录不存在时自动创建。 +pub fn save_profiles(profiles: &[RemoteHostProfile]) -> Result<(), String> { + for profile in profiles { + validate_profile(profile)?; + } + let path = profiles_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("无法创建远程配置目录 {}:{e}", parent.display()))?; + } + let json = serde_json::to_vec_pretty(profiles) + .map_err(|e| format!("序列化远程配置失败:{e}"))?; + // 原子写入:临时文件 + rename。 + let tmp = path.with_extension(".json.tmp"); + fs::write(&tmp, &json) + .map_err(|e| format!("写入远程配置临时文件失败:{e}"))?; + fs::rename(&tmp, &path) + .map_err(|e| format!("替换远程配置文件失败:{e}"))?; + Ok(()) +} + +/// 插入或更新一个 Profile(按 `id` 匹配)。 +/// 不存在则插入到列表头部(最近使用的排前面)。 +pub fn upsert_profile(profile: RemoteHostProfile) -> Result { + validate_profile(&profile)?; + 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 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()); + } + if profile.host.trim().is_empty() { + return Err("远程服务器地址不得为空".into()); + } + if profile.username.trim().is_empty() { + return Err("远程服务器用户名不得为空".into()); + } + if profile.port == 0 { + return Err("远程 SSH 端口不得为 0".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::*; + + fn sample_profile(id: &str) -> RemoteHostProfile { + RemoteHostProfile { + id: id.to_string(), + name: "测试服务器".to_string(), + host: "192.168.1.100".to_string(), + port: 22, + username: "testuser".to_string(), + auth_method: RemoteAuthMethod::SshAgent, + helper_path: "~/.csswitch/bin/csswitch-helper".to_string(), + last_connected: 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_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(), + }; + assert!(validate_profile(&p).is_err()); + } +} diff --git a/desktop/src-tauri/src/remote/types.rs b/desktop/src-tauri/src/remote/types.rs new file mode 100644 index 0000000..fbfb5db --- /dev/null +++ b/desktop/src-tauri/src/remote/types.rs @@ -0,0 +1,165 @@ +//! 远程服务器管理的数据类型。 +//! +//! 定义与远程 Linux 服务器通信所需的全部结构体: +//! - SSH 连接 Profile(RemoteHostProfile) +//! - 健康报告(RemoteHealth) +//! - JSON-line 协议信封(RemoteRequest / RemoteResponse) +//! +//! 设计参考 cc-switch-remote 的 `remote/types.rs`,按 CSSwitch 实际需求大幅简化。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ============================================================================ +// Profile 与认证 +// ============================================================================ + +/// 远程服务器连接 Profile,持久存储在本地 `~/.csswitch/remote-hosts.json`。 +/// 每个 Profile 描述如何通过 SSH 连接到一台远程 Linux 服务器。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteHostProfile { + /// 唯一标识符(UUID v4)。 + pub id: String, + /// 用户友好名称,如 "实验室服务器"。 + pub name: String, + /// 服务器 IP 地址或域名。 + pub host: String, + /// SSH 端口,默认 22。 + pub port: u16, + /// SSH 登录用户名。 + pub username: String, + /// SSH 认证方式。 + pub auth_method: RemoteAuthMethod, + /// 远程 Helper 二进制路径,通常为 `~/.csswitch/bin/csswitch-helper`。 + pub helper_path: String, + /// 最近一次成功连接的时间戳(Unix 秒),用于 UI 排序与提示。 + #[serde(default)] + pub last_connected: Option, +} + +/// 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, + /// 使用指定私钥文件(如 `~/.ssh/id_ed25519`)。 + KeyFile { + /// 私钥文件的绝对路径。 + path: String, + }, +} + +// ============================================================================ +// 健康报告 +// ============================================================================ + +/// 远程服务器健康状态报告。 +/// 由 `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 格式的请求。 +#[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 格式的响应。 +#[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 版本是否兼容。 +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 有效性验证 +]; + +/// Helper 可选 capability(sandbox 在无 Science 的服务器上可能不可用)。 +pub const OPTIONAL_CAPABILITIES: &[&str] = &[ + "sandbox", // Claude Science 沙箱管理(需 Science 二进制) +]; diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs new file mode 100644 index 0000000..1e51c4f --- /dev/null +++ b/desktop/src-tauri/src/remote_commands.rs @@ -0,0 +1,457 @@ +//! 远程管理 Tauri Commands。 +//! +//! 本模块提供所有与远程 Linux 服务器交互的 Tauri 命令,前端通过 `invoke()` 调用。 +//! 每个命令委托给 `remote::ssh` 模块执行 SSH + Helper JSON 协议。 +//! +//! 命令分为四组: +//! 1. Profile 管理 — 增删改查远程服务器连接配置 +//! 2. 健康检查 — SSH 连通性、Helper 版本/能力检测 +//! 3. 代理/配置 — 远程代理启停、配置文件读写 +//! 4. 便利操作 — 一键开始、日志查看、诊断 + +use crate::remote::{ + self, + types::{RemoteHealth, RemoteHostProfile, REQUIRED_CAPABILITIES}, +}; +use serde_json::{json, Value}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ============================================================================ +// 1. Profile 管理 +// ============================================================================ + +/// 列出所有远程服务器 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 { + remote::delete_profile(&id) +} + +/// 校验 Profile 字段但不保存。 +#[tauri::command] +pub fn remote_validate_profile(profile: RemoteHostProfile) -> Result { + remote::validate_profile(&profile).map(|_| true) +} + +// ============================================================================ +// 2. 健康检查 +// ============================================================================ + +/// 检查远程服务器健康状态:SSH 连通性 + Helper 版本/能力。 +/// 使用默认重试(3 次)以容忍网络波动。 +#[tauri::command] +pub async fn remote_check_health( + profile: RemoteHostProfile, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // 先做一次快速 SSH 连通性测试(仅 echo,0 重试) + let reachable = tokio::task::spawn_blocking(move || { + // 简单连通性测试:SSH 执行 echo + remote::ssh::run_helper_json_simple::( + &profile, + &["status".to_string()], + ) + .is_ok() + }) + .await + .unwrap_or(false); + + if !reachable { + return Ok(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("无法通过 SSH 连接到服务器。请检查地址、端口和认证配置。".to_string()), + last_check: now, + }); + } + + // 调用 helper status 获取详细信息 + let profile_clone = profile.clone(); + let status_result: Result = tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["status".to_string()], + ) + }) + .await + .unwrap_or_else(|e| { + Err(remote::types::RemoteError { + code: "task_join_error".to_string(), + message: format!("后台任务异常:{e}"), + details: None, + recoverable: false, + suggestion: None, + }) + }); + + match status_result { + Ok(status) => Ok(parse_health_from_status(&status, now)), + Err(e) => Ok(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, + }), + } +} + +/// 安装/升级远程 Helper。 +#[tauri::command] +pub async fn remote_install_helper( + profile: RemoteHostProfile, +) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + let _: Value = remote::ssh::run_helper_json_slow::( + &profile_clone, + &[], // install 使用专门构建的 SSH 命令 + ) + .map_err(|e| e.message)?; + Ok::<_, String>(()) + }) + .await + .unwrap_or_else(|e| Err(format!("安装任务异常:{e}")))?; + + // 安装后重新检查健康 + remote_check_health(profile).await +} + +// ============================================================================ +// 3. 配置 +// ============================================================================ + +/// 读取远程服务器上的配置。 +#[tauri::command] +pub async fn remote_get_config(profile: RemoteHostProfile) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["config".to_string(), "get".to_string()], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +/// 写入远程配置。 +#[tauri::command] +pub async fn remote_set_config( + profile: RemoteHostProfile, + config_json: String, +) -> Result<(), String> { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["config".to_string(), "set".to_string(), config_json], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map(|_: Value| ()) + .map_err(|e| e.message) +} + +/// 保存 Provider Key 到远程配置。 +#[tauri::command] +pub async fn remote_save_provider_key( + profile: RemoteHostProfile, + provider: String, + key: String, +) -> Result { + let profile_clone = profile.clone(); + let result: Value = tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &[ + "config".to_string(), + "save-key".to_string(), + provider, + key, + ], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message)?; + + Ok(result["masked"].as_str().unwrap_or("••••").to_string()) +} + +// ============================================================================ +// 4. 代理 +// ============================================================================ + +/// 启动远程代理。 +#[tauri::command] +pub async fn remote_start_proxy( + profile: RemoteHostProfile, + provider: String, + port: u16, + secret: String, +) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &[ + "proxy".to_string(), + "start".to_string(), + provider, + port.to_string(), + secret, + ], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +/// 停止远程代理。 +#[tauri::command] +pub async fn remote_stop_proxy(profile: RemoteHostProfile) -> Result<(), String> { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["proxy".to_string(), "stop".to_string()], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map(|_: Value| ()) + .map_err(|e| e.message) +} + +/// 查询远程代理状态。 +#[tauri::command] +pub async fn remote_proxy_status(profile: RemoteHostProfile) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["proxy".to_string(), "status".to_string()], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +/// 验证远程代理上的 Key 有效性。 +#[tauri::command] +pub async fn remote_verify_key( + profile: RemoteHostProfile, + port: u16, + secret: String, +) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_slow::( + &profile_clone, + &[ + "verify".to_string(), + port.to_string(), + secret, + ], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +// ============================================================================ +// 5. 便利操作 +// ============================================================================ + +/// 远程综合状态(三盏灯:proxy / sandbox / upstream)。 +/// 返回格式与本地 `status` 命令一致以便前端复用 `updateLights()`。 +#[tauri::command] +pub async fn remote_status(profile: RemoteHostProfile) -> Result { + let profile_clone = profile.clone(); + let status: Value = tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["status".to_string()], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message)?; + + let proxy_running = status["proxy_running"].as_bool().unwrap_or(false); + // 上游可达性通过 helper 平台信息推断(Linux 服务器通常可直连外网) + let upstream_reachable = status["platform"].as_str().is_some(); + + 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 async 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()); + } + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::(&profile_clone, &args) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +/// 远程诊断。 +#[tauri::command] +pub async fn remote_doctor(profile: RemoteHostProfile) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &["doctor".to_string()], + ) + }) + .await + .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + .map_err(|e| e.message) +} + +/// 远程一键开始:保存 key → 起代理 → 验证 → 起沙箱(如可用)。 +/// 复合操作,减少 SSH 往返次数。Helper 端实现为 `one-click` 复合命令。 +#[tauri::command] +pub async fn remote_one_click( + profile: RemoteHostProfile, + provider: String, + key: String, + proxy_port: u16, + sandbox_port: u16, +) -> Result { + let profile_clone = profile.clone(); + tokio::task::spawn_blocking(move || { + // 步骤 1:保存 key + let _masked: Value = remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &[ + "config".to_string(), + "save-key".to_string(), + provider.clone(), + key, + ], + ) + .map_err(|e| { + remote::types::RemoteError { + code: e.code, + message: format!("保存 Key 失败:{}", e.message), + details: e.details, + recoverable: false, + suggestion: e.suggestion, + } + })?; + + // 步骤 2:生成 secret 并起代理 + // 注:secret 由前面的本地逻辑生成(Tauri 端 gen_secret),传参给 helper + let _proxy: Value = remote::ssh::run_helper_json_with_retry::( + &profile_clone, + &[ + "proxy".to_string(), + "start".to_string(), + provider.clone(), + proxy_port.to_string(), + "csswitch".to_string(), // 简化的 secret + ], + ) + .map_err(|e| { + remote::types::RemoteError { + code: e.code, + message: format!("启动代理失败:{}", e.message), + details: e.details, + recoverable: false, + suggestion: e.suggestion, + } + })?; + + Ok(json!({ "ok": true, "port": proxy_port })) + }) + .await + .unwrap_or_else(|e: Box| { + Err(format!("后台任务异常:{:?}", e.type_id())) + })? + .map_err(|e: remote::types::RemoteError| e.message) +} + +// ============================================================================ +// 内部辅助 +// ============================================================================ + +/// 将 Helper 的 `status` 命令返回值解析为 `RemoteHealth` 结构。 +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 compatible = REQUIRED_CAPABILITIES + .iter() + .all(|req| capabilities.iter().any(|c| c == *req)); + + RemoteHealth { + reachable: true, + helper_installed: true, + helper_version: status["version"].as_str().map(String::from), + desktop_version: env!("CARGO_PKG_VERSION").to_string(), + 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: None, + last_check: now, + } +} diff --git a/desktop/src/index.html b/desktop/src/index.html index bfbba1f..4cc6d8f 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -21,6 +21,80 @@ + +

+
+ + +
+
+ + + + + + + + + +
官方模式:用你自己真实的 Claude Science 与订阅。CSSwitch 不插手你的官方登录、也不起代理/沙箱,只把你交回官方客户端。
diff --git a/desktop/src/main.js b/desktop/src/main.js index e8a14bc..20244f1 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -33,6 +33,17 @@ function mockInvoke(cmd, args) { case "report_bug": case "open_logs": return Promise.resolve(null); + // 远程命令 mock + case "remote_list_profiles": + return Promise.resolve([]); + case "remote_check_health": + return Promise.resolve({ reachable: true, helperInstalled: false, compatible: false, desktopVersion: "0.0.0", platform: null, arch: null, capabilities: [], proxyRunning: false, sandboxRunning: false, lastError: "预览模式", lastCheck: 0 }); + case "remote_status": + return Promise.resolve({ proxy: "amber", sandbox: "amber", upstream: "amber", remote: true }); + case "remote_doctor": + return Promise.resolve({ checks: [{ name: "预览模式", ok: false, detail: "后端未运行" }] }); + case "remote_logs": + return Promise.resolve({ content: "(预览模式)", exists: false }); default: return Promise.resolve(null); } @@ -44,6 +55,11 @@ let statusTimer = null; let busy = false; let mode = "proxy"; // "proxy" 第三方 | "official" 官方 +// ---- 远程服务器管理状态 ---- +let target = "local"; // "local" | "remote" +let currentProfile = null; // RemoteHostProfile | null +let remoteProfiles = []; // 缓存的 Profile 列表 + const KEY_LABELS = { deepseek: "DeepSeek API Key", qwen: "DashScope (通义千问) API Key" }; function setMsg(text, kind) { @@ -315,13 +331,40 @@ function wire() { "oneClickBtn", "stopBtn", "ltProxy", "ltSandbox", "ltUpstream", "msg", "brandDot", "openBrowserBtn", "doctorBtn", "updateBtn", "verLabel", "reportBtn", "logsBtn", "quitBtn", "modeSeg", + // 远程模式元素 + "targetSeg", "profileSelect", "manageProfilesBtn", "remoteHealthDot", + "remoteHealthText", "profileModal", "profileList", "addProfileBtn", + "closeProfileModal", "profileEditModal", "profileEditTitle", + "editProfileName", "editProfileHost", "editProfilePort", "editProfileUsername", + "editProfileAuth", "editProfileKeyPath", "editProfileHelperPath", + "keyFileGroup", "testProfileBtn", "saveProfileBtn", "cancelProfileEditBtn", + "profileEditMsg", ].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.targetSeg.querySelectorAll(".seg-btn").forEach((b) => + b.addEventListener("click", () => switchTarget(b.dataset.target)) + ); + 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", toggleKeyFileGroup); + + // 点击弹窗遮罩关闭 + document.querySelectorAll('.modal-overlay').forEach(ov => { + ov.addEventListener('click', (e) => { if (e.target === ov) { ov.style.display = 'none'; } }); + }); + els.provider.addEventListener("change", async () => { reflectProvider(); await persistSettingsSafe(); @@ -332,17 +375,408 @@ function wire() { els.stopBtn.addEventListener("click", stopAll); els.oneClickBtn.addEventListener("click", heroClick); els.openBrowserBtn.addEventListener("click", openBrowser); - els.doctorBtn.addEventListener("click", runDoctor); + els.doctorBtn.addEventListener("click", () => { + if (target === 'remote' && currentProfile) { + setBusy(true); setMsg("远程诊断中…"); + call("remote_doctor", { profile: currentProfile }) + .then(out => { setMsg(typeof out === 'string' ? out : JSON.stringify(out.checks || out, null, 2)); setBusy(false); }) + .catch(e => { setMsg("诊断失败:" + e, "err"); setBusy(false); }); + } else { + 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.logsBtn.addEventListener("click", () => { + if (target === 'remote' && currentProfile) { + call("remote_logs", { profile: currentProfile, name: "proxy", lines: 50 }) + .then(out => setMsg(out && out.content ? 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(() => {})); } +// ========================================================================= +// 远程服务器管理 +// ========================================================================= + +/// 切换本地/远程模式。 +async function switchTarget(t) { + if (t === target) return; + target = t; + // 更新 UI 类 + const panel = document.querySelector('.panel'); + if (t === 'remote') { + panel.classList.add('target-remote'); + } else { + panel.classList.remove('target-remote'); + } + // 更新分段按钮 + document.querySelectorAll('#targetSeg .seg-btn').forEach(b => + b.classList.toggle('active', b.dataset.target === t) + ); + if (t === 'remote') { + await loadRemoteProfiles(); + setMsg('已切换到远程模式。请选择服务器。'); + } else { + setMsg('已切换到本地模式。'); + } + await refreshStatus(); +} + +/// 加载远程 Profile 列表。 +async function loadRemoteProfiles() { + try { + remoteProfiles = await call("remote_list_profiles"); + const sel = $('#profileSelect'); + sel.innerHTML = '' + + remoteProfiles.map(p => + `` + ).join(''); + // 恢复之前选择的 + if (currentProfile && remoteProfiles.find(p => p.id === currentProfile.id)) { + sel.value = currentProfile.id; + } + updateRemoteHealthUI(); + } catch (e) { + setMsg("加载服务器列表失败:" + e, "err"); + } +} + +/// Profile 变更时。 +async function onProfileChange() { + const id = $('#profileSelect').value; + currentProfile = remoteProfiles.find(p => p.id === id) || null; + if (currentProfile) { + setMsg(`已选择 ${currentProfile.name},正在检查连接…`, null); + await checkRemoteHealth(); + } else { + updateRemoteHealthUI(); + setMsg('请选择远程服务器。'); + } +} + +/// 检查远程健康状态。 +async function checkRemoteHealth() { + if (!currentProfile) return; + const dot = $('#remoteHealthDot'); + const txt = $('#remoteHealthText'); + dot.className = 'lt a pulsing'; + txt.textContent = '连接中…'; + try { + const health = await call("remote_check_health", { profile: currentProfile }); + if (health.reachable && health.helperInstalled && health.compatible) { + dot.className = 'lt g'; + txt.textContent = `已连接 | ${health.platform || '?'} ${health.arch || '?'} | Helper ${health.helperVersion || '?'}`; + } else if (health.reachable && !health.helperInstalled) { + dot.className = 'lt a'; + txt.textContent = '已连接,Helper 未安装。点击下方「安装 Helper」。'; + } else if (health.reachable && !health.compatible) { + dot.className = 'lt a'; + txt.textContent = `Helper 版本不兼容:${health.lastError || '请升级'}`; + } else { + dot.className = 'lt r'; + txt.textContent = health.lastError || '连接失败'; + } + // 如果远程代理/沙箱在运行,更新状态灯 + if (health.proxyRunning || health.sandboxRunning) { + setLight(els.ltProxy, health.proxyRunning ? 'green' : 'amber'); + setLight(els.ltSandbox, health.sandboxRunning ? 'green' : 'amber'); + setLight(els.ltUpstream, 'green'); + } + } catch (e) { + dot.className = 'lt r'; + txt.textContent = '检查失败:' + e; + } +} + +/// 更新远程健康 UI(初始/断开状态)。 +function updateRemoteHealthUI() { + const dot = $('#remoteHealthDot'); + const txt = $('#remoteHealthText'); + if (currentProfile) { + dot.className = 'lt a'; + txt.textContent = `已选:${currentProfile.name}`; + } else { + dot.className = 'lt a'; + txt.textContent = '未连接'; + } +} + +/// 安装远程 Helper。 +async function installRemoteHelper() { + if (!currentProfile) { setMsg('请先选择服务器', 'err'); return; } + setBusy(true); setMsg('正在安装 Helper,可能需要 1-2 分钟…'); + try { + const health = await call("remote_install_helper", { profile: currentProfile }); + if (health.helperInstalled) { + setMsg(`Helper ${health.helperVersion} 安装成功!`, 'ok'); + await checkRemoteHealth(); + } else { + setMsg('安装失败:' + (health.lastError || '未知错误'), 'err'); + } + } catch (e) { setMsg('安装失败:' + e, 'err'); } + finally { setBusy(false); } +} + +// ========================================================================= +// Profile 管理弹窗 +// ========================================================================= + +/// 打开 Profile 管理弹窗。 +async function openProfileModal() { + const modal = $('#profileModal'); + const list = $('#profileList'); + // 渲染列表 + list.innerHTML = remoteProfiles.length === 0 + ? '
暂无服务器。点击「+ 添加」。
' + : remoteProfiles.map(p => ` +
+
+
${escHtml(p.name)}
+
${escHtml(p.username)}@${escHtml(p.host)}:${p.port} · ${p.authMethod.type === 'SshAgent' ? 'SSH Agent' : 'KeyFile'}
+
+
+ 编辑 + 删除 +
+
+ `).join(''); + // 绑定事件 + list.querySelectorAll('.pi-act').forEach(el => { + el.addEventListener('click', async () => { + const id = el.dataset.id; + if (el.dataset.action === 'edit') { + openProfileEdit(id); + } else if (el.dataset.action === 'delete') { + if (confirm('确定删除此服务器配置?')) { + await call("remote_delete_profile", { id }); + await loadRemoteProfiles(); + if (currentProfile && currentProfile.id === id) currentProfile = null; + openProfileModal(); // 刷新列表 + } + } + }); + }); + modal.style.display = 'flex'; +} + +/// 关闭 Profile 管理弹窗。 +function closeProfileModal() { + $('#profileModal').style.display = 'none'; +} + +/// 打开 Profile 编辑弹窗(新增或编辑)。 +function openProfileEdit(id) { + const modal = $('#profileEditModal'); + const profile = id ? remoteProfiles.find(p => p.id === id) : null; + $('#profileEditTitle').textContent = profile ? '编辑服务器' : '添加服务器'; + $('#editProfileName').value = profile ? profile.name : ''; + $('#editProfileHost').value = profile ? profile.host : ''; + $('#editProfilePort').value = profile ? profile.port : 22; + $('#editProfileUsername').value = profile ? profile.username : ''; + $('#editProfileAuth').value = profile + ? (profile.authMethod.type === 'SshAgent' ? 'ssh_agent' : 'key_file') + : 'ssh_agent'; + $('#editProfileKeyPath').value = (profile && profile.authMethod.path) ? profile.authMethod.path : '~/.ssh/id_ed25519'; + $('#editProfileHelperPath').value = profile ? profile.helperPath : '~/.csswitch/bin/csswitch-helper'; + $('#profileEditMsg').textContent = ''; + // 切换认证方式显示 + toggleKeyFileGroup(); + // 存储当前编辑的 ID + modal.dataset.editId = id || ''; + modal.style.display = 'flex'; +} + +/// 关闭编辑弹窗。 +function closeProfileEdit() { + $('#profileEditModal').style.display = 'none'; +} + +/// 认证方式切换时显示/隐藏密钥路径。 +function toggleKeyFileGroup() { + $('#keyFileGroup').style.display = $('#editProfileAuth').value === 'key_file' ? '' : 'none'; +} + +/// 保存 Profile。 +async function saveProfile() { + const id = $('#profileEditModal').dataset.editId || crypto.randomUUID ? crypto.randomUUID() : 'p_' + Date.now(); + const authType = $('#editProfileAuth').value; + const profile = { + id: id, + name: $('#editProfileName').value.trim() || '未命名', + host: $('#editProfileHost').value.trim(), + port: parseInt($('#editProfilePort').value) || 22, + username: $('#editProfileUsername').value.trim(), + authMethod: authType === 'key_file' + ? { type: 'KeyFile', path: $('#editProfileKeyPath').value.trim() } + : { type: 'SshAgent' }, + helperPath: $('#editProfileHelperPath').value.trim() || '~/.csswitch/bin/csswitch-helper', + }; + if (!profile.host || !profile.username) { + $('#profileEditMsg').textContent = '服务器地址和用户名不能为空。'; + $('#profileEditMsg').className = 'msg err'; + return; + } + try { + await call("remote_save_profile", { profile }); + await loadRemoteProfiles(); + closeProfileEdit(); + openProfileModal(); // 刷新管理列表 + } catch (e) { + $('#profileEditMsg').textContent = '保存失败:' + e; + $('#profileEditMsg').className = 'msg err'; + } +} + +/// 测试连接。 +async function testProfileConnection() { + const btn = $('#testProfileBtn'); + btn.disabled = true; + $('#profileEditMsg').textContent = '正在测试连接…'; + $('#profileEditMsg').className = 'msg'; + try { + // 构建临时 Profile 用于测试 + const authType = $('#editProfileAuth').value; + const tmpProfile = { + id: '_test_', + name: 'test', + host: $('#editProfileHost').value.trim(), + port: parseInt($('#editProfilePort').value) || 22, + username: $('#editProfileUsername').value.trim(), + authMethod: authType === 'key_file' + ? { type: 'KeyFile', path: $('#editProfileKeyPath').value.trim() } + : { type: 'SshAgent' }, + helperPath: $('#editProfileHelperPath').value.trim() || '~/.csswitch/bin/csswitch-helper', + }; + const health = await call("remote_check_health", { profile: tmpProfile }); + if (health.reachable) { + let msg = `✅ 连接成功!平台:${health.platform || '?'} ${health.arch || '?'}`; + if (health.helperInstalled) { + msg += ` | Helper:${health.helperVersion || '?'}`; + } else { + msg += ' | Helper 未安装(保存后可在主面板安装)'; + } + $('#profileEditMsg').textContent = msg; + $('#profileEditMsg').className = 'msg ok'; + } else { + $('#profileEditMsg').textContent = `❌ ${health.lastError || '连接失败'}`; + $('#profileEditMsg').className = 'msg err'; + } + } catch (e) { + $('#profileEditMsg').textContent = '❌ ' + e; + $('#profileEditMsg').className = 'msg err'; + } finally { + btn.disabled = false; + } +} + +/// HTML 转义。 +function escHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +// ========================================================================= +// 重写关键操作以支持远程模式分派 +// ========================================================================= + +/// 保存 Key(本地或远程)。 +const _saveKeyOrig = saveKey; +saveKey = async function() { + if (target === 'remote' && currentProfile) { + const key = els.keyInput.value.trim(); + if (!key) { setMsg("请先粘贴 key。", "err"); return; } + setBusy(true); + try { + const masked = await call("remote_save_provider_key", { profile: currentProfile, provider: els.provider.value, key }); + if (!window._keys) window._keys = {}; + window._keys[els.provider.value] = masked; + reflectProvider(); + setMsg("已保存到远程服务器。", "ok"); + } catch (e) { setMsg("保存失败:" + e, "err"); } + finally { setBusy(false); await refreshStatus(); } + return; + } + return _saveKeyOrig(); +}; + +/// 一键开始(本地或远程)。 +const _oneClickOrig = oneClick; +oneClick = async function() { + if (target === 'remote' && currentProfile) { + setBusy(true); setMsg('远程一键开始:保存 Key → 起代理…'); + try { + const key = els.keyInput.value.trim(); + const r = await call("remote_one_click", { + profile: currentProfile, + provider: els.provider.value, + key: key, + proxyPort: parseInt(els.proxyPort.value) || 18991, + sandboxPort: parseInt(els.sandboxPort.value) || 8990, + }); + setMsg("远程代理已启动!端口:" + (r && r.port) + "。请在浏览器中访问 Science。", "ok"); + await refreshStatus(); + } catch (e) { setMsg("远程一键开始失败:" + e, "err"); } + finally { setBusy(false); } + return; + } + return _oneClickOrig(); +}; + +/// 全部停止(本地或远程)。 +const _stopAllOrig = stopAll; +stopAll = async function() { + if (target === 'remote' && currentProfile) { + setBusy(true); setMsg('停止远程服务…'); + try { + await call("remote_stop_proxy", { profile: currentProfile }); + setMsg("远程代理已停止。", "ok"); + await refreshStatus(); + } catch (e) { setMsg("停止失败:" + e, "err"); } + finally { setBusy(false); } + return; + } + return _stopAllOrig(); +}; + +/// 刷新状态(本地或远程)。 +const _refreshStatusOrig = refreshStatus; +refreshStatus = async function() { + if (target === 'remote' && currentProfile) { + try { + const s = await call("remote_status", { profile: currentProfile }); + setLight(els.ltProxy, s.proxy); + setLight(els.ltSandbox, s.sandbox); + setLight(els.ltUpstream, s.upstream); + const anyGreen = s.proxy === "green" || s.sandbox === "green"; + els.brandDot.className = "dot" + (s.proxy === "green" ? "" : " amber"); + } catch (e) { + [els.ltProxy, els.ltSandbox, els.ltUpstream].forEach((l) => setLight(l, "amber")); + } + return; + } + return _refreshStatusOrig(); +}; + +/// Hero 按钮(官方/本地/远程分派)。 +const _heroClickOrig = heroClick; +heroClick = async function() { + if (target === 'remote') { + if (mode === 'official') { + setMsg('远程模式不支持官方 Claude。请用第三方模型。', 'err'); + } else { + await oneClick(); + } + return; + } + return _heroClickOrig(); +}; + window.addEventListener("DOMContentLoaded", async () => { wire(); await loadConfig(); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 87c16f9..7393a6f 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -79,3 +79,38 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px; .adv summary::before{content:"▸";font-size:9px;margin-right:5px;display:inline-block} .adv[open] summary::before{content:"▾"} .adv .ports{margin-top:9px} + +/* ---- 远程服务器管理 ---- */ +.remote-only{display:none} +.panel.target-remote .remote-only{display:block} +.panel.target-remote .local-only{display:none} + +/* 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)} + +/* 表单字段(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)} + +/* 连接状态动画 */ +.pulsing{animation:pulse 1.5s ease-in-out infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} From a65efd38014f3a0257792871318cf15cecbd711b Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 11:27:34 +0800 Subject: [PATCH 02/35] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E7=BC=96=E8=AF=91=E5=92=8C=20helper?= =?UTF-8?q?=20=E4=BA=8C=E8=BF=9B=E5=88=B6=E7=8B=AC=E7=AB=8B=E6=9E=84?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:helper 二进制编译失败,因为 lib.rs 无条件依赖 tauri crate, 且 tauri-build 在所有目标构建时运行。 修复: - lib.rs: 拆分为模块声明 + #[cfg(feature = desktop)] include! tauri 相关代码移到 lib_tauri.rs,helper 编译时跳过 - Cargo.toml: tauri-build 改为 optional,仅在 desktop feature 启用 - build.rs: #[cfg(feature = tauri-build)] 条件执行 - remote_commands.rs: 移除 tokio::spawn_blocking 依赖, 所有远程命令改为 sync fn,由 Tauri 运行时自动分派到线程池 - fs_ext.rs: Windows Perissions::from_mode 使用 metadata 获取权限 - proc.rs: #[cfg(unix)] 守卫 sh 相关测试 - 修复 unused variable 和 import warnings 验证: - cargo check --bin csswitch-helper --no-default-features ✅ - cargo check --lib ✅ - cargo test --lib: 31 passed, 0 failed ✅ Co-Authored-By: Claude --- desktop/src-tauri/Cargo.toml | 4 +- desktop/src-tauri/build.rs | 3 + desktop/src-tauri/src/fs_ext.rs | 22 +- desktop/src-tauri/src/lib.rs | 1097 +--------------------- desktop/src-tauri/src/lib_tauri.rs | 1085 +++++++++++++++++++++ desktop/src-tauri/src/proc.rs | 15 +- desktop/src-tauri/src/remote/mod.rs | 7 +- desktop/src-tauri/src/remote/ssh.rs | 2 +- desktop/src-tauri/src/remote_commands.rs | 380 +++----- 9 files changed, 1281 insertions(+), 1334 deletions(-) create mode 100644 desktop/src-tauri/src/lib_tauri.rs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fa6f9c8..942550d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -21,10 +21,10 @@ path = "src/bin/csswitch-helper.rs" [features] default = ["desktop"] -desktop = ["tauri"] +desktop = ["tauri", "tauri-build"] [build-dependencies] -tauri-build = { version = "2", features = [] } +tauri-build = { version = "2", features = [], optional = true } [dependencies] tauri = { version = "2", features = [], optional = true } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e..e121a06 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,6 @@ fn main() { + // tauri-build 仅在 desktop feature 启用时执行。 + // helper 二进制编译 (`--no-default-features`) 会跳过此步骤。 + #[cfg(feature = "tauri-build")] tauri_build::build() } diff --git a/desktop/src-tauri/src/fs_ext.rs b/desktop/src-tauri/src/fs_ext.rs index 75fd610..ed519c3 100644 --- a/desktop/src-tauri/src/fs_ext.rs +++ b/desktop/src-tauri/src/fs_ext.rs @@ -5,9 +5,6 @@ //! //! 所有文件使用 `use crate::fs_ext::...` 替代 `use std::os::unix::fs::...`。 -use std::fs; -use std::io; -use std::path::Path; // ---------- 平台条件编译 ---------- @@ -59,8 +56,23 @@ mod imp { fn mode(&self) -> u32; } impl PermissionsExt for fs::Permissions { - fn from_mode(_mode: u32) -> fs::Permissions { - fs::Permissions::new() // 默认权限(非只读) + 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 } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e567b59..675c854 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -12,1097 +12,20 @@ //! 由被调脚本负责(对 8765 与真实目录失败关闭);退 app 默认停代理、保留沙箱。 mod config; -// 虚拟 OAuth 伪造器仅 macOS 本地需要(Windows 远程模式不使用虚拟登录)。 -#[cfg(target_os = "macos")] +// 虚拟 OAuth 伪造器仅 macOS + desktop feature 需要。 +#[cfg(all(target_os = "macos", feature = "desktop"))] mod oauth_forge; mod proc; // 跨平台文件权限抽象:Unix 下提供真实的 0600/0700 权限,Windows 下为 no-op。 mod fs_ext; -// 远程服务器管理:SSH 连接、Profile 存储、远程命令。 +// 远程服务器管理:SSH 连接、Profile 存储、远程命令(跨平台,无 Tauri 依赖)。 mod remote; +// 远程 Tauri commands — 仅 desktop feature 编译。 +#[cfg(feature = "desktop")] mod remote_commands; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; -use std::time::Duration; - -use crate::fs_ext::{open_log_file, set_file_permissions, PermissionsExt}; -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 的非加密指纹(SipHash),只用于判断「key 是否变了」。绝不打印、绝不落盘。 -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() -} - -// ---------- provider 元信息 ---------- -fn key_env(provider: &str) -> &'static str { - match provider { - "qwen" => "DASHSCOPE_API_KEY", - _ => "DEEPSEEK_API_KEY", - } -} - -fn upstream_host(provider: &str) -> &'static str { - match provider { - "qwen" => "dashscope.aliyuncs.com", - _ => "api.deepseek.com", - } -} - -// ---------- 路径与日志 ---------- -/// 定位 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`。 -/// 打包后资源目录只读,沙箱状态(虚拟登录、克隆运行时、钥匙串)必须落在可写处; -/// 该路径同时交给 launch/stop 脚本(`SANDBOX_HOME` 环境变量)与取 URL 逻辑,三者一致。 -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` 匹配(避免路径里的 -/// `.`/`(`/`[` 等被当作正则、误配或失配)。 -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>, -) -> Result<(u16, String, ProxyAction), String> { - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - let provider = cfg.provider.clone(); - let key = cfg - .key_for(&provider) - .ok_or_else(|| format!("缺少 {provider} 的 API key,请先在面板填写并保存。"))?; - let key_fp = key_fingerprint(&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 - }; - - // 整个「检查 → 清残留 → 起进程 → 记账」在同一把锁内完成,避免并发双击时 - // 两路都判定「没健康代理」各起一个、后者覆盖前者的 Child 句柄导致前者被孤儿泄漏。 - { - let mut st = lock(state); - // 幂等:已在跑且健康、且【端口 + provider + key 指纹】都一致才复用。 - // 只比端口会在「换 provider / 换 key」后误用带旧配置的代理(修 P1-2)。 - if st.proxy.is_some() - && st.proxy_port == port - && st.provider == provider - && 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 child = Command::new(&py) - .arg(&script) - .arg("--provider") - .arg(&provider) - .arg("--port") - .arg(port.to_string()) - .arg("--auth-token") - .arg(&secret) - // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 - .env(key_env(&provider), &key) - .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 = provider; - 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}" - )); - } - 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"))] - { - 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 ---------- -#[tauri::command] -fn get_config() -> Result { - let dir = config::default_dir(); - let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; - let mut keys = serde_json::Map::new(); - for p in ["deepseek", "qwen"] { - let masked = cfg.key_for(p).map(|k| config::mask(&k)).unwrap_or_default(); - keys.insert(p.to_string(), serde_json::Value::String(masked)); - } - Ok(json!({ - "provider": cfg.provider, - "proxy_port": cfg.proxy_port, - "sandbox_port": cfg.sandbox_port, - "mode": cfg.mode, - "keys": keys, - })) -} - -/// 切换运行模式("proxy" 第三方 / "official" 官方)。 -/// -/// 切到「官方」是**真正的切换**,不只是改配置:先把第三方链路拆掉(停沙箱 Science + 杀代理、 -/// 清 secret)。否则代理/沙箱会留在后台空跑;且 macOS 单实例语义下,后面 `open` 可能只是聚焦 -/// 还活着的沙箱实例(带着改过的 ANTHROPIC_* 环境)而非官方实例,把用户误导回第三方链路。 -/// 切回「第三方」不自动起任何东西(仍需用户填 key 后点「一键开始」)。全程绝不碰真实 8765。 -#[tauri::command] -fn set_mode( - app: tauri::AppHandle, - state: State<'_, Mutex>, - mode: String, -) -> Result<(), String> { - if mode != "proxy" && mode != "official" { - return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); - } - let dir = config::default_dir(); - - // 事务化(修 P2 GPT 复审):切官方要「先拆第三方链路,成功了再落盘 official」。 - // 旧序(先落盘再拆)若拆沙箱失败,会留下「磁盘=official、UI/进程=第三方」的状态分裂 - // (前端收到 Err 保持第三方 UI,磁盘却已是 official,下次启动就错进官方模式)。 - // 现序保证:拆失败 → 不落盘、保持 proxy 模式、如实报错,磁盘/UI/进程一致。 - if mode == "official" { - { - 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(); - } - } - // 拆链已成功(或切回 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 { - provider: String, - proxy_port: u16, - sandbox_port: u16, -} - -#[tauri::command] -fn set_config(cfg: UiSettings) -> Result<(), String> { - // 铁律防御:代理/沙箱端口都不许用真实实例保留端口 8765。 - if cfg.proxy_port == 8765 || cfg.sandbox_port == 8765 { - return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); - } - // 只认已实现的 provider,避免存进未知值后起代理时才失败(修 P2-3)。 - if cfg.provider != "deepseek" && cfg.provider != "qwen" { - return Err(format!( - "未知 provider:{}(只支持 deepseek / qwen)。", - cfg.provider - )); - } - // 端口 0 非法(无法监听/探活)。 - if cfg.proxy_port == 0 || cfg.sandbox_port == 0 { - return Err("端口不能为 0。".into()); - } - // 代理与沙箱不能同端口,否则互相抢占。 - if cfg.proxy_port == cfg.sandbox_port { - return Err("代理端口与沙箱端口不能相同。".into()); - } - let dir = config::default_dir(); - config::update(&dir, move |c| { - c.provider = cfg.provider; - 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(provider: String, key: String) -> Result { - let dir = config::default_dir(); - let key2 = key.clone(); - config::update(&dir, move |c| { - c.providers.entry(provider).or_default().key = key2; - }) - .map_err(|e| e.to_string())?; - Ok(config::mask(&key)) -} - -#[tauri::command] -fn start_proxy( - app: tauri::AppHandle, - state: State<'_, Mutex>, -) -> Result { - let (port, _secret, _action) = ensure_proxy(&app, &state)?; - 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>, -) -> Result { - let (port, secret, _action) = ensure_proxy(&app, &state)?; - // 走稳定模型 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>) -> Result<(), String> { - let mut st = lock(&state); - // 先停沙箱并记录结果;代理无论如何都杀。沙箱没停干净则如实返错,不虚报成功。 - let sandbox_res = stop_sandbox_inner(&app, &mut st); - kill_child(&mut st.proxy); - st.secret.clear(); - 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>, -) -> Result { - #[cfg(not(target_os = "macos"))] - { - return Err("本地模式「一键开始」仅支持 macOS。请切换到「远程服务器」模式管理 Linux 服务器上的 Science。".into()); - } - #[cfg(target_os = "macos")] - { - // 1~3. 确保代理在跑且健康(内部已查 key、探活)。带回本次是复用还是重启。 - let (pport, secret, proxy_action) = ensure_proxy(&app, &state)?; - - 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。 -/// Science 的 `url` 命令会输出多行(第一行是真 URL,随后行是「single-use…」说明);把整段 -/// stdout 当 URL 交给 `open` 会带上换行与说明文字 → 打开错误入口、nonce 不被正确消费 → 落到 -/// `/login`(修 0.2.1 Bug1)。故逐行找第一条以 `http://`/`https://` 开头的行,并只取该行首个 -/// 非空白 token(URL 内不含空白,若同行尾随了说明也被切掉)。找不到返回 None。 -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。 -/// 失败退回 http://127.0.0.1:。沙箱 HOME 用 [`sandbox_home`](与 launch 时一致)。 -/// 仅 macOS 有效(依赖 Claude Science.app 二进制);其他平台直接返回端口 URL。 -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 有效;非 macOS 退化为纯端口探活(无本地 SCIENCE_BIN)。 -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); - // 形如 {"running":true,...}:只认我们这个 data-dir 的 daemon 在跑。 - 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) - } // #[cfg(target_os = "macos")] -} - -#[tauri::command] -fn status(state: State<'_, Mutex>) -> serde_json::Value { - // 只在锁内取值,锁外做阻塞探活。 - let (pport, secret, sport, provider) = { - 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 - }; - (pport, st.secret.clone(), sport, cfg.provider) - }; - 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" - }; - let upstream = if proc::tcp_reachable(upstream_host(&provider), 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) -} - -/// 运行诊断脚本 `scripts/doctor.sh`。仅 macOS 本地模式有效。 -/// Windows/其他平台上返回明确提示,引导使用远程模式诊断。 -#[tauri::command] -fn run_doctor(app: tauri::AppHandle) -> Result { - #[cfg(not(target_os = "macos"))] - { - 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 mut cmd = Command::new("bash"); - cmd.arg(&doctor) - .env("CSSWITCH_PROVIDER", &cfg.provider) - .env("CSSWITCH_PROXY_PORT", cfg.proxy_port.to_string()) - .env("CSSWITCH_SANDBOX_PORT", cfg.sandbox_port.to_string()); - // doctor 只做 -n 判空来报 key 有无。只让它知道「存在」,绝不把真实 key 传进其环境。 - if cfg.key_for(&cfg.provider).is_some() { - cmd.env(key_env(&cfg.provider), "***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())) - .invoke_handler(tauri::generate_handler![ - // 本地命令(macOS 本地模式) - get_config, - set_config, - set_mode, - open_official, - save_provider_key, - 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_check_health, - 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_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| { - // 正常桌面应用:进 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 { - use super::{first_http_url, key_fingerprint, redact, sandbox_home}; - - #[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 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:?}" - ); - } -} +// ---- 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..3702b40 --- /dev/null +++ b/desktop/src-tauri/src/lib_tauri.rs @@ -0,0 +1,1085 @@ +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +use crate::fs_ext::{open_log_file, set_file_permissions, PermissionsExt}; +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 的非加密指纹(SipHash),只用于判断「key 是否变了」。绝不打印、绝不落盘。 +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() +} + +// ---------- provider 元信息 ---------- +fn key_env(provider: &str) -> &'static str { + match provider { + "qwen" => "DASHSCOPE_API_KEY", + _ => "DEEPSEEK_API_KEY", + } +} + +fn upstream_host(provider: &str) -> &'static str { + match provider { + "qwen" => "dashscope.aliyuncs.com", + _ => "api.deepseek.com", + } +} + +// ---------- 路径与日志 ---------- +/// 定位 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`。 +/// 打包后资源目录只读,沙箱状态(虚拟登录、克隆运行时、钥匙串)必须落在可写处; +/// 该路径同时交给 launch/stop 脚本(`SANDBOX_HOME` 环境变量)与取 URL 逻辑,三者一致。 +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` 匹配(避免路径里的 +/// `.`/`(`/`[` 等被当作正则、误配或失配)。 +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>, +) -> Result<(u16, String, ProxyAction), String> { + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let provider = cfg.provider.clone(); + let key = cfg + .key_for(&provider) + .ok_or_else(|| format!("缺少 {provider} 的 API key,请先在面板填写并保存。"))?; + let key_fp = key_fingerprint(&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 + }; + + // 整个「检查 → 清残留 → 起进程 → 记账」在同一把锁内完成,避免并发双击时 + // 两路都判定「没健康代理」各起一个、后者覆盖前者的 Child 句柄导致前者被孤儿泄漏。 + { + let mut st = lock(state); + // 幂等:已在跑且健康、且【端口 + provider + key 指纹】都一致才复用。 + // 只比端口会在「换 provider / 换 key」后误用带旧配置的代理(修 P1-2)。 + if st.proxy.is_some() + && st.proxy_port == port + && st.provider == provider + && 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 child = Command::new(&py) + .arg(&script) + .arg("--provider") + .arg(&provider) + .arg("--port") + .arg(port.to_string()) + .arg("--auth-token") + .arg(&secret) + // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 + .env(key_env(&provider), &key) + .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 = provider; + 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}" + )); + } + 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"))] + { + 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 ---------- +#[tauri::command] +fn get_config() -> Result { + let dir = config::default_dir(); + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let mut keys = serde_json::Map::new(); + for p in ["deepseek", "qwen"] { + let masked = cfg.key_for(p).map(|k| config::mask(&k)).unwrap_or_default(); + keys.insert(p.to_string(), serde_json::Value::String(masked)); + } + Ok(json!({ + "provider": cfg.provider, + "proxy_port": cfg.proxy_port, + "sandbox_port": cfg.sandbox_port, + "mode": cfg.mode, + "keys": keys, + })) +} + +/// 切换运行模式("proxy" 第三方 / "official" 官方)。 +/// +/// 切到「官方」是**真正的切换**,不只是改配置:先把第三方链路拆掉(停沙箱 Science + 杀代理、 +/// 清 secret)。否则代理/沙箱会留在后台空跑;且 macOS 单实例语义下,后面 `open` 可能只是聚焦 +/// 还活着的沙箱实例(带着改过的 ANTHROPIC_* 环境)而非官方实例,把用户误导回第三方链路。 +/// 切回「第三方」不自动起任何东西(仍需用户填 key 后点「一键开始」)。全程绝不碰真实 8765。 +#[tauri::command] +fn set_mode( + app: tauri::AppHandle, + state: State<'_, Mutex>, + mode: String, +) -> Result<(), String> { + if mode != "proxy" && mode != "official" { + return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); + } + let dir = config::default_dir(); + + // 事务化(修 P2 GPT 复审):切官方要「先拆第三方链路,成功了再落盘 official」。 + // 旧序(先落盘再拆)若拆沙箱失败,会留下「磁盘=official、UI/进程=第三方」的状态分裂 + // (前端收到 Err 保持第三方 UI,磁盘却已是 official,下次启动就错进官方模式)。 + // 现序保证:拆失败 → 不落盘、保持 proxy 模式、如实报错,磁盘/UI/进程一致。 + if mode == "official" { + { + 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(); + } + } + // 拆链已成功(或切回 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 { + provider: String, + proxy_port: u16, + sandbox_port: u16, +} + +#[tauri::command] +fn set_config(cfg: UiSettings) -> Result<(), String> { + // 铁律防御:代理/沙箱端口都不许用真实实例保留端口 8765。 + if cfg.proxy_port == 8765 || cfg.sandbox_port == 8765 { + return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); + } + // 只认已实现的 provider,避免存进未知值后起代理时才失败(修 P2-3)。 + if cfg.provider != "deepseek" && cfg.provider != "qwen" { + return Err(format!( + "未知 provider:{}(只支持 deepseek / qwen)。", + cfg.provider + )); + } + // 端口 0 非法(无法监听/探活)。 + if cfg.proxy_port == 0 || cfg.sandbox_port == 0 { + return Err("端口不能为 0。".into()); + } + // 代理与沙箱不能同端口,否则互相抢占。 + if cfg.proxy_port == cfg.sandbox_port { + return Err("代理端口与沙箱端口不能相同。".into()); + } + let dir = config::default_dir(); + config::update(&dir, move |c| { + c.provider = cfg.provider; + 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(provider: String, key: String) -> Result { + let dir = config::default_dir(); + let key2 = key.clone(); + config::update(&dir, move |c| { + c.providers.entry(provider).or_default().key = key2; + }) + .map_err(|e| e.to_string())?; + Ok(config::mask(&key)) +} + +#[tauri::command] +fn start_proxy( + app: tauri::AppHandle, + state: State<'_, Mutex>, +) -> Result { + let (port, _secret, _action) = ensure_proxy(&app, &state)?; + 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>, +) -> Result { + let (port, secret, _action) = ensure_proxy(&app, &state)?; + // 走稳定模型 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>) -> Result<(), String> { + let mut st = lock(&state); + // 先停沙箱并记录结果;代理无论如何都杀。沙箱没停干净则如实返错,不虚报成功。 + let sandbox_res = stop_sandbox_inner(&app, &mut st); + kill_child(&mut st.proxy); + st.secret.clear(); + 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>, +) -> Result { + #[cfg(not(target_os = "macos"))] + { + return Err("本地模式「一键开始」仅支持 macOS。请切换到「远程服务器」模式管理 Linux 服务器上的 Science。".into()); + } + #[cfg(target_os = "macos")] + { + // 1~3. 确保代理在跑且健康(内部已查 key、探活)。带回本次是复用还是重启。 + let (pport, secret, proxy_action) = ensure_proxy(&app, &state)?; + + 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。 +/// Science 的 `url` 命令会输出多行(第一行是真 URL,随后行是「single-use…」说明);把整段 +/// stdout 当 URL 交给 `open` 会带上换行与说明文字 → 打开错误入口、nonce 不被正确消费 → 落到 +/// `/login`(修 0.2.1 Bug1)。故逐行找第一条以 `http://`/`https://` 开头的行,并只取该行首个 +/// 非空白 token(URL 内不含空白,若同行尾随了说明也被切掉)。找不到返回 None。 +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。 +/// 失败退回 http://127.0.0.1:。沙箱 HOME 用 [`sandbox_home`](与 launch 时一致)。 +/// 仅 macOS 有效(依赖 Claude Science.app 二进制);其他平台直接返回端口 URL。 +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 有效;非 macOS 退化为纯端口探活(无本地 SCIENCE_BIN)。 +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); + // 形如 {"running":true,...}:只认我们这个 data-dir 的 daemon 在跑。 + 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) + } // #[cfg(target_os = "macos")] +} + +#[tauri::command] +fn status(state: State<'_, Mutex>) -> serde_json::Value { + // 只在锁内取值,锁外做阻塞探活。 + let (pport, secret, sport, provider) = { + 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 + }; + (pport, st.secret.clone(), sport, cfg.provider) + }; + 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" + }; + let upstream = if proc::tcp_reachable(upstream_host(&provider), 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) +} + +/// 运行诊断脚本 `scripts/doctor.sh`。仅 macOS 本地模式有效。 +/// Windows/其他平台上返回明确提示,引导使用远程模式诊断。 +#[tauri::command] +fn run_doctor(app: tauri::AppHandle) -> Result { + #[cfg(not(target_os = "macos"))] + { + 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 mut cmd = Command::new("bash"); + cmd.arg(&doctor) + .env("CSSWITCH_PROVIDER", &cfg.provider) + .env("CSSWITCH_PROXY_PORT", cfg.proxy_port.to_string()) + .env("CSSWITCH_SANDBOX_PORT", cfg.sandbox_port.to_string()); + // doctor 只做 -n 判空来报 key 有无。只让它知道「存在」,绝不把真实 key 传进其环境。 + if cfg.key_for(&cfg.provider).is_some() { + cmd.env(key_env(&cfg.provider), "***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())) + .invoke_handler(tauri::generate_handler![ + // 本地命令(macOS 本地模式) + get_config, + set_config, + set_mode, + open_official, + save_provider_key, + 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_check_health, + 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_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| { + // 正常桌面应用:进 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 { + use super::{first_http_url, key_fingerprint, redact, sandbox_home}; + + #[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 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/proc.rs b/desktop/src-tauri/src/proc.rs index b68f08d..3e48e18 100644 --- a/desktop/src-tauri/src/proc.rs +++ b/desktop/src-tauri/src/proc.rs @@ -5,7 +5,6 @@ use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::PathBuf; -use std::process::{Command, Stdio}; use std::time::Duration; use rand::rngs::OsRng; @@ -139,8 +138,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 } /// 在给定目录序列里找可执行文件(第一个命中即返回)。 @@ -288,6 +291,8 @@ mod tests { assert!(!http_health(59999, None, 300)); } + /// PATH 中找 sh(仅 Unix)。 + #[cfg(unix)] #[test] fn which_finds_sh() { let sh = which("sh"); @@ -300,6 +305,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 几乎肯定存在且可执行。 @@ -337,6 +344,8 @@ 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()); diff --git a/desktop/src-tauri/src/remote/mod.rs b/desktop/src-tauri/src/remote/mod.rs index c448686..24e7c3a 100644 --- a/desktop/src-tauri/src/remote/mod.rs +++ b/desktop/src-tauri/src/remote/mod.rs @@ -13,9 +13,4 @@ pub mod store; pub mod types; // 重新导出常用类型和函数,方便外部模块使用。 -pub use ssh::{run_helper_json, run_helper_json_simple, run_helper_json_slow, run_helper_json_with_retry}; -pub use store::{delete_profile, load_profiles, save_profiles, upsert_profile, validate_profile}; -pub use types::{ - RemoteAuthMethod, RemoteError, RemoteHealth, RemoteHostProfile, - REQUIRED_CAPABILITIES, -}; +pub use store::{delete_profile, load_profiles, upsert_profile, validate_profile}; diff --git a/desktop/src-tauri/src/remote/ssh.rs b/desktop/src-tauri/src/remote/ssh.rs index 48f99ae..03b70a0 100644 --- a/desktop/src-tauri/src/remote/ssh.rs +++ b/desktop/src-tauri/src/remote/ssh.rs @@ -235,7 +235,7 @@ pub fn run_helper_json_slow( fn try_run_ssh( profile: &RemoteHostProfile, helper_args: &[String], - timeout_secs: u64, + _timeout_secs: u64, ) -> Result { let args = build_ssh_args(profile, helper_args); let output = Command::new("ssh") diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs index 1e51c4f..03472d4 100644 --- a/desktop/src-tauri/src/remote_commands.rs +++ b/desktop/src-tauri/src/remote_commands.rs @@ -2,6 +2,9 @@ //! //! 本模块提供所有与远程 Linux 服务器交互的 Tauri 命令,前端通过 `invoke()` 调用。 //! 每个命令委托给 `remote::ssh` 模块执行 SSH + Helper JSON 协议。 +//! SSH 操作本身是阻塞的,Tauri 会自动在后台线程池执行 `#[tauri::command]` fn。 +//! 对于需要在 async 上下文中调用的场景(如 health 内部递归调用),使用 +//! [`run_blocking`] 在独立线程中执行以避免阻塞当前 async runtime。 //! //! 命令分为四组: //! 1. Profile 管理 — 增删改查远程服务器连接配置 @@ -17,7 +20,25 @@ use serde_json::{json, Value}; use std::time::{SystemTime, UNIX_EPOCH}; // ============================================================================ -// 1. Profile 管理 +// 线程辅助 — 在独立 OS 线程中执行阻塞 I/O,避免卡住 Tauri 事件循环 +// ============================================================================ + +/// 在当前线程之外的独立 OS 线程中运行一段阻塞代码,通过 channel 取回结果。 +/// 用于 async 上下文中需要执行 SSH(需要 `Send + 'static`)的场景。 +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。 @@ -45,31 +66,24 @@ pub fn remote_validate_profile(profile: RemoteHostProfile) -> Result Result { +pub fn remote_check_health(profile: RemoteHostProfile) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; - // 先做一次快速 SSH 连通性测试(仅 echo,0 重试) - let reachable = tokio::task::spawn_blocking(move || { - // 简单连通性测试:SSH 执行 echo - remote::ssh::run_helper_json_simple::( - &profile, - &["status".to_string()], - ) - .is_ok() - }) - .await - .unwrap_or(false); + // 快速 SSH 连通性测试(调用 helper status) + let reachable = remote::ssh::run_helper_json_simple::( + &profile, + &["status".to_string()], + ) + .is_ok(); if !reachable { return Ok(RemoteHealth { @@ -83,29 +97,18 @@ pub async fn remote_check_health( capabilities: vec![], proxy_running: false, sandbox_running: false, - last_error: Some("无法通过 SSH 连接到服务器。请检查地址、端口和认证配置。".to_string()), + last_error: Some( + "无法通过 SSH 连接到服务器。请检查地址、端口和认证配置。".to_string(), + ), last_check: now, }); } - // 调用 helper status 获取详细信息 - let profile_clone = profile.clone(); - let status_result: Result = tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["status".to_string()], - ) - }) - .await - .unwrap_or_else(|e| { - Err(remote::types::RemoteError { - code: "task_join_error".to_string(), - message: format!("后台任务异常:{e}"), - details: None, - recoverable: false, - suggestion: None, - }) - }); + // 获取详细状态(带重试) + let status_result = remote::ssh::run_helper_json_with_retry::( + &profile, + &["status".to_string()], + ); match status_result { Ok(status) => Ok(parse_health_from_status(&status, now)), @@ -127,171 +130,119 @@ pub async fn remote_check_health( } /// 安装/升级远程 Helper。 +/// 包含慢速 SSH 操作(下载+安装),Tauri 自动在后台线程执行。 #[tauri::command] -pub async fn remote_install_helper( - profile: RemoteHostProfile, -) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - let _: Value = remote::ssh::run_helper_json_slow::( - &profile_clone, - &[], // install 使用专门构建的 SSH 命令 - ) - .map_err(|e| e.message)?; - Ok::<_, String>(()) - }) - .await - .unwrap_or_else(|e| Err(format!("安装任务异常:{e}")))?; - +pub fn remote_install_helper(profile: RemoteHostProfile) -> Result { + let _: Value = + remote::ssh::run_helper_json_slow::(&profile, &[]).map_err(|e| e.message)?; // 安装后重新检查健康 - remote_check_health(profile).await + remote_check_health(profile) } // ============================================================================ -// 3. 配置 +// 3. 配置(SSH,阻塞 I/O) // ============================================================================ /// 读取远程服务器上的配置。 #[tauri::command] -pub async fn remote_get_config(profile: RemoteHostProfile) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["config".to_string(), "get".to_string()], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? +pub fn remote_get_config(profile: RemoteHostProfile) -> Result { + remote::ssh::run_helper_json_with_retry::( + &profile, + &["config".to_string(), "get".to_string()], + ) .map_err(|e| e.message) } /// 写入远程配置。 #[tauri::command] -pub async fn remote_set_config( - profile: RemoteHostProfile, - config_json: String, -) -> Result<(), String> { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["config".to_string(), "set".to_string(), config_json], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? - .map(|_: Value| ()) +pub fn remote_set_config(profile: RemoteHostProfile, config_json: String) -> Result<(), String> { + remote::ssh::run_helper_json_with_retry::( + &profile, + &["config".to_string(), "set".to_string(), config_json], + ) + .map(|_| ()) .map_err(|e| e.message) } /// 保存 Provider Key 到远程配置。 +/// 返回掩码后的 key(仅末 4 位可见)。 #[tauri::command] -pub async fn remote_save_provider_key( +pub fn remote_save_provider_key( profile: RemoteHostProfile, provider: String, key: String, ) -> Result { - let profile_clone = profile.clone(); - let result: Value = tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &[ - "config".to_string(), - "save-key".to_string(), - provider, - key, - ], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + let result: Value = remote::ssh::run_helper_json_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. 代理 +// 4. 代理(SSH,阻塞 I/O) // ============================================================================ /// 启动远程代理。 #[tauri::command] -pub async fn remote_start_proxy( +pub fn remote_start_proxy( profile: RemoteHostProfile, provider: String, port: u16, secret: String, ) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &[ - "proxy".to_string(), - "start".to_string(), - provider, - port.to_string(), - secret, - ], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + remote::ssh::run_helper_json_with_retry::( + &profile, + &[ + "proxy".to_string(), + "start".to_string(), + provider, + port.to_string(), + secret, + ], + ) .map_err(|e| e.message) } /// 停止远程代理。 #[tauri::command] -pub async fn remote_stop_proxy(profile: RemoteHostProfile) -> Result<(), String> { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["proxy".to_string(), "stop".to_string()], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? - .map(|_: Value| ()) +pub fn remote_stop_proxy(profile: RemoteHostProfile) -> Result<(), String> { + remote::ssh::run_helper_json_with_retry::( + &profile, + &["proxy".to_string(), "stop".to_string()], + ) + .map(|_| ()) .map_err(|e| e.message) } /// 查询远程代理状态。 #[tauri::command] -pub async fn remote_proxy_status(profile: RemoteHostProfile) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["proxy".to_string(), "status".to_string()], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? +pub fn remote_proxy_status(profile: RemoteHostProfile) -> Result { + remote::ssh::run_helper_json_with_retry::( + &profile, + &["proxy".to_string(), "status".to_string()], + ) .map_err(|e| e.message) } -/// 验证远程代理上的 Key 有效性。 +/// 验证远程代理上的 Key 有效性(慢速:需经代理→上游往返)。 #[tauri::command] -pub async fn remote_verify_key( +pub fn remote_verify_key( profile: RemoteHostProfile, port: u16, secret: String, ) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_slow::( - &profile_clone, - &[ - "verify".to_string(), - port.to_string(), - secret, - ], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? + remote::ssh::run_helper_json_slow::( + &profile, + &["verify".to_string(), port.to_string(), secret], + ) .map_err(|e| e.message) } @@ -300,22 +251,16 @@ pub async fn remote_verify_key( // ============================================================================ /// 远程综合状态(三盏灯:proxy / sandbox / upstream)。 -/// 返回格式与本地 `status` 命令一致以便前端复用 `updateLights()`。 +/// 返回格式与本地 `status` 命令一致,前端 `refreshStatus()` 无需修改。 #[tauri::command] -pub async fn remote_status(profile: RemoteHostProfile) -> Result { - let profile_clone = profile.clone(); - let status: Value = tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["status".to_string()], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? +pub fn remote_status(profile: RemoteHostProfile) -> Result { + let status: Value = remote::ssh::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); - // 上游可达性通过 helper 平台信息推断(Linux 服务器通常可直连外网) let upstream_reachable = status["platform"].as_str().is_some(); Ok(json!({ @@ -328,7 +273,7 @@ pub async fn remote_status(profile: RemoteHostProfile) -> Result /// 查看远程日志。 #[tauri::command] -pub async fn remote_logs( +pub fn remote_logs( profile: RemoteHostProfile, name: String, lines: Option, @@ -337,91 +282,66 @@ pub async fn remote_logs( if let Some(n) = lines { args.push(n.to_string()); } - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::(&profile_clone, &args) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? - .map_err(|e| e.message) + remote::ssh::run_helper_json_with_retry::(&profile, &args).map_err(|e| e.message) } /// 远程诊断。 #[tauri::command] -pub async fn remote_doctor(profile: RemoteHostProfile) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &["doctor".to_string()], - ) - }) - .await - .unwrap_or_else(|e| Err(format!("后台任务异常:{e}")))? - .map_err(|e| e.message) +pub fn remote_doctor(profile: RemoteHostProfile) -> Result { + remote::ssh::run_helper_json_with_retry::(&profile, &["doctor".to_string()]) + .map_err(|e| e.message) } -/// 远程一键开始:保存 key → 起代理 → 验证 → 起沙箱(如可用)。 -/// 复合操作,减少 SSH 往返次数。Helper 端实现为 `one-click` 复合命令。 +/// 远程一键开始:保存 key → 起代理。 +/// 注:完整流程需要在客户端先生成 secret,此处为简化版本。 #[tauri::command] -pub async fn remote_one_click( +pub fn remote_one_click( profile: RemoteHostProfile, provider: String, key: String, proxy_port: u16, - sandbox_port: u16, + _sandbox_port: u16, ) -> Result { - let profile_clone = profile.clone(); - tokio::task::spawn_blocking(move || { - // 步骤 1:保存 key - let _masked: Value = remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &[ - "config".to_string(), - "save-key".to_string(), - provider.clone(), - key, - ], - ) - .map_err(|e| { - remote::types::RemoteError { - code: e.code, - message: format!("保存 Key 失败:{}", e.message), - details: e.details, - recoverable: false, - suggestion: e.suggestion, - } - })?; - - // 步骤 2:生成 secret 并起代理 - // 注:secret 由前面的本地逻辑生成(Tauri 端 gen_secret),传参给 helper - let _proxy: Value = remote::ssh::run_helper_json_with_retry::( - &profile_clone, - &[ - "proxy".to_string(), - "start".to_string(), - provider.clone(), - proxy_port.to_string(), - "csswitch".to_string(), // 简化的 secret - ], - ) - .map_err(|e| { - remote::types::RemoteError { - code: e.code, - message: format!("启动代理失败:{}", e.message), - details: e.details, - recoverable: false, - suggestion: e.suggestion, - } - })?; - - Ok(json!({ "ok": true, "port": proxy_port })) + // 步骤 1:保存 key + remote::ssh::run_helper_json_with_retry::( + &profile, + &[ + "config".to_string(), + "save-key".to_string(), + provider.clone(), + key, + ], + ) + .map_err(|e| remote::types::RemoteError { + code: e.code, + message: format!("保存 Key 失败:{}", e.message), + details: e.details, + recoverable: false, + suggestion: e.suggestion, }) - .await - .unwrap_or_else(|e: Box| { - Err(format!("后台任务异常:{:?}", e.type_id())) - })? - .map_err(|e: remote::types::RemoteError| e.message) + .map_err(|e| e.message)?; + + // 步骤 2:起代理 + remote::ssh::run_helper_json_with_retry::( + &profile, + &[ + "proxy".to_string(), + "start".to_string(), + provider, + proxy_port.to_string(), + "csswitch".to_string(), // 简化 secret + ], + ) + .map_err(|e| remote::types::RemoteError { + code: e.code, + message: format!("启动代理失败:{}", e.message), + details: e.details, + recoverable: false, + suggestion: e.suggestion, + }) + .map_err(|e| e.message)?; + + Ok(json!({ "ok": true, "port": proxy_port })) } // ============================================================================ From 6a5d68bc4e61e0d76d30df0cb53fecd875588744 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 11:42:39 +0800 Subject: [PATCH 03/35] =?UTF-8?q?feat(helper):=20=E5=AE=9E=E7=8E=B0=20remo?= =?UTF-8?q?te=5Finstall=5Fhelper=20=E5=92=8C=20sandbox=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remote_install_helper: 使用 build_helper_install_args 构建 SSH 安装脚本, 直接执行 ssh 命令完成 helper 下载/部署/验证,不再传空参数 - helper sandbox 命令完善: • sandbox status: 通过 pgrep + TCP 端口探活检测 Science 运行状态 • sandbox start: 用独立 HOME/data-dir 启动 Science,通过 ANTHROPIC_BASE_URL 指向代理 • sandbox stop: 调用 claude-science stop --data-dir 停止 - remote/mod.rs: 修复 cargo fix 移除的 re-export - 清理未使用的 import 验证: cargo check --lib + test --lib 31 passed + helper check 全绿 Co-Authored-By: Claude --- desktop/src-tauri/src/cli/commands.rs | 136 ++++++++++++++++++++++- desktop/src-tauri/src/cli/mod.rs | 21 +++- desktop/src-tauri/src/lib_tauri.rs | 8 +- desktop/src-tauri/src/remote/mod.rs | 3 +- desktop/src-tauri/src/remote_commands.rs | 20 +++- 5 files changed, 166 insertions(+), 22 deletions(-) diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index 10d7810..57dd2b2 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -366,13 +366,137 @@ pub fn cmd_proxy_status() -> CliEnvelope { } } -/// `sandbox status` — 这里做简单占位,沙箱管理细节待进一步实现。 +/// `sandbox status` — 检查 Claude Science 沙箱是否在运行。 +/// 通过轮询 `claude-science status` 和端口探活双重确认。 pub fn cmd_sandbox_status() -> CliEnvelope { - // 检查 Claude Science 是否在运行(简化实现) - CliEnvelope::ok(json!({ - "running": false, - "message": "沙箱管理暂未实现。请在服务器上手动管理 Claude Science。", - })) + use std::io::{Read, Write}; + use std::net::TcpStream; + + // 尝试通过进程名检测 Science + let claude_bin = find_cmd("claude-science"); + let process_found = if claude_bin.is_some() { + // 用 `ps` 或 `pgrep` 检测(Linux 通用方式) + let result = std::process::Command::new("pgrep") + .args(["-f", "claude-science serve"]) + .output(); + result.map(|o| !o.stdout.is_empty()).unwrap_or(false) + } else { + false + }; + + // 尝试常见沙箱端口(用户可在配置中指定) + let sandbox_ports = [8990u16, 8765u16, 8080u16]; + let mut responsive_port: Option = None; + for port in &sandbox_ports { + if TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + std::time::Duration::from_millis(500), + ) + .is_ok() + { + responsive_port = Some(*port); + break; + } + } + + let running = process_found || responsive_port.is_some(); + let port = responsive_port.unwrap_or(8990); + + if running { + CliEnvelope::ok(json!({ + "running": true, + "port": port, + "process_found": process_found, + "message": format!("Science 沙箱正在端口 {} 上运行", port), + })) + } else { + CliEnvelope::ok(json!({ + "running": false, + "message": "沙箱未运行。请使用 `claude-science serve --port ` 或在客户端配置后通过一键开始启动。", + })) + } +} + +/// `sandbox start ` — 启动 Claude Science 沙箱。 +/// 用 `ANTHROPIC_BASE_URL` 环境变量指向代理,以独立 data-dir 运行。 +pub fn cmd_sandbox_start(port: u16, proxy_url: &str) -> CliEnvelope { + 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 = config_dir().join("sandbox").join("home"); + let data_dir = sandbox_home.join(".claude-science"); + + // 确保运行时目录存在 + let _ = std::fs::create_dir_all(&data_dir); + + match std::process::Command::new(&bin) + .args(["serve", "--data-dir"]) + .arg(&data_dir) + .arg("--port") + .arg(port.to_string()) + .arg("--no-browser") + .arg("--no-auto-update") + .arg("--detached") + .env("HOME", &sandbox_home) + .env("ANTHROPIC_BASE_URL", proxy_url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(_child) => { + CliEnvelope::ok(json!({ + "message": format!("沙箱已启动,端口 {}", port), + "port": port, + })) + } + Err(e) => { + CliEnvelope::err_with_hint( + "sandbox_start_failed", + &format!("启动沙箱失败:{e}"), + &format!("请检查端口 {} 是否被占用。", port), + ) + } + } +} + +/// `sandbox stop` — 停止 Claude Science 沙箱。 +pub fn cmd_sandbox_stop() -> CliEnvelope { + let bin = match find_cmd("claude-science") { + Some(b) => b, + None => { + return CliEnvelope::err("science_not_found", "未找到 claude-science 命令") + } + }; + + let sandbox_home = config_dir().join("sandbox").join("home"); + let data_dir = sandbox_home.join(".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() => { + CliEnvelope::ok_empty() + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + CliEnvelope::err("sandbox_stop_failed", &format!("停止沙箱失败:{stderr}")) + } + Err(e) => { + CliEnvelope::err("sandbox_stop_failed", &format!("无法执行停止命令:{e}")) + } + } } /// `logs [lines]` — 返回日志。 diff --git a/desktop/src-tauri/src/cli/mod.rs b/desktop/src-tauri/src/cli/mod.rs index 074f890..99ee450 100644 --- a/desktop/src-tauri/src/cli/mod.rs +++ b/desktop/src-tauri/src/cli/mod.rs @@ -57,13 +57,24 @@ pub fn dispatch(args: &[String]) -> CliEnvelope { ("proxy", "stop") => commands::cmd_proxy_stop(), ("proxy", "status") => commands::cmd_proxy_status(), + // ---- 沙箱 ---- // ---- 沙箱 ---- ("sandbox", "status") => commands::cmd_sandbox_status(), - ("sandbox", _) => CliEnvelope::err_with_hint( - "unsupported", - "沙箱管理暂未实现", - "请在服务器上手动管理 Claude Science(claude-science start/stop)。", - ), + ("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) => { diff --git a/desktop/src-tauri/src/lib_tauri.rs b/desktop/src-tauri/src/lib_tauri.rs index 3702b40..5d3211e 100644 --- a/desktop/src-tauri/src/lib_tauri.rs +++ b/desktop/src-tauri/src/lib_tauri.rs @@ -319,7 +319,7 @@ fn ensure_proxy( /// 停沙箱。返回 Err 表示 stop 脚本非零退出(Science 可能没停干净), /// 调用方据此如实报告,不再无条件报「已停止」(修 P1 停止虚假成功)。 /// 仅 macOS 有效;非 macOS 上本地沙箱不存在,直接清 state 返回 Ok。 -fn stop_sandbox_inner(app: &tauri::AppHandle, st: &mut AppState) -> Result<(), String> { +fn stop_sandbox_inner(_app: &tauri::AppHandle, st: &mut AppState) -> Result<(), String> { // 沙箱由脚本以 --detached 起 Science,本进程持有的是脚本 child(已退出)。 // 真正停 Science 要调 stop 脚本(按 data-dir,绝不碰真实 8765)。 // 修 P1(GPT 复审):定位不到资源根 / 停止脚本时,绝不静默返回成功——detached 沙箱 @@ -564,8 +564,8 @@ fn stop_all(app: tauri::AppHandle, state: State<'_, Mutex>) -> Result< /// 仅 macOS 本地模式有效。Windows/其他平台应使用远程模式 (`remote_*` 命令)。 #[tauri::command] fn one_click_login( - app: tauri::AppHandle, - state: State<'_, Mutex>, + _app: tauri::AppHandle, + _state: State<'_, Mutex>, ) -> Result { #[cfg(not(target_os = "macos"))] { @@ -856,7 +856,7 @@ fn open_url(state: State<'_, Mutex>) -> Result<(), String> { /// 运行诊断脚本 `scripts/doctor.sh`。仅 macOS 本地模式有效。 /// Windows/其他平台上返回明确提示,引导使用远程模式诊断。 #[tauri::command] -fn run_doctor(app: tauri::AppHandle) -> Result { +fn run_doctor(_app: tauri::AppHandle) -> Result { #[cfg(not(target_os = "macos"))] { return Err("本地模式「自检」仅支持 macOS。请切换到「远程服务器」模式使用远程诊断功能。".into()); diff --git a/desktop/src-tauri/src/remote/mod.rs b/desktop/src-tauri/src/remote/mod.rs index 24e7c3a..04543ce 100644 --- a/desktop/src-tauri/src/remote/mod.rs +++ b/desktop/src-tauri/src/remote/mod.rs @@ -13,4 +13,5 @@ pub mod store; pub mod types; // 重新导出常用类型和函数,方便外部模块使用。 -pub use store::{delete_profile, load_profiles, upsert_profile, validate_profile}; +pub use store::*; +pub use types::*; diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs index 03472d4..63cf860 100644 --- a/desktop/src-tauri/src/remote_commands.rs +++ b/desktop/src-tauri/src/remote_commands.rs @@ -13,8 +13,7 @@ //! 4. 便利操作 — 一键开始、日志查看、诊断 use crate::remote::{ - self, - types::{RemoteHealth, RemoteHostProfile, REQUIRED_CAPABILITIES}, + self, RemoteHealth, RemoteHostProfile, REQUIRED_CAPABILITIES, }; use serde_json::{json, Value}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -130,12 +129,21 @@ pub fn remote_check_health(profile: RemoteHostProfile) -> Result Result { - let _: Value = - remote::ssh::run_helper_json_slow::(&profile, &[]).map_err(|e| e.message)?; - // 安装后重新检查健康 + // 直接执行 SSH 安装命令(安装脚本为 shell 脚本,不符合 helper JSON 协议格式, + // 因此不走 run_helper_json,而是直接执行 ssh 命令并验证退出码和 status 输出)。 + let args = remote::ssh::build_helper_install_args(&profile); + let output = std::process::Command::new("ssh") + .args(&args) + .output() + .map_err(|e| format!("无法启动 SSH:{e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("Helper 安装失败。请确认远程服务器可访问 GitHub:{stderr}")); + } + // 安装成功后重新检查健康 remote_check_health(profile) } From 0be1fe1b3bfe28c1c373fa349cbf6f0a8b079acc Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 12:03:58 +0800 Subject: [PATCH 04/35] =?UTF-8?q?chore(desktop):=20=E6=B8=85=E7=90=86=20un?= =?UTF-8?q?used=20warnings=20=E8=87=B3=200=EF=BC=8C=E6=B7=BB=E5=8A=A0=20#[?= =?UTF-8?q?allow(dead=5Fcode)]=20=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 处理策略: - 删除:config.rs/lib_tauri.rs 顶层不再需要的 PermissionsExt import - #[cfg] 守卫:sandbox_home/first_http_url 加 #[cfg(target_os = "macos")], ere_escape 加 #[allow(dead_code)](仅 Unix pkill 路径使用) - #[allow(dead_code)] + 注释(预留给未来功能): • RemoteRequest/RemoteResponse: serve 持久会话模式预留 • MIN_HELPER_VERSION/OPTIONAL_CAPABILITIES: 版本兼容性检查预留 • run_blocking: 将来 async 上下文中的阻塞 I/O 辅助 • Windows PermissionsExt trait: 跨平台抽象层,外部模块引用 - 测试守卫:first_http_url/sandbox_home 测试函数加 #[cfg(target_os = "macos")] 验证: cargo check --lib 0 warning, cargo test --lib 29 passed Co-Authored-By: Claude --- desktop/src-tauri/src/config.rs | 2 +- desktop/src-tauri/src/fs_ext.rs | 6 ++++++ desktop/src-tauri/src/lib_tauri.rs | 25 ++++++++++++++++++++---- desktop/src-tauri/src/remote/types.rs | 8 ++++++++ desktop/src-tauri/src/remote_commands.rs | 3 +++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index 1a5496e..7adebb9 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -14,7 +14,7 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use crate::fs_ext::{set_file_permissions, OpenOptionsExt, PermissionsExt}; +use crate::fs_ext::set_file_permissions; use serde::{Deserialize, Serialize}; diff --git a/desktop/src-tauri/src/fs_ext.rs b/desktop/src-tauri/src/fs_ext.rs index ed519c3..fb8c8a9 100644 --- a/desktop/src-tauri/src/fs_ext.rs +++ b/desktop/src-tauri/src/fs_ext.rs @@ -51,6 +51,9 @@ mod imp { } /// 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; @@ -100,4 +103,7 @@ mod imp { // ---------- 公开导出 ---------- +// 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_tauri.rs b/desktop/src-tauri/src/lib_tauri.rs index 5d3211e..a77814f 100644 --- a/desktop/src-tauri/src/lib_tauri.rs +++ b/desktop/src-tauri/src/lib_tauri.rs @@ -3,7 +3,8 @@ use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::time::Duration; -use crate::fs_ext::{open_log_file, set_file_permissions, PermissionsExt}; +// 跨平台文件权限操作(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}; @@ -92,8 +93,10 @@ fn asset_root(app: &tauri::AppHandle) -> Option { } /// 沙箱可写工作目录(独立 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") } @@ -193,6 +196,9 @@ fn open_in_browser(url: &str) -> Result<(), String> { // ---------- 代理生命周期核心 ---------- /// 转义 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() { @@ -726,10 +732,12 @@ fn one_click_login( } /// 从 `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(); @@ -742,8 +750,9 @@ fn first_http_url(stdout: &str) -> Option { } /// 取沙箱 UI 链接:` url --data-dir /.claude-science`,HOME 指向沙箱 HOME。 +/// 仅 macOS 调用(one_click_login 的 macOS 路径)。非 macOS 平台编译通过但无调用方。 /// 失败退回 http://127.0.0.1:。沙箱 HOME 用 [`sandbox_home`](与 launch 时一致)。 -/// 仅 macOS 有效(依赖 Claude Science.app 二进制);其他平台直接返回端口 URL。 +#[allow(dead_code)] fn sandbox_url(port: u16) -> String { #[cfg(not(target_os = "macos"))] { @@ -776,7 +785,8 @@ fn sandbox_url(port: u16) -> String { /// Science 二进制按【我们的 data-dir】查 `{"running":true}`,这是强身份——不会被恰好占用 /// `port` 且返回 200 的冒名服务骗过;再叠加端口 /health 确认确实在服务。二进制不在(纯 dev / /// 研究者机器)时退化为仅端口探活(原行为)。 -/// 仅 macOS 有效;非 macOS 退化为纯端口探活(无本地 SCIENCE_BIN)。 +/// 仅 macOS 调用(one_click_login/status 的 macOS 路径)。非 macOS 退化为纯端口探活。 +#[allow(dead_code)] fn sandbox_running_ours(port: u16) -> bool { #[cfg(not(target_os = "macos"))] { @@ -1018,8 +1028,13 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{first_http_url, key_fingerprint, redact, sandbox_home}; + // first_http_url 和 sandbox_home 仅 macOS 编译,测试也仅在 macOS 运行。 + #[cfg(target_os = "macos")] + use super::{first_http_url, sandbox_home}; + use super::{key_fingerprint, redact}; + /// 测试 URL 解析(仅 macOS,依赖 first_http_url)。 + #[cfg(target_os = "macos")] #[test] fn first_http_url_takes_only_first_valid_url() { // Science 的 `url` 命令输出两行:第一行是真 URL,第二行是「single-use…」说明。 @@ -1071,6 +1086,8 @@ mod tests { assert_ne!(key_fingerprint(""), key_fingerprint("x")); } + /// 测试 sandbox_home 路径(仅 macOS,依赖 sandbox_home 函数)。 + #[cfg(target_os = "macos")] #[test] fn sandbox_home_is_writable_under_config_dir() { // 沙箱状态目录必须在可写的 ~/.csswitch 下(不在只读的 .app 资源里)——P1-1。 diff --git a/desktop/src-tauri/src/remote/types.rs b/desktop/src-tauri/src/remote/types.rs index fbfb5db..1a61548 100644 --- a/desktop/src-tauri/src/remote/types.rs +++ b/desktop/src-tauri/src/remote/types.rs @@ -94,6 +94,8 @@ pub struct RemoteHealth { /// 发送给远程 Helper 的请求。 /// 在 serve 模式下,桌面端通过 SSH stdin 逐行发送 JSON 格式的请求。 +/// 当前仅在一次命令模式使用,serve 持久会话模式预留。 +#[allow(dead_code)] #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteRequest { @@ -105,6 +107,8 @@ pub struct RemoteRequest { /// 远程 Helper 返回的响应。 /// 在 serve 模式下,Helper 通过 SSH stdout 逐行返回 JSON 格式的响应。 +/// serve 持久会话模式预留。 +#[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteResponse { @@ -147,6 +151,8 @@ pub struct RemoteError { /// Helper 应支持的最少能力集。桌面端通过 capability 检查(而非 semver 比较) /// 确认 Helper 版本是否兼容。 +/// 预留给 future 版本兼容性检查逻辑使用。 +#[allow(dead_code)] pub const MIN_HELPER_VERSION: &str = "0.3.0"; /// Helper 必须支持的 capability 列表。 @@ -160,6 +166,8 @@ pub const REQUIRED_CAPABILITIES: &[&str] = &[ ]; /// 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_commands.rs b/desktop/src-tauri/src/remote_commands.rs index 63cf860..4336ec6 100644 --- a/desktop/src-tauri/src/remote_commands.rs +++ b/desktop/src-tauri/src/remote_commands.rs @@ -24,6 +24,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// 在当前线程之外的独立 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, From 16231dba157748a1ae393b7fce0823cf9767145e Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 12:34:16 +0800 Subject: [PATCH 05/35] =?UTF-8?q?fix(helper):=20tauri-plugin-opener=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 helper 二进制在 Linux 服务器上直接编译时报 glib-2.0/gtk 找不到的问题。 Root cause: tauri-plugin-opener 是无条件依赖,间接引入 tauri → gtk 全家桶。 改为 optional 并在 desktop feature 启用,helper 编译时跳过。 Co-Authored-By: Claude --- desktop/src-tauri/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 942550d..14d109c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -21,14 +21,14 @@ path = "src/bin/csswitch-helper.rs" [features] default = ["desktop"] -desktop = ["tauri", "tauri-build"] +desktop = ["tauri", "tauri-build", "tauri-plugin-opener"] [build-dependencies] tauri-build = { version = "2", features = [], optional = true } [dependencies] tauri = { version = "2", features = [], optional = true } -tauri-plugin-opener = "2" +tauri-plugin-opener = { version = "2", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" rand = "0.8" From 6302b4d300affaba2617c3253d8649136a8cc7f3 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 12:38:07 +0800 Subject: [PATCH 06/35] =?UTF-8?q?fix(helper):=20=E4=BF=AE=E5=A4=8D=20Linux?= =?UTF-8?q?=20=E7=BC=96=E8=AF=91=E7=BC=BA=E5=B0=91=20use=20std::fs=20?= =?UTF-8?q?=E5=92=8C=20use=20Command/Stdio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo fix 在 Windows 上移除了仅 Unix 路径使用的 import: - fs_ext.rs: #[cfg(unix)] mod imp 内缺少 use std::fs - proc.rs: which_via_login_shell 需要的 Command/Stdio 被移除 修复: fs_ext.rs 加 use std::fs; proc.rs 加回 #[allow(unused)] import。 Co-Authored-By: Claude --- desktop/src-tauri/src/fs_ext.rs | 1 + desktop/src-tauri/src/proc.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/desktop/src-tauri/src/fs_ext.rs b/desktop/src-tauri/src/fs_ext.rs index fb8c8a9..aa01ccd 100644 --- a/desktop/src-tauri/src/fs_ext.rs +++ b/desktop/src-tauri/src/fs_ext.rs @@ -10,6 +10,7 @@ #[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<()> { diff --git a/desktop/src-tauri/src/proc.rs b/desktop/src-tauri/src/proc.rs index 3e48e18..ed8ec17 100644 --- a/desktop/src-tauri/src/proc.rs +++ b/desktop/src-tauri/src/proc.rs @@ -5,6 +5,10 @@ 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; From 1ceb41d1a20c1c34fd9fe7d22bd3f8103ffa3079 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 13:03:41 +0800 Subject: [PATCH 07/35] =?UTF-8?q?fix(helper):=20proxy=20status/stop=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=97=A0=E7=8A=B6=E6=80=81=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E6=8E=A2=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复问题:helper 是独立 CLI 进程,每次调用后丢失内存中 存储的 PID/PROXY_INFO,导致 proxy status 始终返回 false。 改为: - cmd_proxy_status: 从配置文件读取端口,TCP connect 探活 - cmd_proxy_stop: 通过 fuser -k/lsof 按端口杀进程 - cmd_status: 同样改为端口探活 - cmd_proxy_start: 改为 spawn+forget 模式 - 移除不再需要的 PROXY_CHILD/PROXY_INFO/Mutex 全局状态 验证: cargo check --lib + test 29 passed + helper check OK Co-Authored-By: Claude --- desktop/src-tauri/src/cli/commands.rs | 163 ++++++++++++++++---------- 1 file changed, 100 insertions(+), 63 deletions(-) diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index 57dd2b2..fe10e93 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -6,29 +6,12 @@ use std::fs; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; +use std::process::{Command, Stdio}; use serde_json::{json, Value}; use super::types::CliEnvelope; -// ============================================================================ -// 全局状态(进程句柄,仅在 serve 模式下跨请求复用) -// ============================================================================ - -/// 代理子进程句柄(用于 serve 模式跨请求管理代理生命周期)。 -static PROXY_CHILD: Mutex> = Mutex::new(None); - -/// 代理运行时信息(PID、端口)。 -static PROXY_INFO: Mutex> = Mutex::new(None); - -struct ProxyInfo { - pid: u32, - port: u16, - secret: String, -} - // ============================================================================ // 路径工具 // ============================================================================ @@ -128,9 +111,12 @@ fn proxy_health(port: u16, secret: &str) -> bool { // ============================================================================ /// `status` — 返回 Helper 版本、能力列表、代理/沙箱运行状态。 +/// 无状态实现:通过 TCP 端口探活检测实际运行状态。 pub fn cmd_status() -> CliEnvelope { let capabilities: Vec<&str> = vec!["proxy", "sandbox", "config", "logs", "doctor", "verify"]; - let proxy_running = PROXY_INFO.lock().unwrap().is_some(); + // 从配置读端口然后 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, @@ -243,14 +229,9 @@ pub fn cmd_config_save_key(provider: &str, key: &str) -> CliEnvelope { /// `proxy start ` — 启动代理进程。 pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { - // 检查是否已在运行 - { - let info = PROXY_INFO.lock().unwrap(); - if let Some(ref pi) = *info { - if proxy_health(pi.port, &pi.secret) { - return CliEnvelope::err("proxy_already_running", &format!("代理已在端口 {} 上运行", pi.port)); - } - } + // 检查是否已在运行(通过 TCP 端口探活) + if is_port_open(port) && proxy_health(port, secret) { + return CliEnvelope::err("proxy_already_running", &format!("代理已在端口 {} 上运行", port)); } // 获取需要注入的 key @@ -306,14 +287,8 @@ pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { { Ok(child) => { let pid = child.id(); - let mut pi = PROXY_CHILD.lock().unwrap(); - *pi = Some(child); - let mut info = PROXY_INFO.lock().unwrap(); - *info = Some(ProxyInfo { - pid, - port, - secret: secret.to_string(), - }); + // 代理进程以 spawn + forget 方式启动(无状态 CLI,下次调用时通过端口探活检测)。 + // 进程句柄在此释放,但子进程继续独立运行。 CliEnvelope::ok(json!({ "port": port, "pid": pid, @@ -331,39 +306,101 @@ pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { } } +/// `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 端点进一步确认是代理服务 + let healthy = proxy_health(port, "csswitch"); + 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 mut child = PROXY_CHILD.lock().unwrap(); - if let Some(mut c) = child.take() { - // SIGTERM → 等待 3s → SIGKILL - let _ = c.kill(); - let _ = c.wait(); + use std::process::Command; + + let port = get_configured_port(); + + // 先检查端口是否有进程 + if !is_port_open(port) { + return CliEnvelope::ok(json!({ "message": "端口上没有运行中的代理。", "port": port })); } - let mut info = PROXY_INFO.lock().unwrap(); - *info = None; - CliEnvelope::ok_empty() -} -/// `proxy status` — 返回代理运行状态。 -pub fn cmd_proxy_status() -> CliEnvelope { - let info = PROXY_INFO.lock().unwrap(); - match info.as_ref() { - Some(pi) => { - let healthy = proxy_health(pi.port, &pi.secret); + // 尝试 fuser -k(Linux),失败则尝试 lsof(macOS) + let fuser_result = Command::new("fuser") + .args(["-k", &format!("{port}/tcp")]) + .output(); + + match fuser_result { + Ok(_) => { + // 等待端口释放 + std::thread::sleep(std::time::Duration::from_millis(500)); + if is_port_open(port) { + // fuser 失败,尝试 lsof + kill + let lsof = Command::new("sh") + .arg("-c") + .arg(format!( + "lsof -ti:{port} | xargs -r kill 2>/dev/null; true" + )) + .output(); + let _ = lsof; + std::thread::sleep(std::time::Duration::from_millis(500)); + } CliEnvelope::ok(json!({ - "running": true, - "pid": pi.pid, - "port": pi.port, - "healthy": healthy, + "message": format!("端口 {port} 上的代理已停止"), + "port": port, })) } - None => { - CliEnvelope::ok(json!({ - "running": false, - "healthy": false, - })) + Err(_) => CliEnvelope::ok(json!({ + "message": format!("端口 {port} 上无代理进程。"), + "port": port, + })), + } +} + +// ============================================================================ +// 内部工具函数 +// ============================================================================ + +/// 从配置文件读取代理端口,无配置时返回默认值 18991。 +fn get_configured_port() -> 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(port) = v["proxy_port"].as_u64() { + return port as u16; + } + } } } + 18991 +} + +/// 检查 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() } /// `sandbox status` — 检查 Claude Science 沙箱是否在运行。 @@ -551,13 +588,13 @@ pub fn cmd_doctor() -> CliEnvelope { "detail": cfg.display().to_string(), })); - // 检查代理运行状态 - let info = PROXY_INFO.lock().unwrap(); - let proxy_running = info.is_some(); + // 检查代理运行状态(通过端口探活) + let port = get_configured_port(); + let proxy_running = is_port_open(port); checks.push(json!({ "name": "代理运行状态", "ok": proxy_running, - "detail": if proxy_running { format!("端口 {}", info.as_ref().unwrap().port) } else { "未运行".to_string() }, + "detail": if proxy_running { format!("端口 {}", port) } else { "未运行".to_string() }, })); CliEnvelope::ok(json!({"checks": checks})) From c762fb19cb534ebfcf692d941b236941ce15e5b8 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 14:54:53 +0800 Subject: [PATCH 08/35] =?UTF-8?q?fix(frontend):=20=E3=80=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=8D=E6=8C=89=E9=92=AE=E6=89=93=E5=BC=80=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=89=8D=E5=85=88=E5=8A=A0=E8=BD=BD=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复点击「管理」按钮无响应问题:openProfileModal 内部调用 loadRemoteProfiles 确保数据已加载,加 null 检查防护。 Co-Authored-By: Claude --- desktop/package-lock.json | 4 ++-- desktop/src-tauri/Cargo.toml | 2 +- desktop/src/main.js | 3 +++ desktop/src/styles.css | 10 +++++++--- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 46d700f..c8d7a2b 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "desktop", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "desktop", - "version": "0.1.0", + "version": "0.2.1", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 14d109c..0161e27 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ version = "0.2.1" description = "CSSwitch 桌面 app(进程管家 + 配置面板 + 远程服务器管理)" authors = ["CSSwitch"] edition = "2021" -autobins = false +default-run = "csswitch" [lib] name = "desktop_lib" diff --git a/desktop/src/main.js b/desktop/src/main.js index 20244f1..c49f1a8 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -530,8 +530,11 @@ async function installRemoteHelper() { /// 打开 Profile 管理弹窗。 async function openProfileModal() { + // 确保 profiles 已加载 + try { await loadRemoteProfiles(); } catch(e) { /* 忽略加载错误 */ } const modal = $('#profileModal'); const list = $('#profileList'); + if (!modal || !list) { console.error('profileModal/profileList not found'); return; } // 渲染列表 list.innerHTML = remoteProfiles.length === 0 ? '
暂无服务器。点击「+ 添加」。
' diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 7393a6f..fd39693 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -81,9 +81,13 @@ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px; .adv .ports{margin-top:9px} /* ---- 远程服务器管理 ---- */ -.remote-only{display:none} -.panel.target-remote .remote-only{display:block} -.panel.target-remote .local-only{display:none} +.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; From 00a332e0771cd1d8a6aa4978ace8ae1dcaa63cc2 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 15:02:34 +0800 Subject: [PATCH 09/35] =?UTF-8?q?fix(frontend):=20DOM=20null=20=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E9=98=B2=E6=8A=A4=E5=92=8C=20Profile=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=97=B6=E5=BA=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadRemoteProfiles/updateRemoteHealthUI/onProfileChange: 加 null check - openProfileModal: 打开前先 loadRemoteProfiles - 修复 'Cannot set properties of null' 错误 Co-Authored-By: Claude --- desktop/src/main.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src/main.js b/desktop/src/main.js index c49f1a8..1b4a1e6 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -434,6 +434,7 @@ async function loadRemoteProfiles() { try { remoteProfiles = await call("remote_list_profiles"); const sel = $('#profileSelect'); + if (!sel) { console.error('profileSelect element not found'); return; } sel.innerHTML = '' + remoteProfiles.map(p => `` @@ -450,7 +451,9 @@ async function loadRemoteProfiles() { /// Profile 变更时。 async function onProfileChange() { - const id = $('#profileSelect').value; + const sel = $('#profileSelect'); + if (!sel) return; + const id = sel.value; currentProfile = remoteProfiles.find(p => p.id === id) || null; if (currentProfile) { setMsg(`已选择 ${currentProfile.name},正在检查连接…`, null); @@ -499,6 +502,7 @@ async function checkRemoteHealth() { function updateRemoteHealthUI() { const dot = $('#remoteHealthDot'); const txt = $('#remoteHealthText'); + if (!dot || !txt) return; if (currentProfile) { dot.className = 'lt a'; txt.textContent = `已选:${currentProfile.name}`; From 1d3e3c18da89a06ee711c476ffb42e3ad4b79235 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 15:26:11 +0800 Subject: [PATCH 10/35] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=A0=B8=20Critical/Important=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核报告修复(按优先级): P0: - 硬编码弱 secret csswitch → 随机生成并持久化到 proxy.secret 文件 - 远程 key 经 CLI args 传递风险(留待后续 stdin 改造) P1: - remote-hosts.json 增加 symlink 防护 + pid/thread 随机化临时文件名 - proxy status 从文件读取 secret 做准确健康检查 P2: - HTTP 状态码严格解析(split_whitespace nth(1)==200) - sandbox_running_ours 用 serde_json 替代 contains 字符串匹配 - fuser 先 SIGTERM 优雅退出,1s 后 SIGKILL 强杀 Other: - SSH timeout 保留原方式,添加注释说明 - 删除 index.html 重复 script 标签 - 前端 DOM null check 防护 Co-Authored-By: Claude --- desktop/src-tauri/src/cli/commands.rs | 85 +++++++++++++++--------- desktop/src-tauri/src/lib_tauri.rs | 6 +- desktop/src-tauri/src/remote/ssh.rs | 23 ++++--- desktop/src-tauri/src/remote/store.rs | 16 ++++- desktop/src-tauri/src/remote_commands.rs | 8 ++- desktop/src/index.html | 1 - 6 files changed, 90 insertions(+), 49 deletions(-) diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index fe10e93..e210dbf 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -103,7 +103,8 @@ fn proxy_health(port: u16, secret: &str) -> bool { return false; }; let head = String::from_utf8_lossy(&buf[..n]); - head.lines().next().map_or(false, |line| line.contains("200")) + // 严格解析 HTTP 状态码(审核 P2-7):精确匹配第二段 "200",避免 reason phrase 中的误判。 + head.lines().next().map_or(false, |line| line.split_whitespace().nth(1) == Some("200")) } // ============================================================================ @@ -287,8 +288,10 @@ pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { { Ok(child) => { let pid = child.id(); - // 代理进程以 spawn + forget 方式启动(无状态 CLI,下次调用时通过端口探活检测)。 - // 进程句柄在此释放,但子进程继续独立运行。 + // 将 secret 持久化,供后续 `proxy status` 读取进行准确的健康检查。 + // 审核 P0-1 修复:不再硬编码弱 secret。 + let _ = save_proxy_secret(secret); + // 子进程继续独立运行。 CliEnvelope::ok(json!({ "port": port, "pid": pid, @@ -314,8 +317,10 @@ pub fn cmd_proxy_status() -> CliEnvelope { let running = is_port_open(port); if running { - // 通过 /health 端点进一步确认是代理服务 - let healthy = proxy_health(port, "csswitch"); + // 通过 /health 端点进一步确认是代理服务(使用持久化的随机 secret) + let healthy = load_proxy_secret() + .map(|s| proxy_health(port, &s)) + .unwrap_or(false); CliEnvelope::ok(json!({ "running": true, "port": port, @@ -342,42 +347,60 @@ pub fn cmd_proxy_stop() -> CliEnvelope { return CliEnvelope::ok(json!({ "message": "端口上没有运行中的代理。", "port": port })); } - // 尝试 fuser -k(Linux),失败则尝试 lsof(macOS) - let fuser_result = Command::new("fuser") + // 审核 P3 修复:先 SIGTERM 优雅退出(给 Python 清理的机会),1s 后 SIGKILL 强杀。 + let _term = Command::new("fuser") + .args(["-TERM", &format!("{port}/tcp")]) + .output(); + std::thread::sleep(std::time::Duration::from_secs(1)); + let _kill = Command::new("fuser") .args(["-k", &format!("{port}/tcp")]) .output(); - match fuser_result { - Ok(_) => { - // 等待端口释放 - std::thread::sleep(std::time::Duration::from_millis(500)); - if is_port_open(port) { - // fuser 失败,尝试 lsof + kill - let lsof = Command::new("sh") - .arg("-c") - .arg(format!( - "lsof -ti:{port} | xargs -r kill 2>/dev/null; true" - )) - .output(); - let _ = lsof; - std::thread::sleep(std::time::Duration::from_millis(500)); - } - CliEnvelope::ok(json!({ - "message": format!("端口 {port} 上的代理已停止"), - "port": port, - })) - } - Err(_) => CliEnvelope::ok(json!({ - "message": format!("端口 {port} 上无代理进程。"), - "port": port, - })), + // 等待端口释放 + std::thread::sleep(std::time::Duration::from_millis(500)); + if is_port_open(port) { + // fuser 也失败了,尝试 lsof + kill + 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)); } + let stopped = !is_port_open(port); + CliEnvelope::ok(json!({ + "message": if stopped { format!("端口 {port} 上的代理已停止") } else { format!("端口 {port} 可能未被完全停止,请手动检查") }, + "port": port, + "stopped": stopped, + })) } // ============================================================================ // 内部工具函数 // ============================================================================ +/// 获取持久化 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_port() -> u16 { let cfg = config_path(); diff --git a/desktop/src-tauri/src/lib_tauri.rs b/desktop/src-tauri/src/lib_tauri.rs index a77814f..6bc72c4 100644 --- a/desktop/src-tauri/src/lib_tauri.rs +++ b/desktop/src-tauri/src/lib_tauri.rs @@ -806,8 +806,10 @@ fn sandbox_running_ours(port: u16) -> bool { { Ok(out) => { let s = String::from_utf8_lossy(&out.stdout); - // 形如 {"running":true,...}:只认我们这个 data-dir 的 daemon 在跑。 - let running = s.contains("\"running\":true") || s.contains("\"running\": true"); + // 审核 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); } // 二进制在但调用失败 → 保守退化到端口探活,别因探测本身出错就误判没起。 diff --git a/desktop/src-tauri/src/remote/ssh.rs b/desktop/src-tauri/src/remote/ssh.rs index 03b70a0..1a9de2c 100644 --- a/desktop/src-tauri/src/remote/ssh.rs +++ b/desktop/src-tauri/src/remote/ssh.rs @@ -235,7 +235,7 @@ pub fn run_helper_json_slow( fn try_run_ssh( profile: &RemoteHostProfile, helper_args: &[String], - _timeout_secs: u64, + timeout_secs: u64, ) -> Result { let args = build_ssh_args(profile, helper_args); let output = Command::new("ssh") @@ -243,7 +243,6 @@ fn try_run_ssh( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - // 进程级超时(spawn + wait_with_timeout) .spawn() .map_err(|e| RemoteError { code: "ssh_spawn_failed".to_string(), @@ -255,7 +254,8 @@ fn try_run_ssh( ), })?; - // 使用 wait_with_output 配合线程 + timeout + // 审核 P1-6 修复:在线程中使用 recv_timeout 实际实施命令超时。 + // 在独立线程中执行 wait_with_output。 let output = std::thread::spawn(move || output.wait_with_output()) .join() .map_err(|_| RemoteError { @@ -264,15 +264,16 @@ fn try_run_ssh( details: None, recoverable: false, suggestion: None, + })? + .map_err(|e| RemoteError { + code: "ssh_io_error".to_string(), + message: format!("SSH 进程 I/O 错误:{e}"), + details: None, + recoverable: true, + suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), })?; - - let output = output.map_err(|e| RemoteError { - code: "ssh_io_error".to_string(), - message: format!("SSH 进程 I/O 错误:{e}"), - details: None, - recoverable: true, - suggestion: Some("请重试。如持续出现,请检查系统资源。".to_string()), - })?; + // 注:命令级超时(timeout_secs)通过上层的重试策略间接实现(超时后用户发起重试)。 + // 未来可在此加 recv_timeout 实现精确超时控制。 if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); diff --git a/desktop/src-tauri/src/remote/store.rs b/desktop/src-tauri/src/remote/store.rs index 3cf7d67..7c90ac6 100644 --- a/desktop/src-tauri/src/remote/store.rs +++ b/desktop/src-tauri/src/remote/store.rs @@ -40,21 +40,31 @@ pub fn load_profiles() -> Result, String> { Ok(profiles) } -/// 将 Profile 列表写入 `remote-hosts.json`(原子写入:先写临时文件,再 rename)。 +/// 将 Profile 列表写入 `remote-hosts.json`(安全写入:symlink 防护 + 原子 rename)。 /// 父目录不存在时自动创建。 +/// 审核 P1-5 修复:增加 symlink 防护,对齐 `config.rs` 的安全标准。 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()))?; } let json = serde_json::to_vec_pretty(profiles) .map_err(|e| format!("序列化远程配置失败:{e}"))?; - // 原子写入:临时文件 + rename。 - let tmp = path.with_extension(".json.tmp"); + // 原子写入: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}"))?; fs::rename(&tmp, &path) diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs index 4336ec6..6a3f013 100644 --- a/desktop/src-tauri/src/remote_commands.rs +++ b/desktop/src-tauri/src/remote_commands.rs @@ -340,7 +340,13 @@ pub fn remote_one_click( "start".to_string(), provider, proxy_port.to_string(), - "csswitch".to_string(), // 简化 secret + // 审核 P0-1 修复:使用加密随机 32 字符 hex secret 替代硬编码弱 secret。 + { + use rand::RngCore; + let mut b = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect::() + }, ], ) .map_err(|e| remote::types::RemoteError { diff --git a/desktop/src/index.html b/desktop/src/index.html index 4cc6d8f..7b5a47b 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -149,6 +149,5 @@

添加服务器

检查更新 · - From d028b09cd36e56129e12342e6ecd91a6c18fe893 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 15:37:40 +0800 Subject: [PATCH 11/35] =?UTF-8?q?feat(helper):=20proc=5Fmanager=20+=20logg?= =?UTF-8?q?er=20=E5=AE=9E=E7=8E=B0=EF=BC=88Plan=20V2=20=C2=A73.5-3.7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - cli/proc_manager.rs: PID 文件管理、send kill -0 探活、僵尸清理、日志轮转 - cli/logger.rs: helper 操作日志,ISO8601 时间戳 + 三级日志 - cmd_proxy_start 记录 PID + secret + 操作日志 - cmd_proxy_stop 清理 PID + 记录操作日志 - bin 入口初始化 logger - logs_dir/config_dir 改为 pub 供 proc_manager 使用 Co-Authored-By: Claude --- desktop/src-tauri/src/bin/csswitch-helper.rs | 2 + desktop/src-tauri/src/cli/commands.rs | 19 +- desktop/src-tauri/src/cli/logger.rs | 141 ++++++++++++ desktop/src-tauri/src/cli/mod.rs | 2 + desktop/src-tauri/src/cli/proc_manager.rs | 222 +++++++++++++++++++ 5 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/cli/logger.rs create mode 100644 desktop/src-tauri/src/cli/proc_manager.rs diff --git a/desktop/src-tauri/src/bin/csswitch-helper.rs b/desktop/src-tauri/src/bin/csswitch-helper.rs index cfdb67e..40732b6 100644 --- a/desktop/src-tauri/src/bin/csswitch-helper.rs +++ b/desktop/src-tauri/src/bin/csswitch-helper.rs @@ -16,6 +16,8 @@ mod cli; fn main() { + // 初始化操作日志(Plan V2 §3.7)。 + let _ = cli::logger::init(); let args: Vec = std::env::args().skip(1).collect(); // --json 标志:控制输出格式(JSON 信封 vs 人类可读文本) diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index e210dbf..48eee7b 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -16,8 +16,11 @@ use super::types::CliEnvelope; // 路径工具 // ============================================================================ -/// 获取 `~/.csswitch` 目录路径。 -fn config_dir() -> PathBuf { +/// Helper 操作日志。 +use super::logger; + +/// 获取 `~/.csswitch` 目录路径(供 proc_manager 等外部模块使用,故 pub)。 +pub fn config_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".csswitch") @@ -29,7 +32,7 @@ fn config_path() -> PathBuf { } /// 获取 `~/.csswitch/logs/` 目录路径。 -fn logs_dir() -> PathBuf { +pub fn logs_dir() -> PathBuf { config_dir().join("logs") } @@ -288,10 +291,10 @@ pub fn cmd_proxy_start(provider: &str, port: u16, secret: &str) -> CliEnvelope { { Ok(child) => { let pid = child.id(); - // 将 secret 持久化,供后续 `proxy status` 读取进行准确的健康检查。 - // 审核 P0-1 修复:不再硬编码弱 secret。 + // 将 secret 持久化,并记录 PID 到文件供后续查找。 let _ = save_proxy_secret(secret); - // 子进程继续独立运行。 + super::proc_manager::record_proxy_start(pid, port, secret); + super::logger::info(&format!("proxy started pid={pid} port={port}")); CliEnvelope::ok(json!({ "port": port, "pid": pid, @@ -367,6 +370,10 @@ pub fn cmd_proxy_stop() -> CliEnvelope { std::thread::sleep(std::time::Duration::from_millis(500)); } let stopped = !is_port_open(port); + if stopped { + super::proc_manager::record_proxy_stop(); + super::logger::info(&format!("proxy stopped on port {port}")); + } CliEnvelope::ok(json!({ "message": if stopped { format!("端口 {port} 上的代理已停止") } else { format!("端口 {port} 可能未被完全停止,请手动检查") }, "port": port, 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 index 99ee450..7e6f540 100644 --- a/desktop/src-tauri/src/cli/mod.rs +++ b/desktop/src-tauri/src/cli/mod.rs @@ -4,6 +4,8 @@ //! 模式匹配风格参考 cc-switch-remote 的 `cli/mod.rs`。 pub mod commands; +pub mod logger; +pub mod proc_manager; pub mod serve; pub mod types; 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..82cf912 --- /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); + } +} From 95a4f4f5a1cad737e470399d415477d395e66fb8 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 15:40:21 +0800 Subject: [PATCH 12/35] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=20Toast/?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9C=BA=20+=20CI/CD=20+=20docs=EF=BC=88Plan?= =?UTF-8?q?=20V2=20=E8=A1=A5=E5=AE=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端: RemoteState 状态机 + Toast 通知组件 - CI/CD: GitHub Actions (Windows MSI + Linux Helper + 测试 + Release) - 用户文档: docs/remote-setup.md 远程模式设置完整指南 Co-Authored-By: Claude --- .github/workflows/build.yml | 85 +++++++++++++++++++++++++++++++++++++ desktop/src/main.js | 29 +++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5a2f50f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,85 @@ +name: Build and Release + +on: + push: + tags: ['v*'] + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + # ---- Windows 桌面 app ---- + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Build Desktop App + run: | + cd desktop + npm install + npx tauri build --bundler nsis + - name: Upload NSIS Installer + uses: actions/upload-artifact@v4 + with: + name: CSSwitch-Windows-x64 + path: desktop/src-tauri/target/release/bundle/nsis/*.exe + + # ---- Linux Helper ---- + build-helper: + runs-on: ubuntu-latest + strategy: + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install musl-tools + run: sudo apt-get install -y musl-tools + - name: Build Helper + run: | + cd desktop/src-tauri + cargo build --bin csswitch-helper --no-default-features --release --target ${{ matrix.target }} + - name: Upload Helper + uses: actions/upload-artifact@v4 + with: + name: csswitch-helper-${{ matrix.target }} + path: desktop/src-tauri/target/${{ matrix.target }}/release/csswitch-helper + + # ---- Tests ---- + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - 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-helper, test] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + CSSwitch-Windows-x64/*.exe + csswitch-helper-*/* + draft: false + generate_release_notes: true diff --git a/desktop/src/main.js b/desktop/src/main.js index 1b4a1e6..fcaf874 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -60,6 +60,35 @@ let target = "local"; // "local" | "remote" let currentProfile = null; // RemoteHostProfile | null let remoteProfiles = []; // 缓存的 Profile 列表 +// ---- 远程状态机(Plan V2 §5.5)---- +const RemoteState = { DISCONNECTED: 'disconnected', CONNECTING: 'connecting', CONNECTED: 'connected', OPERATING: 'operating', ERROR: 'error' }; +let remoteState = RemoteState.DISCONNECTED; + +function setRemoteState(newState, data) { + remoteState = newState; + const dot = $('#remoteHealthDot'); + const txt = $('#remoteHealthText'); + if (!dot || !txt) return; + switch (newState) { + case RemoteState.DISCONNECTED: dot.className = 'lt a'; txt.textContent = '未连接'; break; + case RemoteState.CONNECTING: dot.className = 'lt a pulsing'; txt.textContent = '连接中…'; break; + case RemoteState.CONNECTED: dot.className = 'lt g'; txt.textContent = data || '已连接'; break; + case RemoteState.OPERATING: txt.textContent = '操作中…'; break; + case RemoteState.ERROR: dot.className = 'lt r'; txt.textContent = data || '错误'; break; + } +} + +// ---- Toast 通知(Plan V2 §5.6)---- +function showToast(message, type, duration) { + type = type || 'info'; duration = duration || 3000; + var t = document.createElement('div'); + t.className = 'toast toast-' + type; + t.textContent = message; + document.body.appendChild(t); + setTimeout(function() { t.classList.add('show'); }, 10); + setTimeout(function() { t.classList.remove('show'); setTimeout(function() { t.remove(); }, 300); }, duration); +} + const KEY_LABELS = { deepseek: "DeepSeek API Key", qwen: "DashScope (通义千问) API Key" }; function setMsg(text, kind) { From 27db765336feab8890d8383ca2f221aa00b6b5db Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 15:51:05 +0800 Subject: [PATCH 13/35] =?UTF-8?q?fix(security):=20CLI=20config=20=E8=AF=BB?= =?UTF-8?q?=E5=86=99=E5=A4=8D=E7=94=A8=20config.rs=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=88=E5=AE=A1=E6=9F=A5=20C1-C3=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查发现 CLI commands.rs 用裸 fs::write 读写 config.json, 绕过桌面端 config.rs 的所有安全防护(symlink 拒绝/0600/原子写)。 修复: - cmd_config_set: 用 config::save_to 替代裸 fs::write - cmd_config_save_key: 用 config::update(读-改-写锁内安全操作) - Helper 二进制导入 config + fs_ext 模块以使用共享安全函数 - 消除 CLI 与桌面端的双重标准安全差距 Co-Authored-By: Claude --- desktop/src-tauri/src/bin/csswitch-helper.rs | 6 ++- desktop/src-tauri/src/cli/commands.rs | 54 +++++--------------- 2 files changed, 19 insertions(+), 41 deletions(-) diff --git a/desktop/src-tauri/src/bin/csswitch-helper.rs b/desktop/src-tauri/src/bin/csswitch-helper.rs index 40732b6..d3034ed 100644 --- a/desktop/src-tauri/src/bin/csswitch-helper.rs +++ b/desktop/src-tauri/src/bin/csswitch-helper.rs @@ -11,7 +11,11 @@ //! 编译(无 Tauri 依赖): //! cargo build --bin csswitch-helper --no-default-features --release -// 通过 #[path] 引入 cli 模块(helper 不依赖 Tauri,无法用 crate:: 引用整个 lib)。 +// 通过 #[path] 引入共享模块(helper 不依赖 Tauri,无法用 crate:: 引用整个 lib)。 +#[path = "../config.rs"] +mod config; +#[path = "../fs_ext.rs"] +mod fs_ext; #[path = "../cli/mod.rs"] mod cli; diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index 48eee7b..b30e98a 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -168,60 +168,34 @@ pub fn cmd_config_get() -> CliEnvelope { } /// `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}")), }; - let dir = config_dir(); - let path = config_path(); - if let Err(e) = fs::create_dir_all(&dir) { - return CliEnvelope::err("config_write_error", &format!("创建配置目录失败:{e}")); - } - let json = match serde_json::to_vec_pretty(&v) { - Ok(j) => j, - Err(e) => return CliEnvelope::err("config_serialize_error", &format!("序列化失败:{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}")), }; - if let Err(e) = fs::write(&path, &json) { - return CliEnvelope::err("config_write_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 path = config_path(); let dir = config_dir(); - let _ = fs::create_dir_all(&dir); - - let mut cfg: Value = if path.exists() { - match fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or(json!({})), - Err(_) => json!({}), - } - } else { - json!({ - "provider": "deepseek", - "proxy_port": 18991, - "sandbox_port": 8990, - "mode": "proxy", - }) - }; - - // 确保 providers 对象存在 - if cfg.get("providers").is_none() { - cfg["providers"] = json!({}); - } - cfg["providers"][provider] = json!({"key": key}); - - let json_bytes = match serde_json::to_vec_pretty(&cfg) { - Ok(j) => j, - Err(e) => return CliEnvelope::err("config_serialize_error", &format!("序列化失败:{e}")), - }; - if let Err(e) = fs::write(&path, &json_bytes) { - return CliEnvelope::err("config_write_error", &format!("无法写入配置文件:{e}")); + let result = crate::config::update(&dir, |cfg| { + cfg.providers.entry(provider.to_string()).or_default().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..]) From 3d0e5251eb65e89f5b5eac08f48f14f7b7f344c9 Mon Sep 17 00:00:00 2001 From: bfzz <473812916@qq.com> Date: Sat, 4 Jul 2026 23:46:16 +0800 Subject: [PATCH 14/35] =?UTF-8?q?fix(remote):=20=E8=A1=A5=E5=85=A8=20Helpe?= =?UTF-8?q?r=20sandbox=20start=20=E5=92=8C=20remote=5Fone=5Fclick=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: cmd_sandbox_start 补全 4 项缺失 - 启动前调用 oauth_forge::ensure_virtual_login 注入虚拟 OAuth 凭证 - 添加 --host 0.0.0.0 允许外部 Windows 客户端访问 - 设置 https_proxy/HTTPS_PROXY (剥掉 /secret path,对齐 launch-virtual-sandbox.sh) - 设置 no_proxy/NO_PROXY="127.0.0.1,localhost,::1" - helper 二进制添加 oauth_forge 模块声明 Bug 2: remote_one_click 增加第三步启沙箱 - 提取随机 secret 为变量,供 proxy start 和 sandbox start 复用 - 代理启动成功后调用 helper sandbox start - _sandbox_port 改为 sandbox_port (不再 unused) 同步附带改动: - ssh.rs: CREATE_NO_WINDOW + recv_timeout 实现真正超时 - store.rs: remote-hosts.json 写入 0600 权限 - main.js/index.html: 远程模式一键开始/停止/状态刷新 UI Co-Authored-By: Claude --- desktop/src-tauri/src/bin/csswitch-helper.rs | 2 + desktop/src-tauri/src/cli/commands.rs | 30 ++++++++++ desktop/src-tauri/src/remote/ssh.rs | 58 ++++++++++++++------ desktop/src-tauri/src/remote/store.rs | 5 ++ desktop/src-tauri/src/remote_commands.rs | 47 +++++++++++----- desktop/src/index.html | 2 +- desktop/src/main.js | 48 ++++++++++++++++ 7 files changed, 162 insertions(+), 30 deletions(-) diff --git a/desktop/src-tauri/src/bin/csswitch-helper.rs b/desktop/src-tauri/src/bin/csswitch-helper.rs index d3034ed..ef1b58c 100644 --- a/desktop/src-tauri/src/bin/csswitch-helper.rs +++ b/desktop/src-tauri/src/bin/csswitch-helper.rs @@ -16,6 +16,8 @@ mod config; #[path = "../fs_ext.rs"] mod fs_ext; +#[path = "../oauth_forge.rs"] +mod oauth_forge; #[path = "../cli/mod.rs"] mod cli; diff --git a/desktop/src-tauri/src/cli/commands.rs b/desktop/src-tauri/src/cli/commands.rs index b30e98a..1cf68c5 100644 --- a/desktop/src-tauri/src/cli/commands.rs +++ b/desktop/src-tauri/src/cli/commands.rs @@ -460,6 +460,7 @@ pub fn cmd_sandbox_status() -> CliEnvelope { /// `sandbox start ` — 启动 Claude Science 沙箱。 /// 用 `ANTHROPIC_BASE_URL` 环境变量指向代理,以独立 data-dir 运行。 +/// 注入虚拟 OAuth 凭证使 Science 认为已登录,设置 --host 0.0.0.0 允许外部访问。 pub fn cmd_sandbox_start(port: u16, proxy_url: &str) -> CliEnvelope { let bin = match find_cmd("claude-science") { Some(b) => b, @@ -479,16 +480,45 @@ pub fn cmd_sandbox_start(port: u16, proxy_url: &str) -> CliEnvelope { // 确保运行时目录存在 let _ = std::fs::create_dir_all(&data_dir); + // 注入虚拟 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(), + }; + match std::process::Command::new(&bin) .args(["serve", "--data-dir"]) .arg(&data_dir) .arg("--port") .arg(port.to_string()) + .arg("--host") + .arg("0.0.0.0") .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(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() diff --git a/desktop/src-tauri/src/remote/ssh.rs b/desktop/src-tauri/src/remote/ssh.rs index 1a9de2c..53f169d 100644 --- a/desktop/src-tauri/src/remote/ssh.rs +++ b/desktop/src-tauri/src/remote/ssh.rs @@ -14,8 +14,20 @@ use std::time::Duration; use serde::de::DeserializeOwned; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + use super::types::{RemoteAuthMethod, RemoteError, RemoteHostProfile}; +/// Windows: 禁止弹出命令行窗口(CREATE_NO_WINDOW) +#[cfg(windows)] +const NO_WINDOW: u32 = 0x08000000; + +fn hide_cmd(mut cmd: Command) -> Command { + #[cfg(windows)] { cmd.creation_flags(NO_WINDOW); } + cmd +} + /// SSH 超时秒数(ConnectTimeout)。 const SSH_TIMEOUT_SECS: u64 = 10; /// Helper 命令执行超时(适用于大多数操作)。 @@ -238,7 +250,7 @@ fn try_run_ssh( timeout_secs: u64, ) -> Result { let args = build_ssh_args(profile, helper_args); - let output = Command::new("ssh") + let output = hide_cmd(Command::new("ssh")) .args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -254,26 +266,40 @@ fn try_run_ssh( ), })?; - // 审核 P1-6 修复:在线程中使用 recv_timeout 实际实施命令超时。 - // 在独立线程中执行 wait_with_output。 - let output = std::thread::spawn(move || output.wait_with_output()) - .join() - .map_err(|_| RemoteError { - code: "ssh_thread_panic".to_string(), - message: "SSH 执行线程异常".to_string(), - details: None, - recoverable: false, - suggestion: None, - })? - .map_err(|e| RemoteError { + // 使用 channel + recv_timeout 实现真正的命令超时 + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = output.wait_with_output(); + let _ = tx.send(result); + }); + + let output = match rx.recv_timeout(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()), - })?; - // 注:命令级超时(timeout_secs)通过上层的重试策略间接实现(超时后用户发起重试)。 - // 未来可在此加 recv_timeout 实现精确超时控制。 + })?, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + return Err(RemoteError { + code: "ssh_timeout".to_string(), + message: format!("SSH 命令执行超时({timeout_secs}秒)"), + details: None, + recoverable: true, + suggestion: Some("网络慢或远程命令卡住。请检查网络连接。".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(); diff --git a/desktop/src-tauri/src/remote/store.rs b/desktop/src-tauri/src/remote/store.rs index 7c90ac6..4377eaa 100644 --- a/desktop/src-tauri/src/remote/store.rs +++ b/desktop/src-tauri/src/remote/store.rs @@ -67,8 +67,13 @@ pub fn save_profiles(profiles: &[RemoteHostProfile]) -> Result<(), String> { )); fs::write(&tmp, &json) .map_err(|e| format!("写入远程配置临时文件失败:{e}"))?; + // 设置 0600 权限,防止其他用户读取 SSH 配置 + 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}"))?; Ok(()) } diff --git a/desktop/src-tauri/src/remote_commands.rs b/desktop/src-tauri/src/remote_commands.rs index 6a3f013..d27d094 100644 --- a/desktop/src-tauri/src/remote_commands.rs +++ b/desktop/src-tauri/src/remote_commands.rs @@ -138,9 +138,9 @@ pub fn remote_install_helper(profile: RemoteHostProfile) -> Result Result { .map_err(|e| e.message) } -/// 远程一键开始:保存 key → 起代理。 -/// 注:完整流程需要在客户端先生成 secret,此处为简化版本。 +/// 远程一键开始:保存 key → 起代理 → 启沙箱。 #[tauri::command] pub fn remote_one_click( profile: RemoteHostProfile, provider: String, key: String, proxy_port: u16, - _sandbox_port: u16, + sandbox_port: u16, ) -> Result { // 步骤 1:保存 key remote::ssh::run_helper_json_with_retry::( @@ -332,6 +331,14 @@ pub fn remote_one_click( }) .map_err(|e| e.message)?; + // 审核 P0-1 修复:使用加密随机 32 字符 hex secret 替代硬编码弱 secret。 + let secret: String = { + use rand::RngCore; + let mut b = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect::() + }; + // 步骤 2:起代理 remote::ssh::run_helper_json_with_retry::( &profile, @@ -340,13 +347,7 @@ pub fn remote_one_click( "start".to_string(), provider, proxy_port.to_string(), - // 审核 P0-1 修复:使用加密随机 32 字符 hex secret 替代硬编码弱 secret。 - { - use rand::RngCore; - let mut b = [0u8; 16]; - rand::rngs::OsRng.fill_bytes(&mut b); - b.iter().map(|x| format!("{x:02x}")).collect::() - }, + secret.clone(), ], ) .map_err(|e| remote::types::RemoteError { @@ -358,6 +359,26 @@ pub fn remote_one_click( }) .map_err(|e| e.message)?; + // 步骤 3:启动沙箱(Science 通过代理访问 Anthropic API) + let proxy_url = format!("http://127.0.0.1:{proxy_port}/{secret}"); + remote::ssh::run_helper_json_with_retry::( + &profile, + &[ + "sandbox".to_string(), + "start".to_string(), + sandbox_port.to_string(), + proxy_url, + ], + ) + .map_err(|e| remote::types::RemoteError { + code: e.code, + message: format!("启动沙箱失败:{}", e.message), + details: e.details, + recoverable: false, + suggestion: e.suggestion, + }) + .map_err(|e| e.message)?; + Ok(json!({ "ok": true, "port": proxy_port })) } diff --git a/desktop/src/index.html b/desktop/src/index.html index 7ee36d5..28ffbc6 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -47,7 +47,7 @@