diff --git a/package-lock.json b/package-lock.json index c6d0b0e5..351b6721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "clsx": "^2.1.1", "diff": "^8.0.4", "dompurify": "^3.4.11", + "fuzzysort": "^4.0.2", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", "katex": "^0.16.38", @@ -4453,6 +4454,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzysort": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/fuzzysort/-/fuzzysort-4.0.2.tgz", + "integrity": "sha512-3c7yD6rK1EXIsqpcu1ZVzXJugODWNMzvUij6yTiziaAHrpNS8kzFOI9mJLE8pmG4K9JOaVgzQRgklfOEk8f4Tg==", + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index cef26d84..719e7178 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "clsx": "^2.1.1", "diff": "^8.0.4", "dompurify": "^3.4.11", + "fuzzysort": "^4.0.2", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", "katex": "^0.16.38", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 83e21d12..9ce1f906 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2731,6 +2731,7 @@ dependencies = [ "log", "papaya", "rapidhash", + "regex", "reqwest 0.12.28", "serde", "serde_json", @@ -2746,6 +2747,8 @@ dependencies = [ "tauri-plugin-single-instance", "tokio", "tokio-tungstenite", + "tokio-util", + "uuid", ] [[package]] @@ -4885,6 +4888,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6fc67da5..d410ea64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ futures-util = "0.3" log = "0.4" papaya = "0.2.3" rapidhash = { version = "4.4.1", features = ["unsafe"] } +regex = "1" reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "stream" @@ -32,8 +33,10 @@ tauri-plugin-log = "2" tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +tokio-util = "0.7" +uuid = { version = "1", features = ["v4"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/app/commands/mod.rs b/src-tauri/src/app/commands/mod.rs index 14419a16..63c34bcc 100644 --- a/src-tauri/src/app/commands/mod.rs +++ b/src-tauri/src/app/commands/mod.rs @@ -3,3 +3,9 @@ pub mod bridge; pub mod opencode; #[cfg(not(target_os = "android"))] pub mod utils; +#[cfg(target_os = "windows")] +pub mod wsl_types; +#[cfg(target_os = "windows")] +pub mod wsl_runtime; +#[cfg(target_os = "windows")] +pub mod wsl_commands; diff --git a/src-tauri/src/app/commands/opencode.rs b/src-tauri/src/app/commands/opencode.rs index dd980365..b1e8d32b 100644 --- a/src-tauri/src/app/commands/opencode.rs +++ b/src-tauri/src/app/commands/opencode.rs @@ -33,20 +33,46 @@ struct SpawnedOpencodeServe { /// 检查 opencode 服务是否在运行(通过 health endpoint) pub async fn is_service_running(url: &str) -> bool { - let health_url = format!("{}/global/health", url.trim_end_matches('/')); - match reqwest::Client::builder() + is_service_running_with_auth(url, None).await +} + +/// 带可选 Basic 鉴权的健康检查,对齐官方实现: +/// 依次尝试 /api/health 与 /global/health;WSL 侧 serve 启用了密码保护, +/// 不带凭据会收到 401 而误判为未就绪。 +pub async fn is_service_running_with_auth(url: &str, auth: Option<(&str, &str)>) -> bool { + let base = url.trim_end_matches('/'); + let client = match reqwest::Client::builder() .connect_timeout(Duration::from_secs(3)) .build() { - Ok(client) => client - .get(&health_url) - .timeout(Duration::from_secs(5)) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false), - Err(_) => false, + Ok(c) => c, + Err(_) => return false, + }; + + let mut request = client.get(format!("{}/api/health", base)); + if let Some((username, password)) = auth { + request = request.basic_auth(username, Some(password)); + } + if request + .timeout(Duration::from_secs(5)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + { + return true; } + + let mut request = client.get(format!("{}/global/health", base)); + if let Some((username, password)) = auth { + request = request.basic_auth(username, Some(password)); + } + request + .timeout(Duration::from_secs(5)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) } /// 启动 opencode serve 进程 @@ -371,6 +397,14 @@ pub async fn confirm_close_app( state: State<'_, ServiceState>, stop_service: bool, ) -> Result<(), String> { + // 我们启动的 WSL 内 opencode 进程随应用退出统一清理,避免后台残留 + #[cfg(target_os = "windows")] + { + use tauri::Manager; + let wsl_state = window.app_handle().state::(); + super::wsl_commands::stop_all_wsl_servers(&wsl_state).await; + } + if stop_service { let pid = state.child_pid.swap(0, Ordering::SeqCst); if pid > 0 { diff --git a/src-tauri/src/app/commands/wsl_commands.rs b/src-tauri/src/app/commands/wsl_commands.rs new file mode 100644 index 00000000..23539f61 --- /dev/null +++ b/src-tauri/src/app/commands/wsl_commands.rs @@ -0,0 +1,1465 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use tauri::command; +use tauri::{AppHandle, Emitter, Manager}; +use tokio_util::sync::CancellationToken; + +use super::wsl_runtime; +use super::wsl_types::*; + +// ============================================ +// WSL 控制器 —— 对齐官方 packages/desktop/src/main/wsl/{servers,sidecar,startup,policy}.ts +// +// 架构映射:官方 Electron main 进程的 controller + 事件订阅,在 Tauri 中 +// 映射为 managed state + app.emit("wsl-state") 全量状态推送;前端订阅事件 +// 维护单一状态树(对齐官方 context.tsx 的 useQuery + subscribe 模式)。 +// ============================================ + +/// 持久化文件名 +const WSL_SERVERS_FILE: &str = "wsl-servers.json"; + +/// 仅序列化服务器配置(不含运行时状态),官方同款 `{ servers: [...] }` 结构 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedServers { + servers: Vec, +} + +/// 从磁盘加载已保存的服务器配置 +fn load_persisted_servers(app: &AppHandle) -> Vec { + let Some(dir) = app.path().app_config_dir().ok() else { + return Vec::new(); + }; + let path = dir.join(WSL_SERVERS_FILE); + let Ok(data) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + serde_json::from_str::(&data) + .map(|p| p.servers) + .unwrap_or_default() +} + +/// 将服务器配置持久化到磁盘 +fn persist_servers(app: &AppHandle, configs: &[WslServerConfig]) -> Result<(), String> { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("Failed to get config dir: {}", e))?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let data = PersistedServers { + servers: configs.to_vec(), + }; + let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; + let path = dir.join(WSL_SERVERS_FILE); + std::fs::write(&path, json).map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; + Ok(()) +} + +/// 持久化记录归一化(官方 normalizePersistedServer):兼容缺 id 的旧记录 +fn normalize_persisted_server(value: WslServerConfig) -> Option { + if value.distro.is_empty() { + return None; + } + if value.id.is_empty() { + return Some(WslServerConfig { + id: wsl_server_id_for_distro(&value.distro), + distro: value.distro, + }); + } + Some(value) +} + +// ============================================ +// 在线目录缓存 —— `wsl --list --online` 需要联网且可能很慢, +// 采用 stale-while-revalidate:新鲜缓存直接用;过期缓存先展示、 +// 后台静默刷新;无缓存才内联拉取。缓存落盘跨重启,冷启动后 +// 首次打开添加对话框也无需等待网络 +// ============================================ + +/// 在线目录缓存文件名(与服务器配置分开:缓存是可丢弃的派生数据, +/// 不应混进服务器定义的持久化文件) +const WSL_ONLINE_CACHE_FILE: &str = "wsl-online-cache.json"; +/// 缓存保鲜窗口:在线目录是微软的发行版列表,变化以周计,24h 足够 +const ONLINE_CACHE_TTL_MS: u64 = 24 * 60 * 60 * 1000; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct OnlineCache { + fetched_at: u64, + distros: Vec, +} + +fn load_online_cache(app: &AppHandle) -> Option { + let dir = app.path().app_config_dir().ok()?; + let data = std::fs::read_to_string(dir.join(WSL_ONLINE_CACHE_FILE)).ok()?; + serde_json::from_str::(&data).ok() +} + +/// 写缓存(尽力而为:失败只意味着下次仍要联网拉取,不影响本次结果) +fn save_online_cache(app: &AppHandle, distros: &[WslOnlineDistro]) { + let Ok(dir) = app.path().app_config_dir() else { + return; + }; + let _ = std::fs::create_dir_all(&dir); + let cache = OnlineCache { + fetched_at: now_ms(), + distros: distros.to_vec(), + }; + if let Ok(json) = serde_json::to_string(&cache) { + let _ = std::fs::write(dir.join(WSL_ONLINE_CACHE_FILE), json); + } +} + +/// 在线目录取数决策(纯函数,独立可测) +#[derive(Debug, Clone, PartialEq, Eq)] +enum OnlineAction { + /// 联网内联拉取(无缓存,或用户显式强制刷新) + Fetch, + /// 缓存新鲜:直接使用,不联网 + UseCache(Vec), + /// 缓存过期:先展示旧数据,后台静默刷新 + ServeStale(Vec), +} + +fn decide_online_action(cache: Option<&OnlineCache>, now: u64, force: bool) -> OnlineAction { + if force { + return OnlineAction::Fetch; + } + match cache { + Some(c) if now.saturating_sub(c.fetched_at) < ONLINE_CACHE_TTL_MS => { + OnlineAction::UseCache(c.distros.clone()) + } + Some(c) => OnlineAction::ServeStale(c.distros.clone()), + None => OnlineAction::Fetch, + } +} + +/// 运行中的 sidecar 句柄:cancel token 触发后监督任务杀掉子进程。 +/// token 是"所有权凭证"——stop/remove 会先从 map 移除再 cancel; +/// 启动流程的其余清理路径都先用 attempt 核对句柄仍是自己的才移除 +/// (见 should_remove_handle),被顶替的旧流程则保持静默。 +struct SidecarHandle { + token: CancellationToken, + attempt: u64, +} + +/// job 凭证:token + 代数(endJob 核对仍是自己开启的 job 才清空) +struct JobHandle { + token: CancellationToken, + generation: u64, +} + +impl std::ops::Deref for JobHandle { + type Target = CancellationToken; + fn deref(&self) -> &Self::Target { + &self.token + } +} + +// ============================================ +// 全局状态(controller) +// ============================================ + +/// 全局 WSL 状态(通过 Tauri managed state 共享) +pub struct WslState { + app: AppHandle, + pub state: Mutex, + /// 每台服务器的 sidecar 句柄(key 为 server id) + sidecars: Mutex>, + /// 每个 server id 的启动尝试计数(官方 startAttempts): + /// stop/remove 会使计数自增,旧的启动流程在关键节点校验 attempt, + /// 避免旧流程的 Ready/Failed 覆盖新流程的状态 + pub start_attempts: Mutex>, + /// 当前 job 的取消令牌:beginJob 时取消上一个 job 的在途命令 + /// (官方 beginJob 的 AbortController 语义) + job_token: Mutex>, + /// job 代数计数器 + job_generation: std::sync::atomic::AtomicU64, + /// 在线目录后台再验证在途标志:防止多次 ServeStale 并发拉起 + /// 多个会挂住的 `wsl --list --online` 进程(离线/网络失败时尤其浪费) + online_revalidating: std::sync::atomic::AtomicBool, +} + +impl WslState { + pub fn new(app: AppHandle) -> Self { + Self { + app, + state: Mutex::new(WslServersState::default()), + sidecars: Mutex::new(HashMap::new()), + start_attempts: Mutex::new(HashMap::new()), + job_token: Mutex::new(None), + job_generation: std::sync::atomic::AtomicU64::new(0), + online_revalidating: std::sync::atomic::AtomicBool::new(false), + } + } + + /// 全量推送当前状态到前端(官方 emit 的 {type:"state", state}) + fn emit(&self) { + if let Ok(s) = self.state.lock() { + let _ = self.app.emit("wsl-state", s.clone()); + } + } + + /// 局部更新状态并推送(官方 setState) + fn set_state(&self, update: impl FnOnce(&mut WslServersState)) { + if let Ok(mut s) = self.state.lock() { + update(&mut s); + } + self.emit(); + } + + /// 开启一个新 job:取消上一个 job 的在途命令(官方 beginJob) + fn begin_job(&self, job: WslJob) -> JobHandle { + let token = CancellationToken::new(); + let generation = self + .job_generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if let Ok(mut slot) = self.job_token.lock() { + if let Some(prev) = slot.take() { + prev.token.cancel(); + } + *slot = Some(JobHandle { + token: token.clone(), + generation, + }); + } + self.set_state(|s| s.job = Some(job)); + JobHandle { token, generation } + } + + /// 结束 job(官方 endJob:仅当仍是自己开启的 job 时清空。 + /// 被 newer job 抢占的旧 job 收尾时不得清掉新 job 的在途标记, + /// 否则前端会短暂误判"无任务在跑"而放行按钮) + fn end_job(&self, handle: &JobHandle) { + let owned = match self.job_token.lock() { + Ok(mut slot) => { + let owned = slot.as_ref().is_some_and(|c| c.generation == handle.generation); + if owned { + *slot = None; + } + owned + } + Err(_) => false, + }; + if owned { + self.set_state(|s| s.job = None); + } + } + + /// 只读快照访问:锁中毒按默认值(空状态)处理——调用方均为 + /// 尽力而为的判定(如 prewarm),不应因锁异常而失败 + fn read_state(&self, f: impl FnOnce(&WslServersState) -> T) -> T { + self.state.lock().map(|s| f(&s)).unwrap_or_default() + } + + /// 从磁盘恢复已保存的服务器配置到内存状态(官方 refreshFromStore) + pub fn restore_persisted(&self) { + let configs: Vec = load_persisted_servers(&self.app) + .into_iter() + .filter_map(normalize_persisted_server) + .collect(); + if configs.is_empty() { + return; + } + self.set_state(|s| { + s.servers = configs + .into_iter() + .map(|config| WslServerItem { + config, + runtime: WslServerRuntime::Stopped, + }) + .collect(); + }); + } + + /// 当前服务器 id 列表(应用启动时自动拉起用) + pub fn persisted_server_ids(&self) -> Vec { + match self.state.lock() { + Ok(s) => s.servers.iter().map(|item| item.config.id.clone()).collect(), + Err(_) => Vec::new(), + } + } +} + +// ============================================ +// 内部实现 —— opencode 检查(官方 opencodeCheck 三分支结果语义) +// ============================================ + +/// 组装 opencode 检查结果:!resolvedPath → 未安装;!version → 无法运行; +/// 命令级失败由调用方以 Err 传播(不写入检查记录) +async fn check_opencode( + distro: &str, + token: Option<&CancellationToken>, +) -> Result { + let resolved = wsl_runtime::resolve_opencode(distro, token).await?; + let version = match &resolved { + Some(path) => wsl_runtime::read_command_version(path, distro, token).await?, + None => None, + }; + + let error = if resolved.is_none() { + Some(format!("opencode is not installed in distribution '{}'", distro)) + } else if version.is_none() { + Some("opencode is installed but could not be executed".to_string()) + } else { + None + }; + + Ok(WslOpencodeCheck { + distro: distro.to_string(), + resolved_path: resolved, + version, + expected_version: None, + matches_desktop: None, + error, + }) +} + +/// 探测结果是否"能力齐备"(官方 distroProbeReady) +fn distro_probe_ready(probe: Option<&WslDistroProbe>) -> bool { + match probe { + Some(p) => p.can_execute && p.has_bash && p.has_curl, + None => false, + } +} + +/// 批量探测 + opencode 检查(官方 probeAddableDistros 增量语义): +/// 只探没有记录的 distro;只对能力齐备且没有检查记录的 distro 查 opencode。 +/// 任一命令级失败则整体失败、不写入任何记录(下轮自动重试)。 +async fn probe_addable_distros(state: &WslState, distros: &[String], token: &CancellationToken) -> Result<(), String> { + let unique: Vec = { + let mut seen = std::collections::HashSet::new(); + distros + .iter() + .filter(|d| seen.insert((*d).clone())) + .cloned() + .collect() + }; + + // 1) 探测没有记录的 distro + let to_probe: Vec = { + let s = state.state.lock().map_err(|e| e.to_string())?; + unique + .iter() + .filter(|d| !s.distro_probes.contains_key(*d)) + .cloned() + .collect() + }; + if !to_probe.is_empty() { + let probes = futures_util::future::join_all(to_probe.iter().map(|d| wsl_runtime::probe_distro(d, Some(token)))) + .await + .into_iter() + .collect::, String>>()?; + state.set_state(|s| { + for probe in probes { + s.distro_probes.insert(probe.name.clone(), probe); + } + }); + } + + // 2) 能力齐备且没有检查记录的 distro 查 opencode + let to_check: Vec = { + let s = state.state.lock().map_err(|e| e.to_string())?; + unique + .iter() + .filter(|d| distro_probe_ready(s.distro_probes.get(*d))) + .filter(|d| !s.opencode_checks.contains_key(*d)) + .cloned() + .collect() + }; + if !to_check.is_empty() { + let checks = futures_util::future::join_all(to_check.iter().map(|d| check_opencode(d, Some(token)))) + .await + .into_iter() + .collect::, String>>()?; + state.set_state(|s| { + for check in checks { + s.opencode_checks.insert(check.distro.clone(), check); + } + }); + } + + Ok(()) +} + +// ============================================ +// Tauri Commands — 探测与列表 +// ============================================ + +/// 探测 WSL 运行时(官方 probeRuntime) +#[command] +pub async fn probe_wsl_runtime(state: tauri::State<'_, WslState>) -> Result<(), String> { + probe_runtime_impl(&state).await +} + +/// 探测实现(命令与 prewarm 共用):begin_job 抢占语义保证同一时刻 +/// 只有一个 runtime 探测在途,后到的取消先前的 +async fn probe_runtime_impl(state: &WslState) -> Result<(), String> { + let token = state.begin_job(WslJob::Runtime { + started_at: now_ms(), + }); + let result = wsl_runtime::probe_wsl_runtime(Some(&token)).await; + match result { + Ok(runtime) => { + state.set_state(|s| { + s.pending_restart = s.pending_restart && !runtime.available; + s.runtime = Some(runtime); + }); + state.end_job(&token); + Ok(()) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } +} + +/// 刷新发行版列表(官方 refreshDistros)。 +/// force=true(用户显式「重新检测」)绕过在线目录缓存强制联网; +/// 默认走 stale-while-revalidate,详见 refresh_distros_inner +#[command] +pub async fn refresh_wsl_distros( + state: tauri::State<'_, WslState>, + force: Option, +) -> Result<(), String> { + refresh_distros_impl(&state, force.unwrap_or(false)).await +} + +async fn refresh_distros_impl(state: &WslState, force: bool) -> Result<(), String> { + let token = state.begin_job(WslJob::Distros { + started_at: now_ms(), + }); + let result = refresh_distros_inner(state, force, &token).await; + state.end_job(&token); + result +} + +async fn refresh_distros_inner( + state: &WslState, + force: bool, + token: &CancellationToken, +) -> Result<(), String> { + // 本地列表:无网络、快,永远新鲜拉取;失败是硬错误 + let installed = wsl_runtime::list_installed_distros(Some(token)).await?; + + let cache = load_online_cache(&state.app); + match decide_online_action(cache.as_ref(), now_ms(), force) { + OnlineAction::UseCache(online) => { + commit_distros(state, installed, online); + Ok(()) + } + OnlineAction::ServeStale(online) => { + commit_distros(state, installed, online); + // 过期缓存:后台静默再验证(不占 job,成功才更新状态与缓存) + revalidate_online_cache_background(state.app.clone()); + Ok(()) + } + OnlineAction::Fetch => match wsl_runtime::list_online_distros(Some(token)).await { + Ok(online) => { + commit_distros(state, installed, online.clone()); + save_online_cache(&state.app, &online); + Ok(()) + } + // 在线目录失败容错:有旧缓存就顶替(谁失败只报谁),无缓存才报错 + Err(e) => match cache { + Some(c) => { + commit_distros(state, installed, c.distros); + Ok(()) + } + None => Err(e), + }, + }, + } +} + +/// 写入两份列表并清理失败探测记录(checkAgain 语义:探测失败可能是 +/// WSL 冷启动等瞬时问题,重试应重新探测而不是永远卡在设置提示上) +fn commit_distros(state: &WslState, installed: Vec, online: Vec) { + state.set_state(|s| { + s.installed = installed; + s.online = online; + s.distro_probes.retain(|_, p| p.can_execute); + }); +} + +/// 后台再验证在线目录(stale-while-revalidate 的 revalidate 半边): +/// 静默更新状态与缓存,失败保持旧值不打扰用户 +fn revalidate_online_cache_background(app: AppHandle) { + let state = app.state::(); + // 已有在途 revalidate 则跳过:避免离线/网络失败机器上每次 ServeStale 都 spawn + // 一个会挂住的 `wsl --list --online`,也防止慢任务的先后顺序覆盖 force 拉取的结果 + if state.online_revalidating.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Ok(online) = wsl_runtime::list_online_distros(None).await { + state.set_state(|s| s.online = online.clone()); + save_online_cache(&app, &online); + } + state.online_revalidating.store(false, std::sync::atomic::Ordering::Release); + }); +} + +/// 设置→服务器页打开时的按需预热(意图信号驱动,取代旧的无条件启动预热): +/// - runtime 未知或此前不可用 → 探测一次(未装 WSL 的机器 `wsl --version` 秒回) +/// - runtime 可用且两份发行版列表皆空 → 刷新列表(在线目录走 TTL 缓存,通常不联网) +/// - 已添加发行版缺 opencode 检查 → 补检查(服务器卡片「安装/更新」按钮的数据源) +/// 幂等:状态已就位时零成本;与对话框 autoProbePlan 共享同一状态树, +/// 谁先到谁生效,不再是靠 begin_job 取消语义仲裁的双触发源 +#[command] +pub async fn prewarm_wsl(state: tauri::State<'_, WslState>) -> Result<(), String> { + // 不可用时重探:用户可能在两次打开设置之间装好/启用了 WSL; + // 可用时跳过:本进程已证明过,无需重复付出 wsl.exe 开销 + let runtime_available = || state.read_state(|s| s.runtime.as_ref().is_some_and(|r| r.available)); + if !runtime_available() { + let _ = probe_runtime_impl(&state).await; + } + if !runtime_available() { + return Ok(()); + } + + if state.read_state(|s| s.installed.is_empty() && s.online.is_empty()) { + let _ = refresh_distros_impl(&state, false).await; + } + + // 为已添加但缺检查的发行版补 opencode 检查。就绪服务器由 Ready 挂钩自行刷新 + // (run_start_server → refresh_opencode_check_background),这里只补未就绪的—— + // 否则启动窗口期设置页早开,会对同一发行版重复 spawn 一次检查 + let missing: Vec<(String, String)> = state.read_state(|s| { + s.servers + .iter() + .filter(|item| !matches!(item.runtime, WslServerRuntime::Ready { .. })) + .filter(|item| !s.opencode_checks.contains_key(&item.config.distro)) + .map(|item| (item.config.id.clone(), item.config.distro.clone())) + .collect() + }); + for (id, distro) in missing { + refresh_opencode_check_background(&state.app, id, distro); + } + Ok(()) +} + +/// 批量探测可添加的发行版(官方 probeAddable,前端 probe 计划驱动) +#[command] +pub async fn probe_wsl_addable(distros: Vec, state: tauri::State<'_, WslState>) -> Result<(), String> { + if distros.is_empty() { + return Ok(()); + } + let token = state.begin_job(WslJob::ProbeAddable { + distros: distros.clone(), + started_at: now_ms(), + }); + let result = probe_addable_distros(&state, &distros, &token).await; + state.end_job(&token); + result +} + +/// 打开 WSL 终端(官方 openTerminal) +#[command] +pub fn open_wsl_terminal(distro: Option) -> Result<(), String> { + wsl_runtime::open_wsl_terminal(distro.as_deref()) +} + +// ============================================ +// Tauri Commands — 安装类 +// ============================================ + +/// 从执行结果提取错误信息(stderr 优先,压缩为非空行) +fn result_error(result: &wsl_runtime::WslCommandResult, fallback: &str) -> String { + let raw = if result.stderr.is_empty() { + &result.stdout + } else { + &result.stderr + }; + let message = wsl_runtime::summarize(raw); + if message.is_empty() { + fallback.to_string() + } else { + message + } +} + +/// 安装 WSL 运行时(触发 UAC 提权,官方 installWsl:成功后探测,仍不可用 → 待重启) +#[command] +pub async fn install_wsl(state: tauri::State<'_, WslState>) -> Result<(), String> { + let token = state.begin_job(WslJob::InstallWsl { + started_at: now_ms(), + }); + let result = wsl_runtime::install_wsl_runtime(Some(&token)).await; + match result { + Ok(r) if r.code == Some(0) => { + // 探测失败也必须收尾 job:`?` 提前返回会跳过 end_job, + // job 永久悬挂会让所有按钮一直处于禁用状态 + match wsl_runtime::probe_wsl_runtime(Some(&token)).await { + Ok(runtime) => { + state.set_state(|s| { + s.pending_restart = !runtime.available; + s.runtime = Some(runtime); + }); + state.end_job(&token); + Ok(()) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } + } + Ok(r) => { + state.end_job(&token); + Err(result_error(&r, "WSL installation failed")) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } +} + +/// 安装成功后的收尾判定(纯函数,独立可测):本命令的成败只由本地操作决定 +/// ——已装列表 + 新发行版探测。在线目录不在输入里:装上发行版不会改变微软 +/// 的在线目录(评审 I1 的旧实现把 `wsl --list --online` 也塞进同一个 match, +/// 联网一失败就把已成功的 installed+probe 一起判死——前端报错,发行版其实 +/// 装好了)。在线目录的新鲜度由 refresh_distros 的 stale-while-revalidate +/// 独立负责,谁失败只报谁。 +fn decide_install_finish( + installed: Result, String>, + probe: Result, +) -> Result<(Vec, WslDistroProbe), String> { + match (installed, probe) { + (Ok(installed), Ok(probe)) => Ok((installed, probe)), + (Err(e), _) | (_, Err(e)) => Err(e), + } +} + +/// 安装指定发行版(官方 installDistro:成功后刷新列表并探测该发行版) +#[command] +pub async fn install_wsl_distro(name: String, state: tauri::State<'_, WslState>) -> Result<(), String> { + let token = state.begin_job(WslJob::InstallDistro { + distro: name.clone(), + started_at: now_ms(), + }); + let result = wsl_runtime::install_wsl_distro(&name, Some(&token)).await; + match result { + Ok(r) if r.code == Some(0) => { + // 只刷本地:已装列表 + 新发行版探测,不拉在线目录(见 decide_install_finish) + let installed = wsl_runtime::list_installed_distros(Some(&token)).await; + let probe = wsl_runtime::probe_distro(&name, Some(&token)).await; + match decide_install_finish(installed, probe) { + Ok((installed, probe)) => { + state.set_state(|s| { + s.installed = installed; + s.distro_probes.insert(name.clone(), probe); + }); + state.end_job(&token); + Ok(()) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } + } + Ok(r) => { + state.end_job(&token); + Err(result_error(&r, "WSL distribution installation failed")) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } +} + +/// 在发行版中安装 opencode(官方 installOpencode:成功后刷新检查并重启该 +/// 发行版已存在的服务器,让新安装生效) +#[command] +pub async fn install_wsl_opencode(distro: String, state: tauri::State<'_, WslState>) -> Result<(), String> { + let token = state.begin_job(WslJob::InstallOpencode { + distro: distro.clone(), + started_at: now_ms(), + }); + let result = wsl_runtime::install_wsl_opencode(&distro, Some(&token)).await; + match result { + Ok(r) if r.code == Some(0) => { + let check = check_opencode(&distro, Some(&token)).await; + match check { + Ok(check) => { + state.set_state(|s| { + s.opencode_checks.insert(distro.clone(), check); + }); + state.end_job(&token); + // 重启该发行版的服务器(官方 wslServerIdToRestart → startServer) + let id = { + let s = state.state.lock().map_err(|e| e.to_string())?; + s.servers + .iter() + .find(|item| item.config.distro == distro) + .map(|item| item.config.id.clone()) + }; + if let Some(id) = id { + start_server_internal(&state.app, &id); + } + Ok(()) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } + } + Ok(r) => { + state.end_job(&token); + Err(result_error(&r, "opencode installation failed")) + } + Err(e) => { + state.end_job(&token); + Err(e) + } + } +} + +// ============================================ +// sidecar 启动(官方 spawnWslSidecar + startServer) +// ============================================ + +/// 分配一个当前空闲的本地端口(官方 allocatePort:绑定 :0 由系统分配后立即释放) +fn allocate_port() -> Result { + std::net::TcpListener::bind(("127.0.0.1", 0)) + .map(|l| l.local_addr().map(|a| a.port()).map_err(|e| e.to_string())) + .map_err(|e| e.to_string())? +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// 收集 sidecar 输出流的行到尾部缓冲(保留 12 行,[stream] 前缀,官方 forwardLines) +async fn collect_recent_lines( + pipe: &mut (impl tokio::io::AsyncRead + Unpin), + stream: &str, + recent: std::sync::Arc>>, +) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut lines = BufReader::new(pipe).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(mut buf) = recent.lock() { + buf.push(format!("[{}] {}", stream, line)); + if buf.len() > 12 { + buf.remove(0); + } + } + } +} + +/// 更新指定服务器的运行时状态并推送 +fn set_runtime(state: &WslState, id: &str, runtime: WslServerRuntime) { + state.set_state(|s| { + if let Some(item) = s.servers.iter_mut().find(|item| item.config.id == id) { + item.runtime = runtime; + } + }); +} + +/// Ready 后后台刷新 opencode 检查(官方 refreshOpencodeCheckBackground): +/// 仅当该服务器仍存在时写入结果 +fn refresh_opencode_check_background(app: &AppHandle, id: String, distro: String) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + match check_opencode(&distro, None).await { + Ok(check) => { + let exists = state + .state + .lock() + .map(|s| s.servers.iter().any(|item| item.config.id == id)) + .unwrap_or(false); + if exists { + state.set_state(|s| { + s.opencode_checks.insert(distro.clone(), check); + }); + } + } + Err(_) => { + // 后台刷新失败不影响运行状态(官方仅记日志) + } + } + }); +} + +/// 启动服务器:立即返回,后台执行启动流程,状态经事件推送更新 +/// (官方 addServer/startServer 的 void startServer(id) 语义) +fn start_server_internal(app: &AppHandle, id: &str) { + let state = app.state::(); + let attempt = match state.start_attempts.lock() { + Ok(mut attempts) => { + let next = attempts.get(id).copied().unwrap_or(0) + 1; + attempts.insert(id.to_string(), next); + next + } + Err(_) => return, + }; + let app = app.clone(); + let id = id.to_string(); + tauri::async_runtime::spawn(async move { + run_start_server(&app, &id, attempt).await; + }); +} + +fn is_current_attempt(app: &AppHandle, id: &str, attempt: u64) -> bool { + let state = app.state::(); + let attempt_ok = state + .start_attempts + .lock() + .map(|m| m.get(id) == Some(&attempt)) + .unwrap_or(false); + let server_ok = state + .state + .lock() + .map(|s| s.servers.iter().any(|item| item.config.id == id)) + .unwrap_or(false); + attempt_ok && server_ok +} + +/// 健康轮询超时预算:生产路径与单元测试共用同一常量,防止测试与实现漂移。 +const HEALTH_TIMEOUT_MS: u64 = 30_000; + +/// 健康轮询超时判定(纯函数,独立可测):本回合已用时达到预算即超时。 +/// 轮询循环的另两个出口是"就绪"与"进程提前退出";进程活着但端口永不同 +/// 通时两者都不会触发,没有这个 deadline 判定,任务会以 10Hz 永远轮询、 +/// 服务器永远停在 Starting、任务永不回收。 +fn health_poll_expired(elapsed_ms: u64, timeout_ms: u64) -> bool { + elapsed_ms >= timeout_ms +} + +/// 启动单个 WSL 服务器(官方 spawnWslSidecar + startServer 合并): +/// +/// - stdin 下发 `bash -se` 启动脚本(PATH 清洗 /mnt/*、WSLENV=、禁 filewatcher、 +/// OPENCODE_CLIENT=desktop、随机密码注入、--print-logs --log-level WARN) +/// - 健康检查 100ms 轮询 / 30s 超时,Basic 鉴权(官方 checkHealth 同款) +/// - Promise.race([health, exit, timeout]) 语义:进程提前退出附带最近 12 行输出 +/// - Ready 后监督进程退出 → failed(官方 listener.onExit) +#[allow(clippy::too_many_lines)] +async fn run_start_server(app: &AppHandle, id: &str, attempt: u64) { + let state = app.state::(); + let is_current = || is_current_attempt(app, id, attempt); + + // 取配置(config 无端口字段,端口动态分配) + let distro = { + let s = match state.state.lock() { + Ok(s) => s, + Err(_) => return, + }; + let Some(item) = s.servers.iter().find(|item| item.config.id == id) else { + return; + }; + item.config.distro.clone() + }; + + // 先清掉残留的旧 sidecar(官方 startServer 先 stopServerInternal) + stop_sidecar_internal(&state, id).await; + if !is_current() { + return; + } + set_runtime(&state, id, WslServerRuntime::Starting); + + // 在 WSL 中解析 opencode 路径(官方 resolveWslOpencode,找不到即失败) + let opencode_path = match wsl_runtime::resolve_opencode(&distro, None).await { + Ok(Some(path)) => path, + Ok(None) => { + let message = format!("opencode is not installed in distribution '{}'", distro); + if is_current() { + set_runtime(&state, id, WslServerRuntime::Failed { message }); + } + return; + } + Err(e) => { + if is_current() { + set_runtime(&state, id, WslServerRuntime::Failed { message: e }); + } + return; + } + }; + if !is_current() { + return; + } + + // 动态分配端口 + 随机密码(官方 allocatePort + randomUUID + "opencode") + let port = match allocate_port() { + Ok(p) => p, + Err(e) => { + if is_current() { + set_runtime(&state, id, WslServerRuntime::Failed { message: e }); + } + return; + } + }; + let password = uuid::Uuid::new_v4().to_string(); + let username = "opencode"; + + // 启动脚本:官方同款逐行还原 + let script = [ + "set -euo pipefail".to_string(), + r#"cd "$HOME" || cd /"#.to_string(), + // 剔除 PATH 中的 Windows 盘符挂载路径(/mnt/...),防止 Windows 侧同名二进制抢先 + r#"PATH=$(awk -v RS=: -v ORS=: '$0 !~ /^\/mnt\//' <<<"$PATH" | sed "s/:$//")"#.to_string(), + "export PATH".to_string(), + "export WSLENV=".to_string(), + "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true".to_string(), + "export OPENCODE_CLIENT=desktop".to_string(), + format!("export OPENCODE_SERVER_USERNAME={}", wsl_runtime::shell_escape(username)), + format!("export OPENCODE_SERVER_PASSWORD={}", wsl_runtime::shell_escape(&password)), + r#"export XDG_STATE_HOME="$HOME/.local/state""#.to_string(), + // 打包版 WARN / 开发版 INFO(官方 app.isPackaged 分支) + format!( + "exec {} --print-logs --log-level {} serve --hostname 0.0.0.0 --port {}", + wsl_runtime::shell_escape(&opencode_path), + if cfg!(debug_assertions) { "INFO" } else { "WARN" }, + port + ), + ] + .join("\n"); + + let mut cmd = tokio::process::Command::new("wsl"); + cmd.args(["-d", &distro, "--", "bash", "-se"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + // 与 run_process 同款(评审 I7):监督任务所在 future 被丢弃(如运行时 + // 关停)时,child 随之 drop,kill_on_drop 保证 wsl.exe 被杀掉—— + // 否则会留下一个没人持有句柄的 opencode serve 孤儿进程占着端口 + cmd.kill_on_drop(true); + + #[cfg(target_os = "windows")] + { + // tokio::process::Command 在 Windows 上自带 creation_flags(GUI 下避免闪黑框) + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + if is_current() { + set_runtime( + &state, + id, + WslServerRuntime::Failed { + message: format!("Failed to start WSL process: {}", e), + }, + ); + } + return; + } + }; + + // 下发启动脚本后立即关闭 stdin,让 bash 开始执行(官方 child.stdin.end(script)) + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(script.as_bytes()).await; + let _ = stdin.shutdown().await; + } + + // 后台收集最近输出(保留尾部 12 行),进程提前退出时附带用于定位 + // (官方 forwardLines:按行收集,[stdout]/[stderr] 前缀) + let recent: std::sync::Arc>> = std::sync::Arc::default(); + { + let Some(mut pipe) = child.stdout.take() else { + return; + }; + let recent = recent.clone(); + tauri::async_runtime::spawn(async move { + collect_recent_lines(&mut pipe, "stdout", recent).await; + }); + } + { + let Some(mut pipe) = child.stderr.take() else { + return; + }; + let recent = recent.clone(); + tauri::async_runtime::spawn(async move { + collect_recent_lines(&mut pipe, "stderr", recent).await; + }); + } + + // 注册 sidecar 句柄(stop/remove 通过 cancel token 杀进程) + let token = CancellationToken::new(); + if let Ok(mut sidecars) = state.sidecars.lock() { + sidecars.insert( + id.to_string(), + SidecarHandle { + token: token.clone(), + attempt, + }, + ); + } + + // Promise.race([health, exit, timedOut]):健康检查 / 提前退出 / 30s 超时。 + // 出循环后 failure 为 None 即"健康检查赢"(ready),Some 即失败腿 + let url = format!("http://127.0.0.1:{}", port); + // 用 tokio 单调时钟(评审建议)而非 SystemTime:NTP 校时/手动改系统时间 + // 会让墙上时钟回跳或前跳,导致超时判定失真;elapsed() 只随真实推进增长 + let poll_started_at = tokio::time::Instant::now(); + let mut failure: Option = None; + while failure.is_none() { + // attempt 失效(stop/remove/restart):终止并静默退出 + if !is_current() { + let _ = child.kill().await; + cleanup_sidecar(&state, id, attempt); + return; + } + // 进程在就绪前退出:附带最近输出(官方 startupFailure serverExitedBeforeHealthy) + if let Ok(Some(status)) = child.try_wait() { + let tail = recent.lock().map(|buf| buf.join("\n")).unwrap_or_default(); + let suffix = if tail.is_empty() { + String::new() + } else { + format!("\n{}", tail) + }; + failure = Some(format!( + "opencode serve exited before becoming healthy (exit code {}).{}", + status.code().map(|c| c.to_string()).unwrap_or_else(|| "null".to_string()), + suffix + )); + break; + } + if super::opencode::is_service_running_with_auth(&url, Some((username, &password))).await { + // ready:failure 保持 None 退出循环,走后面的 Ready 路径 + break; + } + // race 的第三腿 timedOut:进程存活但端口从不通(WSL 端口转发失败的 + // 常见形态)。必须在循环内判超时——没有这个出口,任务会永远停在下面的 + // 100ms 轮询里,服务器卡在 Starting,UI 按钮永不恢复。 + // (官方 healthTimeout) + if health_poll_expired(poll_started_at.elapsed().as_millis() as u64, HEALTH_TIMEOUT_MS) { + failure = Some(format!( + "opencode serve in distribution '{}' did not become healthy within {}ms", + distro, HEALTH_TIMEOUT_MS + )); + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + if let Some(message) = failure { + let _ = child.kill().await; + cleanup_sidecar(&state, id, attempt); + if is_current() { + set_runtime(&state, id, WslServerRuntime::Failed { message }); + } + return; + } + + // attempt 失效:杀掉本次进程避免泄漏 + if !is_current() { + let _ = child.kill().await; + cleanup_sidecar(&state, id, attempt); + return; + } + + // Ready:更新状态并推送(官方 setRuntime ready + sidecars.set) + set_runtime( + &state, + id, + WslServerRuntime::Ready { + url: url.clone(), + username: Some(username.to_string()), + password: Some(password.clone()), + }, + ); + refresh_opencode_check_background(app, id.to_string(), distro.clone()); + + // 监督:Ready 后进程退出 → failed(官方 listener.onExit → serverExited)。 + // token 触发(stop/remove)时先移除句柄再杀进程,监督任务静默退出; + // 自然退出时句柄还在 map 里(是自己)→ 标记 failed。 + // wait() 本身也可能返回 Err(系统调用失败等),此时子进程状态未知—— + // 旧实现 `if let Ok(status)` 让 Err 静默蒸发:句柄留在 map、runtime + // 永远停在 Ready 指向一个死/未知进程(评审 I7)。Err 与意外退出同等处理。 + let watch_token = token.clone(); + let mut child = child; + let waited = tokio::select! { + status = child.wait() => status, + _ = watch_token.cancelled() => { + let _ = child.kill().await; + cleanup_sidecar(&state, id, attempt); + return; + } + }; + // 只有当 map 里的句柄仍是本次启动注册的(attempt 匹配)才处理退出; + // cleanup_sidecar 在同一把锁内完成归属判断+摘除,返回归属结果, + // 避免旧流程晚醒时误删新流程句柄后还把退出误报成本次服务的失败 + let owned = cleanup_sidecar(&state, id, attempt); + if owned && is_current() { + let message = match waited { + Ok(status) => format!( + "opencode serve exited (exit code {})", + status.code().map(|c| c.to_string()).unwrap_or_else(|| "null".to_string()) + ), + Err(e) => format!("failed to wait on opencode serve process: {}", e), + }; + set_runtime(&state, id, WslServerRuntime::Failed { message }); + } +} + +/// 句柄归属判定(纯函数,独立可测):map 里存的 attempt 等于本次 attempt 才允许移除。 +/// 被顶替的旧流程晚醒时(例如卡在健康 HTTP 调用里、期间用户重试且新流程已注册 +/// 自己的句柄),若无条件按 id 移除就会把新流程的"所有权凭证"删掉——此后 +/// stop/remove/退出清理都找不到 token,opencode serve 泄漏在发行版里占着端口, +/// 而 remove 命令照样返回 Ok。 +fn should_remove_handle(held: Option, attempt: u64) -> bool { + held == Some(attempt) +} + +/// 从 sidecars 移除本次启动的句柄——仅当句柄仍归属本次 attempt(见 should_remove_handle), +/// 返回是否真正移除(即归属是否仍是本次)。无条件移除点只有 stop_sidecar_internal: +/// 主动停止方自己摘走句柄;被废弃流程的收尾清理必须先证明句柄还是自己的。 +fn cleanup_sidecar(state: &tauri::State<'_, WslState>, id: &str, attempt: u64) -> bool { + if let Ok(mut sidecars) = state.sidecars.lock() { + if should_remove_handle(sidecars.get(id).map(|h| h.attempt), attempt) { + sidecars.remove(id); + return true; + } + } + false +} + +/// 停止 sidecar:从 map 移除句柄并 cancel token(监督任务杀进程)。 +/// 官方 stopServerInternal 只 stop listener、不改 runtime。 +async fn stop_sidecar_internal(state: &tauri::State<'_, WslState>, id: &str) { + let handle = state + .sidecars + .lock() + .ok() + .and_then(|mut m| m.remove(id)); + if let Some(handle) = handle { + handle.token.cancel(); + // 给监督任务一点时间完成 kill,避免端口/状态竞争 + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } +} + +// ============================================ +// Tauri Commands — 服务器管理 +// ============================================ + +/// 添加 WSL 服务器(官方 addServer:确定性 id、防重复、立即启动、事件推送) +#[command] +pub async fn add_wsl_server( + distro: String, + state: tauri::State<'_, WslState>, +) -> Result { + let id = wsl_server_id_for_distro(&distro); + + // 防止重复添加(官方 alreadyAdded) + { + let s = state.state.lock().map_err(|e| e.to_string())?; + if s.servers.iter().any(|item| item.config.id == id) { + return Err(format!("Server for distribution '{}' has already been added", distro)); + } + } + + let config = WslServerConfig { + id: id.clone(), + distro: distro.clone(), + }; + + // 先持久化、后改内存(评审 I7):旧顺序先注册 Starting + spawn 启动再写盘, + // 写盘失败会留下一个配置从未落盘、重启后必然消失的"幽灵服务器"——用户 + // 对着一个不可能存在的运行中条目做停止/删除。落盘失败时内存与进程都不动。 + let configs = { + let s = state.state.lock().map_err(|e| e.to_string())?; + let mut configs: Vec = s.servers.iter().map(|item| item.config.clone()).collect(); + configs.push(config.clone()); + configs + }; + persist_servers(&state.app, &configs)?; + + // 落盘成功 → 注册 runtime(runtime 直接 starting,官方同款)→ 立即启动。 + // set_state 内部已 emit,无需再单独推送 + state.set_state(|s| { + s.servers.push(WslServerItem { + config: config.clone(), + runtime: WslServerRuntime::Starting, + }); + }); + + start_server_internal(&state.app, &id); + Ok(config) +} + +/// 移除 WSL 服务器(官方 removeServer:invalidate attempt + stop + persist + +/// 清除该 distro 的探测/检查记录) +#[command] +pub async fn remove_wsl_server(id: String, state: tauri::State<'_, WslState>) -> Result<(), String> { + // 官方 invalidateStartAttempt:使进行中的启动流程失效 + if let Ok(mut attempts) = state.start_attempts.lock() { + let next = attempts.get(&id).copied().unwrap_or(0) + 1; + attempts.insert(id.clone(), next); + } + + stop_sidecar_internal(&state, &id).await; + + // 先取出被删服务器的 distro 名,再删除并持久化剩余配置 + let (removed_distro, remaining): (Option, Vec) = { + let mut s = state.state.lock().map_err(|e| e.to_string())?; + let removed = s + .servers + .iter() + .find(|item| item.config.id == id) + .map(|item| item.config.distro.clone()); + s.servers.retain(|item| item.config.id != id); + let remaining = s.servers.iter().map(|item| item.config.clone()).collect(); + (removed, remaining) + }; + let Some(removed_distro) = removed_distro else { + return Err(format!("Server '{}' not found", id)); + }; + persist_servers(&state.app, &remaining)?; + + // 官方 clearWslDistroState:清除该 distro 的 distroProbes / opencodeChecks + state.set_state(|s| { + s.distro_probes.remove(&removed_distro); + s.opencode_checks.remove(&removed_distro); + }); + Ok(()) +} + +/// 启动 WSL 服务器(官方 startServer:立即返回,状态经事件推送) +#[command] +pub async fn start_wsl_server(id: String, state: tauri::State<'_, WslState>) -> Result<(), String> { + let exists = { + let s = state.state.lock().map_err(|e| e.to_string())?; + s.servers.iter().any(|item| item.config.id == id) + }; + if !exists { + return Err(format!("Server '{}' not found", id)); + } + start_server_internal(&state.app, &id); + Ok(()) +} + +/// 获取 WSL 状态(官方 getState) +#[command] +pub async fn get_wsl_state(state: tauri::State<'_, WslState>) -> Result { + let state = state.state.lock().map_err(|e| e.to_string())?; + Ok(state.clone()) +} + +/// 停止所有 WSL 服务器(应用退出时调用,官方 stopAll) +pub async fn stop_all_wsl_servers(state: &tauri::State<'_, WslState>) { + let ids: Vec = match state.state.lock() { + Ok(s) => s.servers.iter().map(|item| item.config.id.clone()).collect(), + Err(_) => return, + }; + // 使所有启动流程失效并逐台停止 + if let Ok(mut attempts) = state.start_attempts.lock() { + for id in &ids { + let next = attempts.get(id).copied().unwrap_or(0) + 1; + attempts.insert(id.clone(), next); + } + } + for id in &ids { + let _ = stop_sidecar_internal(&state, id).await; + } +} + +/// 应用启动初始化:只做「恢复用户上次状态」的最小工作——读持久化配置并 +/// 拉起全部已添加服务器(sidecar 链自行处理就绪等待与状态推送)。 +/// +/// 一切 WSL 探测(runtime / 发行版列表 / opencode 检查)都是按需成本, +/// 由设置页打开时的 prewarm_wsl 触发:从未添加 WSL 服务器的机器, +/// 启动路径的 WSL 开销为零;添加过的机器也不在启动时重复付出 +/// 检查成本(每台服务器 Ready 后已有 refresh_opencode_check_background) +pub fn initialize_wsl(app: &AppHandle) { + let state = app.state::(); + state.restore_persisted(); + for id in state.persisted_server_ids() { + start_server_internal(app, &id); + } +} + +// ============================================ +// 单元测试 —— 纯决策逻辑(在线目录缓存 / sidecar 句柄所有权 / 健康轮询超时) +// 模块已在 mod.rs 按 target_os = "windows" 门控,测试只需 cfg(test) +// ============================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn online(name: &str) -> WslOnlineDistro { + WslOnlineDistro { + name: name.to_string(), + label: format!("{name} label"), + } + } + + fn cache(fetched_at: u64) -> OnlineCache { + OnlineCache { + fetched_at, + distros: vec![online("Ubuntu")], + } + } + + #[test] + fn no_cache_fetches_inline() { + assert_eq!(decide_online_action(None, 1_000, false), OnlineAction::Fetch); + } + + #[test] + fn fresh_cache_used_without_network() { + let now = 1_000 + ONLINE_CACHE_TTL_MS - 1; + match decide_online_action(Some(&cache(1_000)), now, false) { + OnlineAction::UseCache(distros) => assert_eq!(distros.len(), 1), + other => panic!("expected UseCache, got {other:?}"), + } + } + + #[test] + fn stale_cache_served_with_background_revalidate() { + let now = 1_000 + ONLINE_CACHE_TTL_MS; + assert!(matches!( + decide_online_action(Some(&cache(1_000)), now, false), + OnlineAction::ServeStale(_) + )); + } + + #[test] + fn force_always_fetches() { + assert_eq!( + decide_online_action(Some(&cache(u64::MAX)), u64::MAX, true), + OnlineAction::Fetch + ); + } + + #[test] + fn future_timestamp_treated_as_fresh() { + // 时钟回拨(fetched_at 在未来)不应导致每次都重新联网 + assert!(matches!( + decide_online_action(Some(&cache(2_000)), 1_000, false), + OnlineAction::UseCache(_) + )); + } + + #[test] + fn cache_round_trips_through_json() { + let original = OnlineCache { + fetched_at: 123, + distros: vec![online("Debian"), online("openSUSE")], + }; + let json = serde_json::to_string(&original).unwrap(); + let parsed: OnlineCache = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.fetched_at, 123); + assert_eq!(parsed.distros.len(), 2); + assert_eq!(parsed.distros[0].name, "Debian"); + } + + // ---- sidecar 句柄所有权(评审 B4)---- + + #[test] + fn owning_attempt_removes_its_own_handle() { + // 归属仍是本次 attempt:允许摘除 + assert!(should_remove_handle(Some(7), 7)); + } + + #[test] + fn stale_attempt_does_not_remove_newer_handle() { + // 交错场景:旧 attempt(7) 从网络等待中晚醒,map 里已是重试后 + // attempt(8) 注册的句柄——旧流程绝不能把它当成自己的摘掉, + // 否则 stop/remove 找不到 token,opencode serve 泄漏在发行版里占着端口 + assert!(!should_remove_handle(Some(8), 7)); + } + + #[test] + fn missing_handle_is_not_attributed_to_caller() { + // stop_sidecar_internal(唯一无条件移除点)已摘走句柄: + // 迟到的清理既无从移除,也不得误报"句柄归属我" + assert!(!should_remove_handle(None, 7)); + } + + // ---- 健康轮询超时(评审 B3)---- + + #[test] + fn health_poll_not_expired_before_deadline() { + // 预算未用尽:进程还没就绪也必须继续等,不得误判超时 + assert!(!health_poll_expired(0, HEALTH_TIMEOUT_MS)); + assert!(!health_poll_expired(HEALTH_TIMEOUT_MS - 100, HEALTH_TIMEOUT_MS)); + } + + #[test] + fn health_poll_expired_at_and_after_deadline() { + // 恰好到点即超时(>=),过期后维持超时 + assert!(health_poll_expired(HEALTH_TIMEOUT_MS, HEALTH_TIMEOUT_MS)); + assert!(health_poll_expired(HEALTH_TIMEOUT_MS + 400, HEALTH_TIMEOUT_MS)); + } + + #[test] + fn health_poll_converges_when_process_alive_but_never_healthy() { + // 模拟最常见的挂死形态:进程存活、每轮健康探测都失败。旧循环只有 + // ready/退出两个出口,此形态永远自旋;现在退出只依赖超时谓词, + // 按 100ms 节奏推进必须收敛,否则由回合上限判失败(防挂死) + let mut elapsed = 0u64; + let mut rounds = 0u32; + while !health_poll_expired(elapsed, HEALTH_TIMEOUT_MS) { + elapsed += 100; + rounds += 1; + assert!(rounds < 400, "轮询必须因超时收敛,不得无限进行"); + } + assert!(elapsed >= HEALTH_TIMEOUT_MS); + } + + // ---- 安装收尾判定(评审 I1)---- + + fn installed_distro(name: &str) -> WslInstalledDistro { + WslInstalledDistro { + name: name.to_string(), + version: Some(2), + is_default: false, + } + } + + fn capable_probe(name: &str) -> WslDistroProbe { + WslDistroProbe { + name: name.to_string(), + can_execute: true, + has_bash: true, + has_curl: true, + error: None, + } + } + + #[test] + fn install_finish_stands_without_online_catalog() { + // 收尾成败只由本地两路结果决定;在线目录不在签名里,联网拉目录 + // 失败已不可能把"安装成功"判死(旧实现把 online 塞进同一个 match) + let (installed, probe) = decide_install_finish( + Ok(vec![installed_distro("Ubuntu")]), + Ok(capable_probe("Ubuntu")), + ) + .expect("installed+probe 齐备即成功"); + assert_eq!(installed.len(), 1); + assert_eq!(probe.name, "Ubuntu"); + } + + #[test] + fn install_finish_reports_only_local_failures() { + // 本地操作失败仍如实上报(谁失败只报谁),错误不被吞掉 + let err = decide_install_finish(Err("list failed".to_string()), Ok(capable_probe("Ubuntu"))).unwrap_err(); + assert_eq!(err, "list failed"); + let err = decide_install_finish(Ok(vec![]), Err("probe failed".to_string())).unwrap_err(); + assert_eq!(err, "probe failed"); + } + + #[test] + fn wsl_job_field_serialization_matches_frontend_contract() { + // 评审"描述不实"之 2:变体名 kebab-case(tag "kind")、字段名 camelCase—— + // 对齐 src/features/wsl/types.ts 判别联合的声明;修复前实发 started_at + let json = serde_json::to_string(&WslJob::Runtime { started_at: 7 }).unwrap(); + assert_eq!(json, r#"{"kind":"runtime","startedAt":7}"#); + } +} diff --git a/src-tauri/src/app/commands/wsl_runtime.rs b/src-tauri/src/app/commands/wsl_runtime.rs new file mode 100644 index 00000000..2f9ca023 --- /dev/null +++ b/src-tauri/src/app/commands/wsl_runtime.rs @@ -0,0 +1,589 @@ +use std::process::{Command as StdCommand, Stdio}; +use std::time::Duration; + +use regex::Regex; +use tokio::process::Command as TokioCommand; +use tokio_util::sync::CancellationToken; + +use super::wsl_types::*; + +// ============================================ +// WSL 运行时层 —— 对齐官方 packages/desktop/src/main/wsl/runtime.ts +// 所有命令统一走 run_process:超时兜底 + job 取消 + GUI 下隐藏控制台窗口 +// ============================================ + +const DEFAULT_WSL_TIMEOUT_MS: u64 = 20_000; + +/// 安装类命令的超时上限(发行版/opencode 下载安装可能持续数分钟,官方 15min) +const INSTALL_TIMEOUT_MS: u64 = 15 * 60_000; + +/// 从 UTF-16LE / UTF-8 混杂的 wsl.exe 输出中解出文本。 +/// wsl.exe 在控制台输出 UTF-16LE(可能带 BOM),管道输出多为 UTF-8; +/// 官方 detectOutputEncoding 用统计法判断,这里保持同一算法。 +fn decode_console_output(bytes: &[u8]) -> String { + if bytes.len() < 2 { + return String::from_utf8_lossy(bytes).to_string(); + } + if bytes[0] == 0xff && bytes[1] == 0xfe { + // 显式 UTF-16LE BOM,直接解码 + let units: Vec = bytes[2..] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + return String::from_utf16_lossy(&units); + } + + let pairs = bytes.len() / 2; + if pairs < 2 { + return String::from_utf8_lossy(bytes).to_string(); + } + let odd_zeroes = (0..pairs).filter(|&i| bytes[i * 2 + 1] == 0).count(); + let even_zeroes = (0..pairs).filter(|&i| bytes[i * 2] == 0).count(); + if odd_zeroes >= pairs.div_ceil(3) && even_zeroes * 2 <= odd_zeroes { + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + String::from_utf16_lossy(&units).trim_start_matches('\u{FEFF}').to_string() + } else { + String::from_utf8_lossy(bytes).to_string() + } +} + +/// WSL 命令执行结果 +pub struct WslCommandResult { + pub code: Option, + pub stdout: String, + pub stderr: String, +} + +/// 组装 wsl.exe 参数:指定了发行版/用户时 args 是 Linux 内命令,需要 "--" 分隔; +/// 否则 args 是 wsl.exe 自身参数(--version / --list),加 "--" 会被当 Linux 命令。 +pub fn wsl_args(args: &[String], distro: Option<&str>, user: Option<&str>) -> Vec { + let mut wsl_args = Vec::new(); + if let Some(d) = distro { + wsl_args.push("-d".to_string()); + wsl_args.push(d.to_string()); + } + if let Some(u) = user { + wsl_args.push("--user".to_string()); + wsl_args.push(u.to_string()); + } + if distro.is_some() || user.is_some() { + wsl_args.push("--".to_string()); + } + wsl_args.extend_from_slice(args); + wsl_args +} + +/// 运行 WSL 命令(官方 runWsl/runWslInDistro) +pub async fn run_wsl_command( + args: &[String], + distro: Option<&str>, + user: Option<&str>, + timeout_ms: Option, + token: Option<&CancellationToken>, +) -> Result { + let wsl_args = wsl_args(args, distro, user); + run_process("wsl", &wsl_args, timeout_ms.unwrap_or(DEFAULT_WSL_TIMEOUT_MS), token).await +} + +/// 运行外部命令:统一处理超时、job 取消与 GUI 下隐藏控制台窗口。 +/// +/// - 超时/取消时 kill_on_drop 杀掉子进程,避免挂死的 wsl.exe 永远滞留 +/// (对齐官方 child.kill();wsl.exe 在 LXSS 服务卡住时可能永不退出) +/// - 错误信息带上完整命令,日志能直接指认是哪条命令挂了 +async fn run_process( + program: &str, + args: &[String], + timeout_ms: u64, + token: Option<&CancellationToken>, +) -> Result { + let mut cmd = TokioCommand::new(program); + cmd.args(args); + // 必须显式把 stdout/stderr 配置为管道:wait_with_output 只回收 piped 的流, + // 漏配时子进程输出直接丢失(列表/探测全部静默变空,且前端会因两个列表同时 + // 为空而无限触发自动刷新) + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // GUI 应用中 spawn 控制台程序必须隐藏新控制台窗口,否则每次调用都会闪黑框 + // (tokio::process::Command 在 Windows 上自带 creation_flags) + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd.kill_on_drop(true); + + let display = format!("{} {}", program, args.join(" ")); + let child = cmd + .spawn() + .map_err(|e| format!("Failed to execute {}: {}", program, e))?; + + let cancel = async { + match token { + Some(t) => t.cancelled().await, + None => std::future::pending::<()>().await, + } + }; + let result = tokio::select! { + r = child.wait_with_output() => r.map_err(|e| format!("Failed to execute {}: {}", program, e))?, + _ = cancel => return Err("WSL command aborted".to_string()), + _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => { + return Err(format!("`{}` timed out after {}ms", display, timeout_ms)); + } + }; + + Ok(WslCommandResult { + code: result.status.code(), + stdout: decode_console_output(&result.stdout), + stderr: decode_console_output(&result.stderr), + }) +} + +/// 运行 PowerShell 命令(WSL 运行时安装需要 UAC 提权,走 Start-Process -Verb RunAs) +async fn run_powershell_command(script: &str, timeout_ms: u64, token: Option<&CancellationToken>) -> Result { + run_process( + "powershell.exe", + &[ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-Command".to_string(), + script.to_string(), + ], + timeout_ms, + token, + ) + .await +} + +/// 提权安装 WSL 运行时(不含发行版),对齐官方 installWslRuntimeElevated +pub async fn install_wsl_runtime(token: Option<&CancellationToken>) -> Result { + let script = [ + "$ErrorActionPreference = 'Stop'", + "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", + "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", + ] + .join("; "); + run_powershell_command(&script, INSTALL_TIMEOUT_MS, token).await +} + +/// 解析 System32 下的 wsl.exe 绝对路径,避免 PATH 上的同名程序劫持 +/// (官方 resolveSystem32Command,安装命令使用) +fn resolve_system32_command(command: &str) -> String { + #[cfg(target_os = "windows")] + { + let root = std::env::var("SystemRoot") + .or_else(|_| std::env::var("windir")) + .unwrap_or_else(|_| r"C:\Windows".to_string()); + let resolved = std::path::Path::new(&root) + .join("System32") + .join(command); + if resolved.exists() { + return resolved.to_string_lossy().to_string(); + } + } + command.to_string() +} + +/// 安装指定发行版(--no-launch 装完不自动进入,避免卡在首次交互;官方同款参数) +pub async fn install_wsl_distro(name: &str, token: Option<&CancellationToken>) -> Result { + let program = resolve_system32_command("wsl.exe"); + let args = vec![ + "--install".to_string(), + "-d".to_string(), + name.to_string(), + "--web-download".to_string(), + "--no-launch".to_string(), + ]; + run_process(&program, &args, INSTALL_TIMEOUT_MS, token).await +} + +/// 在发行版中安装 opencode(官方安装脚本,安装最新版) +pub async fn install_wsl_opencode(distro: &str, token: Option<&CancellationToken>) -> Result { + run_wsl_command( + &[ + "bash".to_string(), + "-lc".to_string(), + "curl -fsSL https://opencode.ai/install | bash".to_string(), + ], + Some(distro), + None, + Some(INSTALL_TIMEOUT_MS), + token, + ) + .await +} + +/// 把多行输出压缩成非空行集合(错误信息展示用,对齐官方 summarize) +pub fn summarize(value: &str) -> String { + value + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +/// 取第一行非空文本(对齐官方 firstLine) +fn first_line(value: &str) -> Option { + value.lines().map(|l| l.trim()).find(|l| !l.is_empty()).map(|s| s.to_string()) +} + +/// 探测 WSL 运行时是否可用(官方 probeWslRuntime) +pub async fn probe_wsl_runtime(token: Option<&CancellationToken>) -> Result { + let result = run_wsl_command(&["--version".to_string()], None, None, Some(DEFAULT_WSL_TIMEOUT_MS), token).await; + + match result { + Ok(r) if r.code == Some(0) => Ok(WslRuntimeCheck { + available: true, + version: first_line(&r.stdout), + error: None, + }), + Ok(r) => { + let error_raw = if r.stderr.is_empty() { r.stdout } else { r.stderr }; + let message = summarize(&error_raw); + Ok(WslRuntimeCheck { + available: false, + version: None, + error: Some(if message.is_empty() { + "WSL is unavailable".to_string() + } else { + message + }), + }) + } + Err(e) => Ok(WslRuntimeCheck { + available: false, + version: None, + error: Some(e), + }), + } +} + +/// 列出已安装的 WSL 发行版(官方 listInstalledWslDistros) +pub async fn list_installed_distros(token: Option<&CancellationToken>) -> Result, String> { + let result = run_wsl_command( + &["--list".to_string(), "--verbose".to_string()], + None, + None, + Some(DEFAULT_WSL_TIMEOUT_MS), + token, + ) + .await?; + + if result.code != Some(0) { + let raw = if result.stderr.is_empty() { result.stdout } else { result.stderr }; + let message = summarize(&raw); + return Err(if message.is_empty() { + "Failed to list installed distros".to_string() + } else { + message + }); + } + + parse_installed_distros(&result.stdout) +} + +/// 解析已安装的发行版列表(对齐官方正则:名字可含空格,以两空格分隔列) +fn parse_installed_distros(output: &str) -> Result, String> { + // 官方正则: /^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/ + let re = Regex::new(r"^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$").map_err(|e| e.to_string())?; + let mut distros = Vec::new(); + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(caps) = re.captures(line) { + let name = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(""); + if name.is_empty() || name.eq_ignore_ascii_case("name") { + continue; + } + let version = caps + .get(3) + .and_then(|m| m.as_str().parse::().ok()); + distros.push(WslInstalledDistro { + name: name.to_string(), + version, + is_default: caps.get(1).is_some(), + }); + } + } + Ok(distros) +} + +/// 列出在线可用的发行版(官方 listOnlineWslDistros) +pub async fn list_online_distros(token: Option<&CancellationToken>) -> Result, String> { + let result = run_wsl_command( + &["--list".to_string(), "--online".to_string()], + None, + None, + Some(DEFAULT_WSL_TIMEOUT_MS), + token, + ) + .await?; + + if result.code != Some(0) { + let raw = if result.stderr.is_empty() { result.stdout } else { result.stderr }; + let message = summarize(&raw); + return Err(if message.is_empty() { + "Failed to list online distros".to_string() + } else { + message + }); + } + + parse_online_distros(&result.stdout) +} + +/// 解析在线发行版列表(对齐官方正则:名字限 [A-Za-z0-9._-],两空格后为友好名) +fn parse_online_distros(output: &str) -> Result, String> { + // 官方正则: /^([A-Za-z0-9._-]+)\s{2,}(.+)$/ + let re = Regex::new(r"^([A-Za-z0-9._-]+)\s{2,}(.+)$").map_err(|e| e.to_string())?; + let mut distros = Vec::new(); + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(caps) = re.captures(trimmed) { + let name = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + if name.eq_ignore_ascii_case("name") { + continue; + } + distros.push(WslOnlineDistro { + name: name.to_string(), + label: caps.get(2).map(|m| m.as_str().trim().to_string()).unwrap_or_default(), + }); + } + } + Ok(distros) +} + +/// 探测单个发行版的能力(官方 probeWslDistro)。 +/// +/// 语义对齐:/bin/true 退出码非 0(或命令错误,官方 .catch 转为 code=1) +/// 记录为 can_execute=false 并附带 error 摘要;bash/curl 命令级失败整体失败。 +pub async fn probe_distro(name: &str, token: Option<&CancellationToken>) -> Result { + let executable = match run_wsl_command( + &["/bin/true".to_string()], + Some(name), + None, + Some(DEFAULT_WSL_TIMEOUT_MS), + token, + ) + .await + { + Ok(r) => r, + // 官方 .catch 把命令错误转成 code=1 + stderr=错误消息 + Err(e) => WslCommandResult { + code: Some(1), + stdout: String::new(), + stderr: e, + }, + }; + + if executable.code != Some(0) { + let raw = if executable.stderr.is_empty() { + executable.stdout + } else { + executable.stderr + }; + let msg = summarize(&raw); + let error = if msg.is_empty() { + format!("Cannot execute commands in distribution '{}'", name) + } else { + msg + }; + return Ok(WslDistroProbe { + name: name.to_string(), + can_execute: false, + has_bash: false, + has_curl: false, + error: Some(error), + }); + } + + // bash / curl 检查并行执行(官方 Promise.all),任一命令级失败整体失败 + let script = |tool: &str| format!("command -v {} >/dev/null && printf yes || printf no", tool); + let bash_args = ["sh".to_string(), "-lc".to_string(), script("bash")]; + let curl_args = ["sh".to_string(), "-lc".to_string(), script("curl")]; + let (bash, curl) = tokio::join!( + run_wsl_command(&bash_args, Some(name), None, Some(DEFAULT_WSL_TIMEOUT_MS), token), + run_wsl_command(&curl_args, Some(name), None, Some(DEFAULT_WSL_TIMEOUT_MS), token), + ); + let bash = bash?; + let curl = curl?; + + Ok(WslDistroProbe { + name: name.to_string(), + can_execute: true, + has_bash: bash.code == Some(0) && bash.stdout.trim() == "yes", + has_curl: curl.code == Some(0) && curl.stdout.trim() == "yes", + error: None, + }) +} + +/// 检查发行版中是否安装了 opencode(官方 resolveWslOpencode) +pub async fn resolve_opencode(distro: &str, token: Option<&CancellationToken>) -> Result, String> { + let result = run_wsl_command( + &[ + "sh".to_string(), + "-lc".to_string(), + r#"if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\n" "$HOME/.opencode/bin/opencode"; fi"# + .to_string(), + ], + Some(distro), + None, + Some(DEFAULT_WSL_TIMEOUT_MS), + token, + ) + .await?; + + Ok(first_line(&result.stdout)) +} + +/// 读取命令版本(官方 readWslCommandVersion;命令路径需要 shell 转义) +pub async fn read_command_version( + command: &str, + distro: &str, + token: Option<&CancellationToken>, +) -> Result, String> { + let result = run_wsl_command( + &[ + "sh".to_string(), + "-lc".to_string(), + format!("{} --version 2>/dev/null || true", shell_escape(command)), + ], + Some(distro), + None, + Some(DEFAULT_WSL_TIMEOUT_MS), + token, + ) + .await?; + + Ok(first_line(&result.stdout)) +} + +/// 打开 WSL 终端(官方 openWslTerminal:cmd /c start 新窗口拉起 wsl) +pub fn open_wsl_terminal(distro: Option<&str>) -> Result<(), String> { + let mut args = vec!["/c".to_string(), "start".to_string(), "".to_string(), "wsl".to_string()]; + if let Some(d) = distro { + args.push("-d".to_string()); + args.push(d.to_string()); + } + + StdCommand::new("cmd.exe") + .args(&args) + .spawn() + .map_err(|e| format!("Failed to open terminal: {}", e))?; + + Ok(()) +} + +/// Shell 转义(启动脚本与安装命令中拼接 shell 源码时使用,官方 shellEscape) +pub fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // 回归契约:run_process 必须把子进程 stdout/stderr 管道化—— + // 漏配时 wait_with_output 返回空输出,发行版列表/能力探测会静默全部变空 + #[cfg(target_os = "windows")] + #[tokio::test] + async fn test_run_process_captures_output() { + let result = run_process( + "cmd", + &["/c".to_string(), "echo hello".to_string()], + 5_000, + None, + ) + .await + .expect("run cmd"); + assert_eq!(result.code, Some(0)); + assert_eq!(result.stdout.trim(), "hello"); + } + + #[test] + fn test_parse_installed_distros() { + // 真实 wsl -l -v 输出:列间以多空格对齐,名字可含空格 + let output = concat!( + "\n", + " NAME STATE VERSION\n", + "* Ubuntu-22.04 Running 2\n", + " docker-desktop Running 2\n", + " Debian GNU/Linux Stopped 1\n", + ); + let distros = parse_installed_distros(output).unwrap(); + assert_eq!(distros.len(), 3); + assert_eq!(distros[0].name, "Ubuntu-22.04"); + assert!(distros[0].is_default); + assert_eq!(distros[0].version, Some(2)); + assert!(!distros[1].is_default); + assert_eq!(distros[1].name, "docker-desktop"); + // 带空格的发行版名(官方正则支持) + assert_eq!(distros[2].name, "Debian GNU/Linux"); + assert_eq!(distros[2].version, Some(1)); + } + + #[test] + fn test_parse_online_distros() { + let output = "NAME FRIENDLY NAME\nUbuntu Ubuntu\nUbuntu-24.04 Ubuntu 24.04 LTS\n"; + let distros = parse_online_distros(output).unwrap(); + assert_eq!(distros.len(), 2); + assert_eq!(distros[0].name, "Ubuntu"); + assert_eq!(distros[1].label, "Ubuntu 24.04 LTS"); + } + + #[test] + fn test_decode_console_output_utf8() { + assert_eq!(decode_console_output(b"hello\nworld"), "hello\nworld"); + } + + #[test] + fn test_decode_console_output_utf16le_bom() { + let text = "Ubuntu\n"; + let mut bytes = vec![0xff, 0xfe]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + assert_eq!(decode_console_output(&bytes), text); + } + + #[test] + fn test_decode_console_output_utf16le_no_bom() { + let text = "NAME STATE"; + let mut bytes: Vec = Vec::new(); + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + // ASCII 文本的 UTF-16LE 编码高位字节全为 0 → 统计法判定为 UTF-16LE + assert_eq!(decode_console_output(&bytes), text); + } + + #[test] + fn test_wsl_args_separator() { + assert_eq!( + wsl_args(&["--version".to_string()], None, None), + vec!["--version".to_string()] + ); + assert_eq!( + wsl_args(&["/bin/true".to_string()], Some("Ubuntu"), None), + vec!["-d".to_string(), "Ubuntu".to_string(), "--".to_string(), "/bin/true".to_string()] + ); + } + + #[test] + fn test_shell_escape() { + assert_eq!(shell_escape("opencode"), "'opencode'"); + assert_eq!(shell_escape("it's"), "'it'\\''s'"); + } +} diff --git a/src-tauri/src/app/commands/wsl_types.rs b/src-tauri/src/app/commands/wsl_types.rs new file mode 100644 index 00000000..ebc4e2d7 --- /dev/null +++ b/src-tauri/src/app/commands/wsl_types.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; + +// ============================================ +// WSL 类型定义 —— 与官方桌面端 packages/app/src/wsl/types.ts 一一对应 +// 所有结构体序列化为 camelCase,前端 TypeScript 类型可直接消费 +// ============================================ + +/// WSL 运行时检查结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslRuntimeCheck { + pub available: bool, + pub version: Option, + pub error: Option, +} + +/// 已安装的 WSL 发行版 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslInstalledDistro { + pub name: String, + /// WSL 1 或 2 + pub version: Option, + pub is_default: bool, +} + +/// 在线可用的发行版 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WslOnlineDistro { + pub name: String, + pub label: String, +} + +/// 发行版探测结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslDistroProbe { + pub name: String, + pub can_execute: bool, + pub has_bash: bool, + pub has_curl: bool, + pub error: Option, +} + +/// OpenCode 检查结果 +/// +/// matchesDesktop / expectedVersion 保留官方结构:OpenCodeUI 版本号与 +/// opencode 版本无关联(官方桌面端与 opencode 同仓库同版本发布,我们不 +/// 是),因此 expected_version 恒为 null、matches_desktop 恒为 null, +/// 前端的 updateOpencode 分支结构保留但不会触发。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslOpencodeCheck { + pub distro: String, + pub resolved_path: Option, + pub version: Option, + pub expected_version: Option, + pub matches_desktop: Option, + pub error: Option, +} + +/// WSL 服务器配置(官方同款:确定性 id = `wsl:`,无端口字段—— +/// 端口在每次启动时动态分配空闲端口) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WslServerConfig { + pub id: String, + pub distro: String, +} + +/// WSL 服务器运行时状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum WslServerRuntime { + Starting, + Ready { + url: String, + username: Option, + password: Option, + }, + Failed { + message: String, + }, + Stopped, +} + +/// WSL 服务器项 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslServerItem { + pub config: WslServerConfig, + pub runtime: WslServerRuntime, +} + +/// WSL 任务类型 +/// variant 名 kebab-case(tag "kind" 的值)、字段名 camelCase—— +/// 与 src/features/wsl/types.ts 的判别联合声明对齐(评审"描述不实"之 2) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", rename_all_fields = "camelCase")] +pub enum WslJob { + Runtime { started_at: u64 }, + Distros { started_at: u64 }, + InstallWsl { started_at: u64 }, + InstallDistro { distro: String, started_at: u64 }, + ProbeAddable { distros: Vec, started_at: u64 }, + InstallOpencode { distro: String, started_at: u64 }, +} + +/// WSL 服务器全局状态(官方 WslServersState 同款字段) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslServersState { + pub runtime: Option, + pub installed: Vec, + pub online: Vec, + pub distro_probes: std::collections::HashMap, + pub opencode_checks: std::collections::HashMap, + pub pending_restart: bool, + pub servers: Vec, + pub job: Option, +} + +impl Default for WslServersState { + fn default() -> Self { + Self { + runtime: None, + installed: Vec::new(), + online: Vec::new(), + distro_probes: std::collections::HashMap::new(), + opencode_checks: std::collections::HashMap::new(), + pending_restart: false, + servers: Vec::new(), + job: None, + } + } +} + +/// 服务器 id:确定性生成,同一发行版天然防重复添加(官方 wslServerIdForDistro) +pub fn wsl_server_id_for_distro(distro: &str) -> String { + format!("wsl:{}", distro) +} diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index d1de2d01..b108137a 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -364,6 +364,17 @@ pub fn run() { } } + // Windows: 注册 WSL 控制器状态(emit 需要持有 AppHandle,manage 在 + // setup 阶段完成),随后执行官方 initialize 对齐的启动逻辑 + #[cfg(target_os = "windows")] + app.manage(commands::wsl_commands::WslState::new(app.handle().clone())); + + // Windows: 启动只做「恢复上次状态」——恢复持久化配置并拉起全部已添加 + // 服务器;WSL 探测(runtime/发行版/opencode 检查)为按需成本,由设置页 + // 打开时前端调用 prewarm_wsl 触发(未添加过 WSL 服务器的机器启动零开销) + #[cfg(target_os = "windows")] + commands::wsl_commands::initialize_wsl(app.handle()); + Ok(()) }); @@ -466,6 +477,36 @@ pub fn run() { commands::opencode::stop_opencode_service, commands::opencode::get_service_started_by_us, commands::opencode::confirm_close_app, + // Windows: WSL 发行版管理命令(对齐官方 ipc.ts 的命令面: + // getState / probeRuntime / refreshDistros / installWsl / installDistro / + // probeAddable / installOpencode / openTerminal / add / remove / start)。 + // 注意:invoke_handler 是整体替换语义,WSL 命令必须合并在同一个 + // generate_handler! 列表里;如果另起一次 invoke_handler 会把 + // 上面的命令全部覆盖,导致前端窗口显示/服务管理等命令 404。 + #[cfg(target_os = "windows")] + commands::wsl_commands::probe_wsl_runtime, + #[cfg(target_os = "windows")] + commands::wsl_commands::refresh_wsl_distros, + #[cfg(target_os = "windows")] + commands::wsl_commands::prewarm_wsl, + #[cfg(target_os = "windows")] + commands::wsl_commands::probe_wsl_addable, + #[cfg(target_os = "windows")] + commands::wsl_commands::open_wsl_terminal, + #[cfg(target_os = "windows")] + commands::wsl_commands::add_wsl_server, + #[cfg(target_os = "windows")] + commands::wsl_commands::remove_wsl_server, + #[cfg(target_os = "windows")] + commands::wsl_commands::start_wsl_server, + #[cfg(target_os = "windows")] + commands::wsl_commands::get_wsl_state, + #[cfg(target_os = "windows")] + commands::wsl_commands::install_wsl, + #[cfg(target_os = "windows")] + commands::wsl_commands::install_wsl_distro, + #[cfg(target_os = "windows")] + commands::wsl_commands::install_wsl_opencode, ]); // Android: 注册 bridge commands @@ -507,5 +548,28 @@ pub fn run() { } } } + + // Windows: 进程退出兜底(评审 I4;真机验收实测坐实泄漏后才加回)。 + // 缺口:we_started=false(只跑 WSL、没跑 Local 服务)时关窗不拦截、 + // confirm_close_app → stop_all_wsl_servers 这条清理链永远不被触发。 + // kill_on_drop 也救不了:sidecar 的 Child 移进了监督任务、阻塞在 wait(), + // 进程收尾时 tokio 运行时先死,drop 里的 kill 没有执行机会 + // (实测:关窗后 wsl.exe 客户端与 distro 内 opencode serve 双双存活)。 + // ExitRequested 是运行时还活着的最后时机:block_on 在本线程驱动 + // stop_all_wsl_servers(token.cancel → 监督任务显式 kill wsl.exe, + // 每台 50ms 上限有界),整体再套 10s timeout 防任何意外挂死退出流程。 + // 与 confirm_close_app 已清理过的路径幂等:sidecars 空 map → no-op。 + #[cfg(target_os = "windows")] + if let tauri::RunEvent::ExitRequested { .. } = &_event { + if let Some(state) = _app_handle.try_state::() { + tauri::async_runtime::block_on(async { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + commands::wsl_commands::stop_all_wsl_servers(&state), + ) + .await; + }); + } + } }); } diff --git a/src/api/events.test.ts b/src/api/events.test.ts index b502d053..d76c6a7a 100644 --- a/src/api/events.test.ts +++ b/src/api/events.test.ts @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EventTypes } from '../types/api/event' +// 按 serverId 返回 URL 的 http mock:订阅迁移测试需要断言「新连接建到了哪台服务器」, +// 固定 URL 的 mock 无法区分 local 与 wsl:Ubuntu。未登记的 serverId 回退到 example.test, +// 保持与既有流式解析测试(不断言 URL)兼容。 +const httpMocks = vi.hoisted(() => ({ + baseUrls: new Map(), +})) + vi.mock('./http', () => ({ - getApiBaseUrl: () => 'http://example.test', + getApiBaseUrl: (serverId?: string) => httpMocks.baseUrls.get(serverId ?? '') ?? 'http://example.test', getAuthHeader: () => ({}), })) @@ -245,3 +252,108 @@ describe('subscribeToEvents', () => { unsubscribe() }) }) + +describe('subscribeToEvents server-change migration', () => { + const LOCAL_URL = 'http://local.test' + const WSL_URL = 'http://wsl.test' + + function fetchedUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map(call => String(call[0])) + } + + beforeEach(() => { + // 与相邻 describe 平级,外层的 vi.resetModules 不会作用于本块测试; + // 必须自行重置,否则 events/serverStore 的模块级状态(连接池、变更监听器)跨测试泄漏 + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + httpMocks.baseUrls.set('local', LOCAL_URL) + httpMocks.baseUrls.set('wsl:Ubuntu', WSL_URL) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + httpMocks.baseUrls.clear() + }) + + it('keeps the active subscription untouched when a non-active server reports server-runtime-updated', async () => { + // 场景:active 是 local,WSL sidecar 首次注册 wsl:Ubuntu。upsertServer 对任何 + // runtime 变化的服务器都无条件广播 server-runtime-updated,订阅绝不能被「迁移」到非 active 服务器 + const openStream = createDeferred>() + const fetchMock = vi.fn().mockReturnValue(openStream.promise) + vi.stubGlobal('fetch', fetchMock) + + const { subscribeToEvents } = await import('./events') + const { serverStore } = await import('../store/serverStore') + + const unsubscribe = subscribeToEvents({}) + + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`]) + + serverStore.upsertServer({ id: 'wsl:Ubuntu', name: 'Ubuntu (WSL)', url: WSL_URL }) + await Promise.resolve() + + // local 连接未被拆、未向 wsl:Ubuntu 发起新连接 + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`]) + + unsubscribe() + openStream.resolve(createFetchResponse([])) + }) + + it('re-subscribes the active subscription when the active server reports server-runtime-updated', async () => { + // 合法迁移场景:active local 的端点变了(如本地运行时 URL override 落到新端口), + // 旧 SSE 还连着死地址,必须拆旧建新、用新 URL 重订阅 + const firstStream = createDeferred>() + const secondStream = createDeferred>() + const fetchMock = vi + .fn() + .mockImplementationOnce(() => firstStream.promise) + .mockImplementationOnce(() => secondStream.promise) + vi.stubGlobal('fetch', fetchMock) + + const { subscribeToEvents } = await import('./events') + const { serverStore } = await import('../store/serverStore') + + const unsubscribe = subscribeToEvents({}) + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`]) + + httpMocks.baseUrls.set('local', `${LOCAL_URL}:9999`) + serverStore.upsertServer({ id: 'local', name: 'Local', url: `${LOCAL_URL}:9999`, isDefault: true }) + await Promise.resolve() + + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`, `${LOCAL_URL}:9999/global/event`]) + + unsubscribe() + firstStream.resolve(createFetchResponse([])) + secondStream.resolve(createFetchResponse([])) + }) + + it('migrates the subscription on a real server-switch', async () => { + // 焦点真实切换(setActiveServer)→ 订阅必须跟着迁移到新 active 服务器 + const firstStream = createDeferred>() + const secondStream = createDeferred>() + const fetchMock = vi + .fn() + .mockImplementationOnce(() => firstStream.promise) + .mockImplementationOnce(() => secondStream.promise) + vi.stubGlobal('fetch', fetchMock) + + const { subscribeToEvents } = await import('./events') + const { serverStore } = await import('../store/serverStore') + + const unsubscribe = subscribeToEvents({}) + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`]) + + // 先注册非 active 的 wsl:Ubuntu(不应动订阅),再把焦点切过去(应迁移) + serverStore.upsertServer({ id: 'wsl:Ubuntu', name: 'Ubuntu (WSL)', url: WSL_URL }) + serverStore.setActiveServer('wsl:Ubuntu') + await Promise.resolve() + + expect(fetchedUrls(fetchMock)).toEqual([`${LOCAL_URL}/global/event`, `${WSL_URL}/global/event`]) + + unsubscribe() + firstStream.resolve(createFetchResponse([])) + secondStream.resolve(createFetchResponse([])) + }) +}) diff --git a/src/api/events.ts b/src/api/events.ts index 9220b88e..fd9d1b62 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -1036,8 +1036,18 @@ export function subscribeToEvents(callbacks: EventCallbacks): () => void { let currentServerId = serverStore.getActiveServerId() let unsubscribe = subscribeToServerEvents(currentServerId, callbacks) - const offServerChange = serverStore.onServerChange(newServerId => { - if (newServerId === currentServerId) return + const offServerChange = serverStore.onServerChange((newServerId, reason) => { + // server-runtime-updated 表示端口/鉴权变了(WSL sidecar 重启、本地运行时 URL 切换), + // 活动中的 SSE 还连着旧地址,必须拆旧建新;建连时 URL/鉴权由 getApiBaseUrl/getAuthHeader + // 从 serverStore 现读,重订阅即拿到新值。但该广播由 upsertServer 对任意服务器无条件发出 + // (含非 active 服务器首次注册,如 WSL 就绪):本 shim 的订阅跟随 active server, + // 变化的不是当前订阅的服务器时只能忽略,否则会把订阅错误地「迁移」到非 active 服务器上 + if (reason === 'server-runtime-updated') { + if (newServerId !== currentServerId) return + } else if (newServerId === currentServerId) { + // 同 id 且非 runtime 变更 → 无需重订阅 + return + } unsubscribe() currentServerId = newServerId unsubscribe = subscribeToServerEvents(currentServerId, callbacks) diff --git a/src/api/wsl.ts b/src/api/wsl.ts new file mode 100644 index 00000000..bd968f3e --- /dev/null +++ b/src/api/wsl.ts @@ -0,0 +1,68 @@ +// ============================================ +// WSL API —— Tauri 平台实现,对应官方 ipc.ts 暴露的命令面 +// 状态更新经 "wsl-state" 事件全量推送(官方 subscribe/event.sender.send 模式) +// ============================================ + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import type { WslServerConfig, WslServersState, WslServersEvent } from '../features/wsl/types' + +// 探测/列表类命令保留独立入口(后端仍单独注册,供弹窗外探测使用) +export const wslApi = { + /** 探测 WSL 运行时 */ + probeRuntime: () => invoke('probe_wsl_runtime'), + + /** 刷新发行版列表(本地 + 在线)。force=true 绕过在线目录 TTL 缓存强制联网 + * (用户显式「重新检测」);默认走 stale-while-revalidate */ + refreshDistros: (force = false) => invoke('refresh_wsl_distros', { force }), + + /** 按需预热(打开设置→服务器页时触发,幂等):补齐 runtime 探测、 + * 发行版列表与已添加服务器的 opencode 检查。启动路径不再做任何 WSL 探测 */ + prewarm: () => invoke('prewarm_wsl'), + + /** 批量探测可添加的发行版(增量语义,官方 probeAddable) */ + probeAddable: (distros: string[]) => invoke('probe_wsl_addable', { distros }), + + /** 打开 WSL 终端 */ + openTerminal: (distro?: string) => invoke('open_wsl_terminal', { distro }), + + /** 添加 WSL 服务器(确定性 id,后端立即启动并经事件推送状态) */ + addServer: (distro: string) => invoke('add_wsl_server', { distro }), + + /** 移除 WSL 服务器 */ + removeServer: (id: string) => invoke('remove_wsl_server', { id }), + + /** 启动 WSL 服务器(立即返回,状态经事件推送) */ + startServer: (id: string) => invoke('start_wsl_server', { id }), + + /** 获取当前状态 */ + getState: () => invoke('get_wsl_state'), + + /** 安装 WSL 运行时(触发 UAC 提权) */ + installWsl: () => invoke('install_wsl'), + + /** 安装指定发行版 */ + installDistro: (name: string) => invoke('install_wsl_distro', { name }), + + /** 在发行版中安装 opencode */ + installOpencode: (distro: string) => invoke('install_wsl_opencode', { distro }), +} + +/** 订阅后端状态推送(官方 wsl-servers-subscribe 模式),返回取消订阅函数 */ +export function subscribeWslState(cb: (event: WslServersEvent) => void): () => void { + let unlisten: UnlistenFn | null = null + let cancelled = false + void listen('wsl-state', event => { + cb({ type: 'state', state: event.payload }) + }).then(fn => { + if (cancelled) { + fn() + return + } + unlisten = fn + }) + return () => { + cancelled = true + unlisten?.() + } +} diff --git a/src/components/BottomPanel.tsx b/src/components/BottomPanel.tsx index f1a750fc..65b12258 100644 --- a/src/components/BottomPanel.tsx +++ b/src/components/BottomPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { TerminalIcon } from './Icons' import { PanelContainer } from './PanelContainer' import { layoutStore, useLayoutStore, type TerminalTab, type PanelTab } from '../store/layoutStore' +import { affectsBoundServer } from '../store/serverChangeScope' import { serverStore } from '../store/serverStore' import { createPtySession, removePtySession, listPtySessions } from '../api/pty' import { useCurrentSessionId } from '../store' @@ -99,7 +100,11 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo } void restoreSessions(++restoreRequestIdRef.current) - return serverStore.onServerChange(() => { + return serverStore.onServerChange((changedId, reason) => { + // 评审 N2:终端恢复的数据主体是 serverId ?? active(对齐 useVcsInfo 的绑定语义)。 + // 旧实现拿 changedId 与 active 比对:面板固定绑定 WSL 而 active 在 local 时, + // 这台 WSL 换端口重启会被误跳过(终端全部失联);反之无关服务器切换会误触发重恢复。 + if (!affectsBoundServer(serverId, changedId, reason, serverStore.getActiveServerId())) return void restoreSessions(++restoreRequestIdRef.current) }) }, [normalizedDirectory]) diff --git a/src/contexts/SessionContext.test.tsx b/src/contexts/SessionContext.test.tsx index f0a921f9..a5700a20 100644 --- a/src/contexts/SessionContext.test.tsx +++ b/src/contexts/SessionContext.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { EventCallbacks } from '../types/api/event' import { SessionContext } from './SessionContext.shared' import { SessionProvider } from './SessionContext' +import type { ServerChangeReason } from '../store/serverStore' function createDeferred() { let resolve!: (value: T) => void @@ -40,7 +41,7 @@ const { })) let latestEventCallbacks: Partial = {} let latestContext: ContextType = null -let latestServerChange: (() => void) | undefined +let latestServerChange: ((serverId: string, reason: ServerChangeReason) => void) | undefined vi.mock('../api', () => ({ getSessions: (...args: unknown[]) => getSessionsMock(...args), @@ -74,6 +75,7 @@ vi.mock('../store/todoStore', () => ({ vi.mock('../store/serverStore', () => ({ serverStore: { onServerChange: (...args: unknown[]) => onServerChangeMock(...args), + getActiveServerId: () => 'local', }, })) @@ -120,7 +122,7 @@ describe('SessionProvider', () => { return vi.fn() }) onServerChangeMock.mockImplementation(listener => { - latestServerChange = listener as () => void + latestServerChange = listener as (serverId: string, reason: ServerChangeReason) => void return vi.fn() }) }) @@ -270,7 +272,8 @@ describe('SessionProvider', () => { expect(getSessionsMock).toHaveBeenCalledTimes(1) await act(async () => { - latestServerChange?.() + // active 服务器自身端点变化(serverId = active)才应触发重拉 + latestServerChange?.('local', 'server-runtime-updated') await Promise.resolve() }) @@ -292,4 +295,31 @@ describe('SessionProvider', () => { expect(latestContext?.sessions.map(session => session.id)).toEqual(['fresh']) }) + + it('keeps the session list untouched when a non-active server endpoint changes', async () => { + getSessionsMock.mockResolvedValue([{ id: 'session-1', directory: '/workspace/demo' }]) + + render( + + + , + ) + + await act(async () => { + vi.runAllTimers() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(1) + expect(latestContext?.sessions.map(session => session.id)).toEqual(['session-1']) + + // 非 active 服务器(remote)端点变化:与 active 会话列表无关,不得清空/重拉 + await act(async () => { + latestServerChange?.('remote', 'server-runtime-updated') + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(1) + expect(latestContext?.sessions.map(session => session.id)).toEqual(['session-1']) + }) }) diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx index 2a6b8d89..74dbdc53 100644 --- a/src/contexts/SessionContext.tsx +++ b/src/contexts/SessionContext.tsx @@ -8,6 +8,7 @@ import { type SessionListParams, } from '../api' import { todoStore } from '../store/todoStore' +import { affectsBoundServer } from '../store/serverChangeScope' import { serverStore } from '../store/serverStore' import { pinnedSessionsStore } from '../store/pinnedSessionsStore' import { useDirectory } from './useDirectory' @@ -218,7 +219,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, [matchesCurrentDirectory]) useEffect(() => { - return serverStore.onServerChange(() => { + return serverStore.onServerChange((serverId, reason) => { + // 会话列表跟随 active server:非 active 服务器端点变化(WSL 重启)不影响本列表, + // 避免无关事件触发「清空 → 重拉」的可见闪烁;只有 active 数据源真的变了才重置 + if (!affectsBoundServer(undefined, serverId, reason, serverStore.getActiveServerId())) return currentLimitRef.current = 30 setSessions([]) void fetchSessionsRef.current() diff --git a/src/features/chat/sidebar/FolderRecentList.tsx b/src/features/chat/sidebar/FolderRecentList.tsx index 9bbb7af1..3483440d 100644 --- a/src/features/chat/sidebar/FolderRecentList.tsx +++ b/src/features/chat/sidebar/FolderRecentList.tsx @@ -887,7 +887,17 @@ function FolderRecentSection({ } }, [isExpanded, inView]) - const { sessions, isLoading, isLoadingMore, hasMore, loadMore, patchLocalSession, removeLocalSession } = useSessions({ + const { + sessions, + isLoading, + isLoadingMore, + hasMore, + error, + loadMore, + refresh, + patchLocalSession, + removeLocalSession, + } = useSessions({ directory: project.worktree, pageSize: DIRECTORY_PAGE_SIZE, enabled: hasActivated && !hasWorkspaceTree, @@ -1082,9 +1092,19 @@ function FolderRecentSection({ onReorderWorkspace={onReorderWorkspace} /> ) : visibleSessions.length === 0 ? ( -
- {t('sidebar.noChatsInFolder')} -
+ error ? ( + + ) : ( +
+ {t('sidebar.noChatsInFolder')} +
+ ) ) : ( <> {visibleSessions.map((session, index) => { @@ -1142,32 +1162,43 @@ function FolderRecentSection({ ) })} - {hasMore && ( + {/* 刷新失败:有旧数据也必须露出失败态(可点重试),并让位给重试入口而不是「显示更多」 */} + {error ? ( + + ) )} )} diff --git a/src/features/chat/sidebar/MultiServerFolderList.tsx b/src/features/chat/sidebar/MultiServerFolderList.tsx index 88766cbd..b5ca329f 100644 --- a/src/features/chat/sidebar/MultiServerFolderList.tsx +++ b/src/features/chat/sidebar/MultiServerFolderList.tsx @@ -30,6 +30,7 @@ import { deleteSession, updateSession, type ApiSession } from '../../../api' import { isSameDirectory } from '../../../utils' import { clearSessionRuntimeState } from '../../../utils/sessionLifecycle' import { uiErrorHandler } from '../../../utils' +import { isWslServerId } from '../../wsl/settings-model' import { FolderRecentList, createDirectoryProject, @@ -68,7 +69,7 @@ function statusDotClass(state: ConnectionInfo['state']): string { case 'connecting': return 'bg-warning-100' case 'error': - return 'bg-error-100' + return 'bg-danger-100' default: return 'bg-text-500/50' } @@ -218,6 +219,13 @@ const ServerFolderGroup = memo(function ServerFolderGroup({ {displayName} + {isWslServerId(serverId) && ( + + {t('wsl.server.label', { ns: 'settings' })} + + )} {health?.status === 'online' && health.version ? ` · v${health.version}` : ''} diff --git a/src/features/chat/sidebar/SearchResults.tsx b/src/features/chat/sidebar/SearchResults.tsx index c901793e..07190e01 100644 --- a/src/features/chat/sidebar/SearchResults.tsx +++ b/src/features/chat/sidebar/SearchResults.tsx @@ -29,7 +29,7 @@ function statusDotClass(state: ConnectionInfo['state']): string { case 'connecting': return 'bg-warning-100' case 'error': - return 'bg-error-100' + return 'bg-danger-100' default: return 'bg-text-500/50' } diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index da7d59ef..c5780791 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -145,7 +145,10 @@ export function SidePanel({ // 多服务器订阅模式配置 const multiServerConfig = useMultiServerStore() // 多服务器模式:Git/路径信息跟随「焦点服务器」(焦点缺省 = 活动服务器) - const { activeServer } = useServerStore() + // servers 快照同时喂给下方 subscribedServerIds 的过滤:WSL 服务器就绪注册 + // (upsert)只更新 serverStore,订阅白名单本身不变,漏了它 memo 会停留在 + // 「服务器还不存在」的旧过滤结果上,侧边栏永远缺 WSL 分组 + const { activeServer, servers: registryServers } = useServerStore() const catalogServerId = multiServerConfig.enabled ? (multiServerConfig.focusedServerId ?? activeServer?.id) : undefined @@ -165,9 +168,9 @@ export function SidePanel({ getStorageVersion, ) const subscribedServerIds = useMemo(() => { - // 白名单精确生效:只展示用户在设置里勾选的服务器 - return multiServerConfig.subscribedServerIds.filter(id => serverStore.getServers().some(s => s.id === id)) - }, [multiServerConfig.subscribedServerIds]) + // 白名单精确生效:只展示用户在设置里勾选的服务器(且已注册进 serverStore) + return multiServerConfig.subscribedServerIds.filter(id => registryServers.some(s => s.id === id)) + }, [multiServerConfig.subscribedServerIds, registryServers]) // 多服务器列表按复合 key(serverId::sessionId)比较,避免跨服务器同名 session 串高亮 const multiServerSelectedSessionKey = selectedSessionId const [globalFolderIndex, setGlobalFolderIndex] = useState(() => { diff --git a/src/features/settings/components/ServerHealthButton.tsx b/src/features/settings/components/ServerHealthButton.tsx new file mode 100644 index 00000000..dbfe26d7 --- /dev/null +++ b/src/features/settings/components/ServerHealthButton.tsx @@ -0,0 +1,53 @@ +// ============================================ +// ServerHealthButton —— 服务器健康检查按钮 +// 图标反映 serverStore healthMap 状态,title 展示延迟/版本/错误详情。 +// ServerItem 与 WslServerRow 共用,保证两类连接卡片行为一致 +// ============================================ + +import { useTranslation } from 'react-i18next' +import { KeyIcon, SpinnerIcon, WifiIcon, WifiOffIcon } from '../../../components/Icons' +import type { ServerHealth } from '../../../store/serverStore' + +export function ServerHealthButton({ health, onCheck }: { health: ServerHealth | null; onCheck: () => void }) { + const { t } = useTranslation(['settings', 'common']) + + const statusTitle = () => { + if (!health) return t('servers.checkHealth') + switch (health.status) { + case 'checking': + return t('servers.checking') + case 'online': + return `${t('servers.onlineLatency', { latency: health.latency })}${health.version ? ` · OpenCode v${health.version}` : ''}` + case 'unauthorized': + return t('servers.invalidCredentials') + case 'offline': + return health.error || t('common:offline') + case 'error': + return health.error || t('common:error') + default: + return t('common:unknown') + } + } + + const statusIcon = () => { + if (!health || health.status === 'checking') return + if (health.status === 'online') return + if (health.status === 'unauthorized') return + return + } + + return ( + + ) +} diff --git a/src/features/settings/components/ServersSettings.tsx b/src/features/settings/components/ServersSettings.tsx index a4aeded1..7845fc35 100644 --- a/src/features/settings/components/ServersSettings.tsx +++ b/src/features/settings/components/ServersSettings.tsx @@ -1,23 +1,22 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '../../../components/ui/Button' import { ConfirmDialog } from '../../../components/ui/ConfirmDialog' -import { - TrashIcon, - WifiIcon, - WifiOffIcon, - SpinnerIcon, - KeyIcon, - PencilIcon, - RetryIcon, - PlugIcon, - CircleIcon, -} from '../../../components/Icons' +import { DropdownMenu } from '../../../components/ui/DropdownMenu' +import { MenuItem } from '../../../components/ui/MenuItem' +import { TrashIcon, KeyIcon, PencilIcon, RetryIcon, PlugIcon, CircleIcon } from '../../../components/Icons' import { useServerStore, useRouter } from '../../../hooks' import { messageStore } from '../../../store' import { useMultiServerStore, multiServerStore } from '../../../store/multiServerStore' +import { useWslStore } from '../../../store/wslStore' import { settingsFieldClass, SettingsSection, SettingRow, Toggle } from './SettingsUI' +import { ServerHealthButton } from './ServerHealthButton' import type { ServerConfig, ServerHealth } from '../../../store/serverStore' +import { isTauri, getDesktopPlatform } from '../../../utils/tauri' +import { wslApi } from '../../../api/wsl' +import { DialogAddWslServer } from '../../wsl/components/DialogAddWslServer' +import { WslServerRow } from '../../wsl/components/WslServerRow' +import { isWslServerId } from '../../wsl/settings-model' const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/ /** 显示名长度上限,避免列表项把右侧操作按钮挤穿 */ @@ -64,31 +63,6 @@ function ServerItem({ const [editing, setEditing] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) - const statusIcon = () => { - if (!health || health.status === 'checking') return - if (health.status === 'online') return - if (health.status === 'unauthorized') return - return - } - - const statusTitle = () => { - if (!health) return t('servers.checkHealth') - switch (health.status) { - case 'checking': - return t('servers.checking') - case 'online': - return `${t('servers.onlineLatency', { latency: health.latency })}${health.version ? ` · OpenCode v${health.version}` : ''}` - case 'unauthorized': - return t('servers.invalidCredentials') - case 'offline': - return health.error || t('common:offline') - case 'error': - return health.error || t('common:error') - default: - return t('common:unknown') - } - } - if (editing) { return ( {subscribed ? : } - + {!server.isDefault && ( <> - + {isWindowsDesktop ? ( +
+ + {/* 设置弹窗本身是 z-[300],portal 到 body 的菜单默认 zIndex=100 会被压在下面(看起来点了没反应) */} + +
+ { setAddMenuOpen(false); setAddingServer(true) }} + /> + { setAddMenuOpen(false); setAddingWslServer(true) }} + /> +
+
+
+ ) : ( + + )} } >
- {orderedServers.map(s => ( - { + if (isWslServerId(s.id)) { + const item = wslItemById.get(s.id) + // wsl 条目必须由 wslStore 状态驱动渲染;状态未到达时跳过, + // 避免出现没有生命周期管理能力的孤立连接条目 + if (!item) return null + return ( + handleSelectServer(s.id)} + onCheckHealth={() => void checkHealth(s.id)} + onToggleSubscribe={() => multiServerStore.setSubscribed(s.id, !multiServerStore.isSubscribed(s.id))} + /> + ) + } + return ( + handleSelectServer(s.id)} + onDelete={() => { + // 删除服务器时同步移出多服务器白名单(避免残留订阅连到已删地址) + if (multiServerStore.isSubscribed(s.id)) { + multiServerStore.setSubscribedServerIds( + multiServerStore.getSubscribedServerIds().filter(id => id !== s.id), + ) + } + removeServer(s.id) + }} + onEdit={updates => { + const auth = updates.password + ? { username: updates.username || 'opencode', password: updates.password } + : undefined + updateServer(s.id, { name: updates.name, url: updates.url, auth }) + void checkHealth(s.id) + }} + onCheckHealth={() => void checkHealth(s.id)} + onToggleSubscribe={() => multiServerStore.setSubscribed(s.id, !multiServerStore.isSubscribed(s.id))} + /> + ) + })} + + {/* 未就绪的 WSL 服务器(starting/failed/stopped)不在连接列表里,同样以卡片形态展示 */} + {unreadyWslItems.map(item => ( + handleSelectServer(s.id)} - onDelete={() => { - // 删除服务器时同步移出多服务器白名单(避免残留订阅连到已删地址) - if (multiServerStore.isSubscribed(s.id)) { - multiServerStore.setSubscribedServerIds( - multiServerStore.getSubscribedServerIds().filter(id => id !== s.id), - ) - } - removeServer(s.id) - }} - onEdit={updates => { - const auth = updates.password - ? { username: updates.username || 'opencode', password: updates.password } - : undefined - updateServer(s.id, { name: updates.name, url: updates.url, auth }) - void checkHealth(s.id) - }} - onCheckHealth={() => void checkHealth(s.id)} - onToggleSubscribe={() => multiServerStore.setSubscribed(s.id, !multiServerStore.isSubscribed(s.id))} + onSelect={() => handleSelectServer(item.config.id)} + onToggleSubscribe={() => multiServerStore.setSubscribed(item.config.id, true)} /> ))} @@ -670,11 +730,17 @@ export function ServersSettings() { /> )} - {servers.length === 0 && !addingServer && ( + {servers.length === 0 && unreadyWslItems.length === 0 && !addingServer && (
{t('servers.noServersConfigured')}
)}
+ + setAddingWslServer(false)} + onAdded={() => void checkAllHealth()} + /> ) } diff --git a/src/features/wsl/components/DialogAddWslServer.tsx b/src/features/wsl/components/DialogAddWslServer.tsx new file mode 100644 index 00000000..860babb8 --- /dev/null +++ b/src/features/wsl/components/DialogAddWslServer.tsx @@ -0,0 +1,535 @@ +// ============================================ +// DialogAddWslServer —— 官方 packages/app/src/wsl/dialog-add-server.tsx 的 React 还原 +// 视图状态机(loading/pendingRestart/checking/unavailable/ready)、main/catalog +// 双视图、primaryButton 状态机、探测计划状态机(probe plan + failure gate) +// 全部对齐官方实现 +// ============================================ + +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dialog } from '../../../components/ui/Dialog' +import { Button } from '../../../components/ui/Button' +import { SpinnerIcon, DownloadIcon, ChevronRightIcon } from '../../../components/Icons' +import { wslApi } from '../../../api/wsl' +import { useWslStore } from '../../../store/wslStore' +import { notificationStore } from '../../../store/notificationStore' +import { + addServerProbePlan, + addServerViewModel, + createProbeFailureGate, + type AddServerText, +} from '../settings-model' + +/** 判定 WSL 运行时错误是否属于"未安装"(官方 isWslRuntimeMissing), + * 决定 unavailable 弹窗显示"安装 WSL"按钮还是纯错误信息 */ +function isWslRuntimeMissing(error: string | null | undefined) { + if (!error) return true + return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error) +} + +function translate(t: (key: string, params?: Record) => string, value: AddServerText) { + if (value.params) return t(value.key, value.params) + return t(value.key) +} + +/** 请求失败的用户可见反馈(官方 requestError 的 toast 等价实现) */ +function requestError(err: unknown) { + console.error('WSL servers request failed', err instanceof Error ? (err.stack ?? err.message) : String(err)) + const message = err instanceof Error ? err.message : String(err) + notificationStore.push('error', message, message, '') +} + +/** 判定是否为 UAC 提权被用户拒绝(PowerShell Start-Process -Verb RunAs 的取消形态, + * 中文 Windows 报「操作已被用户取消」、英文报 "canceled by the user") */ +function isUacDeniedError(err: unknown) { + const message = err instanceof Error ? err.message : String(err) + return /canceled by the user|cancelled by the user|操作已被用户取消/i.test(message) +} + +interface DialogWslServerProps { + isOpen: boolean + onClose: () => void + onAdded?: () => void | Promise +} + +export function DialogAddWslServer({ isOpen, onClose, onAdded }: DialogWslServerProps) { + const { t } = useTranslation('settings') + const wsl = useWslStore() + const [view, setView] = useState<'main' | 'catalog'>('main') + const [selectedDistro, setSelectedDistro] = useState(null) + const [catalogSearch, setCatalogSearch] = useState('') + const [catalogTarget, setCatalogTarget] = useState(null) + const [adding, setAdding] = useState(false) + const [probingAddable, setProbingAddable] = useState(false) + // 状态变化后强制重算探测计划(effect 依赖兜底) + const [, setTick] = useState(0) + const gateRef = useRef(createProbeFailureGate()) + const probingRef = useRef(false) + + const model = addServerViewModel({ + state: wsl ?? undefined, + view, + selectedDistro, + catalogSearch, + catalogTarget, + adding, + probingAddable, + }) + + // 探测计划状态机(官方 add-server-probes.ts 的 React 版): + // 状态变化重算计划 → gate 放行 → 执行 → settle;同 key 失败不自动重试, + // 用户显式操作(checkAgain 等)时 reset + useEffect(() => { + if (!isOpen || probingRef.current) return + const gate = gateRef.current + const plan = addServerProbePlan({ + state: wsl ?? undefined, + view, + adding, + busy: model.busy, + selectedDistro: model.selectedDistro, + addableInstalledDistros: model.addableInstalledDistros, + }) + if (!plan || !gate.accepts(plan.key)) return + + const run = (action: () => Promise) => { + probingRef.current = true + action() + .then( + () => gate.settle(plan.key), + (error: unknown) => { + gate.settle(plan.key, error) + console.error('WSL probe failed:', error) + }, + ) + .finally(() => { + probingRef.current = false + setTick(tick => tick + 1) + }) + } + + if (plan.kind === 'auto') { + run(() => (plan.plan.action === 'probeRuntime' ? wslApi.probeRuntime() : wslApi.refreshDistros())) + return + } + setProbingAddable(true) + run(() => + wslApi.probeAddable(plan.plan.distros).finally(() => { + setProbingAddable(false) + }), + ) + }, [isOpen, wsl, view, adding, model.busy, model.selectedDistro, model.addableInstalledDistros]) + + const run = (action: () => Promise) => { + void action().catch((err: unknown) => requestError(err)) + } + + const refreshDistros = () => { + gateRef.current.reset() + // 用户显式「重新检测」:绕过在线目录缓存强制联网刷新 + run(() => wslApi.refreshDistros(true)) + } + + /** runtime 探测重试(unavailable 分支的「重新检测」):与自动探测共用同一触发器, + * reset 失败门控后手动执行;结果经 wslStore 状态回流驱动视图切换 */ + const recheckRuntime = () => { + gateRef.current.reset() + run(() => wslApi.probeRuntime()) + } + + /** 安装 WSL 运行时(触发 UAC 提权):用户拒绝提权时给可读的本地化文案, + * 其余错误原样透出 */ + const installWsl = () => { + run(async () => { + try { + await wslApi.installWsl() + } catch (err) { + throw isUacDeniedError(err) ? new Error(t('wsl.onboarding.uacDenied')) : err + } + }) + } + + const installDistro = (name: string) => { + gateRef.current.reset() + run(async () => { + await wslApi.installDistro(name) + setView('main') + }) + } + + const installCatalogDistro = () => { + if (model.installingCatalogDistro) return + const name = model.catalogTarget + if (!name) return + installDistro(name) + } + + const closeCatalog = () => { + gateRef.current.reset() + setView('main') + setCatalogSearch('') + setCatalogTarget(null) + } + + const runPrimary = async () => { + const button = model.primaryButton + if (button.loading) return + const distro = model.selectedDistro + const action = button.action + if (!distro || !action) return + if (action === 'install-opencode') { + run(() => wslApi.installOpencode(distro)) + return + } + setAdding(true) + try { + await wslApi.addServer(distro) + await onAdded?.() + onClose() + } catch (err) { + requestError(err) + } finally { + setAdding(false) + } + } + + if (!isOpen) return null + + // 状态树尚未从后端到达(loading) + if (!wsl) { + return ( + +
+ +
+
+ ) + } + + const runtimeError = wsl.runtime?.error ?? null + + // WSL 不可用 / 待重启 / 运行时检查中(官方 runtimeState 分支) + if (model.runtimeState !== 'ready') { + if (model.runtimeState === 'checking' || model.runtimeState === 'loading') { + return ( + +
+ +
+
+ ) + } + return ( + + ) + } + + const primaryButton = model.primaryButton + + return ( + + {view === 'catalog' ? ( + <> +
+ setCatalogSearch(e.target.value)} + placeholder={t('wsl.onboarding.searchDistros')} + className="w-full h-9 px-3 rounded-lg bg-bg-200/60 border border-border-200/60 text-[length:var(--fs-sm)] text-text-100 placeholder:text-text-500 focus:outline-none focus:border-accent-main-100/60" + /> +
+ {model.filteredInstallableDistros.length === 0 ? ( + model.distrosChecking ? ( + // 在线目录同样受列表刷新影响:刷新期间显示检测中 +
+ + {t('wsl.onboarding.checkingDistros')} +
+ ) : ( +

+ {t('wsl.onboarding.noDistros')} +

+ ) + ) : ( + model.filteredInstallableDistros.map(d => ( + + )) + )} +
+
+
+ + +
+ + ) : ( + <> +
+
+ + {t('wsl.onboarding.installedDistros')} + + +
+ +
+ {model.addableInstalledDistros.length === 0 ? ( + model.distrosChecking ? ( + // 列表还在后台刷新:显示检测中而不是"尚未检测到"(WSL 冷启动可能要等较久) +
+ + {t('wsl.onboarding.checkingDistros')} +
+ ) : ( +

+ {model.visibleInstalledDistros.length + ? t('wsl.onboarding.allDistrosAdded') + : t('wsl.onboarding.noDistros')} +

+ ) + ) : ( + model.addableInstalledDistros.map(d => { + const status = model.distroStatuses[d.name] + // 能力探测失败(canExecute=false):提示用户打开一次发行版,行内直接给「打开终端」入口 + const needsOpenOnce = wsl.distroProbes[d.name]?.canExecute === false + // 行容器用 div 而非 button:HTML 不允许按钮嵌套,禁用语义用 aria-disabled + 跳过触发表达 + const rowDisabled = d.version === 1 || model.busy + // WSL 1 发行版不可添加:保留可聚焦并经 aria-describedby 指向行内「WSL 2 required」原因, + // 键盘用户也能读到不能选的原因(临时 busy 行无需解释,维持不可聚焦) + const wsl2Required = d.version === 1 + const wsl2ReasonId = `wsl2-required-${d.name}` + const selectDistro = () => setSelectedDistro(d.name) + return ( +
{ + if (!rowDisabled) selectDistro() + }} + onKeyDown={e => { + // 只响应行自身的按键;焦点在行内「打开终端」按钮上时 Enter/Space 不触发选中 + if (rowDisabled || e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + selectDistro() + } + }} + className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left transition-colors + ${ + selectedDistro === d.name + ? 'border-accent-main-100/40 bg-accent-main-100/5' + : rowDisabled + ? 'border-border-200/20 opacity-50 cursor-not-allowed' + : 'border-border-200/40 hover:border-border-300' + }`} + > +
+
{d.name}
+
+ WSL {d.version ?? '?'} + {wsl.opencodeChecks[d.name]?.version && + ` · OpenCode v${wsl.opencodeChecks[d.name]!.version}`} +
+
+ {(status || needsOpenOnce) && ( +
+ {status && ( + + {translate(t, status.label)} + + )} + {needsOpenOnce && ( + + )} +
+ )} +
+ ) + }) + )} +
+ + {/* 需要其他发行版 → 在线目录入口(官方 needAnotherDistro 卡片) */} + {model.installableDistros.length > 0 && ( + + )} +
+ +
+ + +
+ + )} +
+ ) +} + +/** WSL 不可用/待重启引导弹窗(官方 DialogWslSetup) */ +function DialogWslSetup(props: { + state: 'pendingRestart' | 'unavailable' + error: string | null + installable: boolean + busy: boolean + onInstall: () => void + /** 「重新检测」:runtime 探测重试(与主视图 checkAgain 同一显式重试模式) */ + onRecheck: () => void + onClose: () => void +}) { + const { t } = useTranslation('settings') + const title = + props.state === 'pendingRestart' + ? t('wsl.onboarding.restartRequired') + : props.installable + ? t('wsl.onboarding.wslNotInstalled.title') + : t('wsl.onboarding.wslUnavailable.title') + const description = + props.state === 'pendingRestart' + ? t('wsl.onboarding.windowsRestartRequired') + : props.installable + ? t('wsl.onboarding.wslNotInstalled.description') + : t('wsl.onboarding.wslUnavailable.description') + + return ( + +
+ +

{title}

+

{description}

+ {!props.installable && props.error && ( +

{props.error}

+ )} + {props.state === 'unavailable' && props.installable && ( + <> + {/* UAC 预告:安装会弹系统提权确认,提前一句话说明,避免用户被突兀的 UAC 弹窗打断 */} +

{t('wsl.onboarding.installWslAdminNotice')}

+ + + )} + {props.state === 'unavailable' && ( + + )} + +
+
+ ) +} diff --git a/src/features/wsl/components/WslServerRow.tsx b/src/features/wsl/components/WslServerRow.tsx new file mode 100644 index 00000000..8a646655 --- /dev/null +++ b/src/features/wsl/components/WslServerRow.tsx @@ -0,0 +1,252 @@ +// ============================================ +// WslServerRow —— 连接列表中的 WSL 服务器卡片(官方 WslServerSettings 行 + 连接能力合并) +// 一台 WSL 服务器只此一张卡片:就绪时点击切换会话、插头订阅侧边栏、健康检查; +// 右侧内聚生命周期操作(设默认 / 安装·更新 opencode / 失败重试 / 彻底删除)。 +// 不提供「编辑连接」:端口/鉴权由 sidecar 每次启动动态生成,手改必被同步覆盖 +// ============================================ + +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ConfirmDialog } from '../../../components/ui/ConfirmDialog' +import { CircleIcon, KeyIcon, PlugIcon, SpinnerIcon, TrashIcon } from '../../../components/Icons' +import { wslApi } from '../../../api/wsl' +import { useWslStore } from '../../../store/wslStore' +import { serverStore } from '../../../store/serverStore' +import { useServerStore } from '../../../hooks/useServerStore' +import { notificationStore } from '../../../store/notificationStore' +import { ServerHealthButton } from '../../settings/components/ServerHealthButton' +import { wslOpencodeAction, wslRuntimeRetryable } from '../settings-model' +import type { WslServerItem } from '../types' +import type { ServerHealth } from '../../../store/serverStore' + +/** runtime 生命周期状态点(评审 UI5)。刻意与右侧健康检查按钮用不同的视觉语言: + * 圆点表达这台服务自身的进程状态(ready=绿色实心;starting=转圈动画;failed=红色 + * 实心,具体崩溃原因在行内下方另有红字),Wi-Fi 图标表达"主动探测 URL 是否可达", + * 两者含义不同、形状也不再撞车。 + * 关于 stopped(灰色空心圆):当前产品没有"停止"操作入口,它只会作为应用启动时 + * restore_persisted 恢复出的初始态短暂出现(后端随即拉起并转入其它状态), + * 普通用户无法稳定看到。保留这一形状是为了将来的停止功能不再与 failed 同形同色。 */ +function StatusDot({ kind }: { kind: WslServerItem['runtime']['kind'] }) { + switch (kind) { + case 'starting': + return + case 'ready': + return