From cfd5ce6331bd47ba217c84caf7a302a954b7a528 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Fri, 28 Aug 2026 22:28:17 +0800 Subject: [PATCH 1/9] chore: exclude local opencode reference dir from git, eslint and vitest --- .gitignore | 6 ++++++ eslint.config.js | 3 ++- vitest.config.ts | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d180eada..ef835b33 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ public/material-icons/ *.njsproj *.sln *.sw? + +# Local tool dirs / reference checkouts (not part of the repo) +.hermes/ +.omo/ +opencode/ +nul diff --git a/eslint.config.js b/eslint.config.js index 0a0062f5..3bb63d89 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,8 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', 'node_modules', 'public/material-icons', 'src-tauri/target/**']), + // opencode/ 是本地参考用的外部仓库,不属于本项目源码 + globalIgnores(['dist', 'node_modules', 'public/material-icons', 'src-tauri/target/**', 'opencode']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/vitest.config.ts b/vitest.config.ts index 81b171f8..ec4d8600 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, defaultExclude } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { bundledLanguagesInfo } from 'shiki/langs' @@ -15,6 +15,8 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test/setup.ts'], css: true, + // opencode/ 是本地参考用的外部仓库,里面的测试不属于本项目 + exclude: [...defaultExclude, 'opencode/**'], coverage: { reporter: ['text', 'html'], }, From 1642caea13bf992915f3cfac0d828e0755f4bf37 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Sun, 30 Aug 2026 13:32:13 +0800 Subject: [PATCH 2/9] feat(wsl): backend server management commands for Windows desktop - WSL runtime probe, distro enumeration (local + online), per-distro probes and opencode version checks with UAC-elevated installers - server lifecycle jobs (start/install/remove) with generation-guarded state, UTF-16 console decoding, full state push via wsl-state event - deterministic server ids (wsl:), startup warm-up - opencode command surface extended for WSL sidecar lifecycle --- src-tauri/Cargo.lock | 4 + src-tauri/Cargo.toml | 5 +- src-tauri/src/app/commands/mod.rs | 16 +- src-tauri/src/app/commands/opencode.rs | 54 +- src-tauri/src/app/commands/wsl_commands.rs | 1106 ++++++++++++++++++++ src-tauri/src/app/commands/wsl_runtime.rs | 589 +++++++++++ src-tauri/src/app/commands/wsl_types.rs | 138 +++ src-tauri/src/app/mod.rs | 38 + 8 files changed, 1934 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/app/commands/wsl_commands.rs create mode 100644 src-tauri/src/app/commands/wsl_runtime.rs create mode 100644 src-tauri/src/app/commands/wsl_types.rs 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..267db2ed 100644 --- a/src-tauri/src/app/commands/mod.rs +++ b/src-tauri/src/app/commands/mod.rs @@ -1,5 +1,11 @@ -pub mod bridge; -#[cfg(not(target_os = "android"))] -pub mod opencode; -#[cfg(not(target_os = "android"))] -pub mod utils; +pub mod bridge; +#[cfg(not(target_os = "android"))] +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..1a1ce8f8 --- /dev/null +++ b/src-tauri/src/app/commands/wsl_commands.rs @@ -0,0 +1,1106 @@ +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) +} + +/// 运行中的 sidecar 句柄:cancel token 触发后监督任务杀掉子进程。 +/// token 是"所有权凭证"——stop/remove 会先从 map 移除再 cancel, +/// 监督任务用 attempt 核对 map 里的句柄是否仍是自己,不是则保持静默。 +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, +} + +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), + } + } + + /// 全量推送当前状态到前端(官方 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); + } + } + + /// 从磁盘恢复已保存的服务器配置到内存状态(官方 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> { + 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)。 +/// 同时清掉 can_execute=false 的探测记录(checkAgain 语义): +/// 探测失败可能是 WSL 冷启动等瞬时问题,重试应重新探测而不是永远卡在 +/// "打开一次完成设置"的提示上。 +#[command] +pub async fn refresh_wsl_distros(state: tauri::State<'_, WslState>) -> Result<(), String> { + let token = state.begin_job(WslJob::Distros { + started_at: now_ms(), + }); + let result = tokio::join!( + wsl_runtime::list_installed_distros(Some(&token)), + wsl_runtime::list_online_distros(Some(&token)) + ); + match result { + (Ok(installed), Ok(online)) => { + state.set_state(|s| { + s.installed = installed; + s.online = online; + // checkAgain 重新探测失败过的 distro + s.distro_probes.retain(|_, p| p.can_execute); + }); + state.end_job(&token); + Ok(()) + } + (Err(e), _) | (_, Err(e)) => { + state.end_job(&token); + Err(e) + } + } +} + +/// 批量探测可添加的发行版(官方 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) + } + } +} + +/// 安装指定发行版(官方 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) => { + let installed = wsl_runtime::list_installed_distros(Some(&token)).await; + let online = wsl_runtime::list_online_distros(Some(&token)).await; + let probe = wsl_runtime::probe_distro(&name, Some(&token)).await; + match (installed, online, probe) { + (Ok(installed), Ok(online), Ok(probe)) => { + state.set_state(|s| { + s.installed = installed; + s.online = online; + s.distro_probes.insert(name.clone(), probe); + }); + state.end_job(&token); + Ok(()) + } + (Err(e), _, _) | (_, Err(e), _) | (_, _, 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 +} + +/// 启动单个 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()); + + #[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 超时 + let url = format!("http://127.0.0.1:{}", port); + let health_timeout_ms: u64 = 30_000; + let mut ready = false; + let mut failure: Option = None; + while !ready && failure.is_none() { + // attempt 失效(stop/remove/restart):终止并静默退出 + if !is_current() { + let _ = child.kill().await; + cleanup_sidecar(&state, id); + 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 = true; + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + if !ready && failure.is_none() { + // 健康检查超时(官方 healthTimeout) + failure = Some(format!( + "opencode serve in distribution '{}' did not become healthy within {}ms", + distro, health_timeout_ms + )); + } + + if let Some(message) = failure { + let _ = child.kill().await; + cleanup_sidecar(&state, id); + if is_current() { + set_runtime(&state, id, WslServerRuntime::Failed { message }); + } + return; + } + + // attempt 失效:杀掉本次进程避免泄漏 + if !is_current() { + let _ = child.kill().await; + cleanup_sidecar(&state, id); + 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。 + let watch_token = token.clone(); + let mut child = child; + if let Ok(status) = tokio::select! { + status = child.wait() => status, + _ = watch_token.cancelled() => { + let _ = child.kill().await; + cleanup_sidecar(&state, id); + return; + } + } { + // 只有当 map 里的句柄仍是本次启动注册的(attempt 匹配)才处理退出 + let owned = state + .sidecars + .lock() + .map(|m| m.get(id).map(|h| h.attempt == attempt).unwrap_or(false)) + .unwrap_or(false); + cleanup_sidecar(&state, id); + if owned && is_current() { + set_runtime( + &state, + id, + WslServerRuntime::Failed { + message: format!( + "opencode serve exited (exit code {})", + status.code().map(|c| c.to_string()).unwrap_or_else(|| "null".to_string()) + ), + }, + ); + } + } +} + +/// 从 sidecars 移除本次启动的句柄(仅当 token 匹配时) +fn cleanup_sidecar(state: &tauri::State<'_, WslState>, id: &str) { + if let Ok(mut sidecars) = state.sidecars.lock() { + sidecars.remove(id); + } +} + +/// 停止 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(), + }; + + // 持久化 → 更新内存(runtime 直接 starting,官方同款)→ 立即启动 + let configs: Vec = { + let mut s = state.state.lock().map_err(|e| e.to_string())?; + s.servers.push(WslServerItem { + config: config.clone(), + runtime: WslServerRuntime::Starting, + }); + s.servers.iter().map(|item| item.config.clone()).collect() + }; + persist_servers(&state.app, &configs)?; + state.emit(); + + 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; + } +} + +/// 应用启动初始化(官方 initialize:恢复配置 + 刷新 opencode 检查 + 全部拉起) +pub fn initialize_wsl(app: &AppHandle) { + let state = app.state::(); + state.restore_persisted(); + let ids = state.persisted_server_ids(); + // 对全部已有服务器后台刷新 opencode 检查(官方 refreshOpencodeChecks) + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + let distros: Vec = { + let s = match state.state.lock() { + Ok(s) => s, + Err(_) => return, + }; + s.servers.iter().map(|item| item.config.distro.clone()).collect() + }; + let checks = futures_util::future::join_all(distros.iter().map(|d| check_opencode(d, None))).await; + let valid_checks: Vec<(String, WslOpencodeCheck)> = checks + .into_iter() + .filter_map(|r| r.ok()) + .filter(|check| { + state + .state + .lock() + .map(|s| s.servers.iter().any(|item| item.config.distro == check.distro)) + .unwrap_or(false) + }) + .map(|check| (check.distro.clone(), check)) + .collect(); + if !valid_checks.is_empty() { + state.set_state(|s| { + for (distro, check) in valid_checks { + s.opencode_checks.insert(distro, check); + } + }); + } + }); + } + // 启动预热(官方 initialize 没做这一步,但官方靠"启动即拉起服务器"焐热 WSL; + // 无服务器的机器首开添加对话框会暴露完整的冷启动空窗):后台探测 runtime, + // 可用时拉取发行版列表,用户打开对话框时即是热数据。 + // 与对话框自身的 autoProbePlan 撞车时由 begin_job 的取消语义兜底 + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if probe_wsl_runtime(app.state::()).await.is_ok() { + let available = { + let state = app.state::(); + state + .state + .lock() + .ok() + .and_then(|s| s.runtime.as_ref().map(|r| r.available)) + .unwrap_or(false) + }; + if available { + let _ = refresh_wsl_distros(app.state::()).await; + } + } + }); + } + // 全部服务器自动拉起(官方 wslServerIdsToStartOnInitialize = 全部) + for id in ids { + start_server_internal(app, &id); + } +} 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..40e8ed16 --- /dev/null +++ b/src-tauri/src/app/commands/wsl_types.rs @@ -0,0 +1,138 @@ +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, 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 任务类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +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..02ab59aa 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -364,6 +364,16 @@ 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: 官方 initialize 对齐——恢复持久化配置 + 后台刷新 opencode + // 检查 + 自动拉起全部服务器(wslServerIdsToStartOnInitialize 返回全部 id) + #[cfg(target_os = "windows")] + commands::wsl_commands::initialize_wsl(app.handle()); + Ok(()) }); @@ -466,6 +476,34 @@ 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::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 From d32b6411dc56fc4f626f41473b271bb4d6b23f77 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Sun, 30 Aug 2026 13:32:14 +0800 Subject: [PATCH 3/9] feat(wsl): frontend server sync, settings UI and connections integration - wslStore syncs runtime into serverStore: ready servers register with auth and auto-subscribe to the multi-server sidebar (WSL badge) - connections list renders one card per WSL server combining connection and lifecycle management (switch, health, default, install/update opencode, retry, remove); add dialog with detection progress state and terminal entry - default-server preference persisted (boot auto-switch, death- rebirth restore after sidecar restart); WSL ids excluded from localStorage persistence; SSE resubscribes on same-id runtime change --- package-lock.json | 7 + package.json | 1 + src/api/events.ts | 7 +- src/api/wsl.ts | 63 +++ .../chat/sidebar/MultiServerFolderList.tsx | 8 + .../components/ServerHealthButton.tsx | 53 ++ .../settings/components/ServersSettings.tsx | 220 +++++--- .../wsl/components/DialogAddWslServer.tsx | 485 ++++++++++++++++++ src/features/wsl/components/WslServerRow.tsx | 243 +++++++++ src/features/wsl/settings-model.test.ts | 167 ++++++ src/features/wsl/settings-model.ts | 359 +++++++++++++ src/features/wsl/types.ts | 102 ++++ src/hooks/useServerStore.ts | 8 + src/locales/en/settings.json | 54 +- src/locales/zh-CN/settings.json | 54 +- src/main.tsx | 4 + src/store/serverStore.test.ts | 295 +++++++++++ src/store/serverStore.ts | 180 ++++++- src/store/wslStore.test.ts | 94 ++++ src/store/wslStore.ts | 204 ++++++++ 20 files changed, 2504 insertions(+), 104 deletions(-) create mode 100644 src/api/wsl.ts create mode 100644 src/features/settings/components/ServerHealthButton.tsx create mode 100644 src/features/wsl/components/DialogAddWslServer.tsx create mode 100644 src/features/wsl/components/WslServerRow.tsx create mode 100644 src/features/wsl/settings-model.test.ts create mode 100644 src/features/wsl/settings-model.ts create mode 100644 src/features/wsl/types.ts create mode 100644 src/store/wslStore.test.ts create mode 100644 src/store/wslStore.ts 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/api/events.ts b/src/api/events.ts index 9220b88e..b55a2492 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -1036,8 +1036,11 @@ 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) => { + // 同 id 且非 runtime 变更 → 无需重订阅。server-runtime-updated 表示端口/鉴权变了 + // (WSL sidecar 重启),活动中的 SSE 还连着旧地址,必须拆旧建新; + // 建连时 URL/鉴权由 getApiBaseUrl/getAuthHeader 从 serverStore 现读,重订阅即拿到新值 + if (newServerId === currentServerId && reason !== 'server-runtime-updated') 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..bbdb89bb --- /dev/null +++ b/src/api/wsl.ts @@ -0,0 +1,63 @@ +// ============================================ +// 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'), + + /** 刷新发行版列表(本地 + 在线) */ + refreshDistros: () => invoke('refresh_wsl_distros'), + + /** 批量探测可添加的发行版(增量语义,官方 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/features/chat/sidebar/MultiServerFolderList.tsx b/src/features/chat/sidebar/MultiServerFolderList.tsx index 88766cbd..6c70845b 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, @@ -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/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..32424e6b 100644 --- a/src/features/settings/components/ServersSettings.tsx +++ b/src/features/settings/components/ServersSettings.tsx @@ -1,23 +1,21 @@ -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 { 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 +62,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 +718,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..fafa6bed --- /dev/null +++ b/src/features/wsl/components/DialogAddWslServer.tsx @@ -0,0 +1,485 @@ +// ============================================ +// 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 } 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, '') +} + +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()) + } + + 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 ( + run(() => wslApi.installWsl())} + onClose={onClose} + /> + ) + } + + 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 + 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 + 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 && ( + + )} + +
+
+ ) +} diff --git a/src/features/wsl/components/WslServerRow.tsx b/src/features/wsl/components/WslServerRow.tsx new file mode 100644 index 00000000..df6c8e56 --- /dev/null +++ b/src/features/wsl/components/WslServerRow.tsx @@ -0,0 +1,243 @@ +// ============================================ +// 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, WifiIcon, WifiOffIcon } 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 生命周期状态点:与健康检查(探测 URL 可达性)语义不同,两者并存 */ +function StatusDot({ kind }: { kind: WslServerItem['runtime']['kind'] }) { + switch (kind) { + case 'starting': + return + case 'ready': + return + case 'failed': + case 'stopped': + return + default: + return + } +} + +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, '') +} + +const tagClass = + 'shrink-0 text-[length:var(--fs-xs)] font-medium text-blue-400 bg-blue-400/10 px-1.5 py-0.5 rounded' +const actionButtonClass = + 'h-7 px-2 rounded-md text-[length:var(--fs-xs)] font-medium text-text-400 hover:text-accent-main-100 hover:bg-accent-main-100/10 transition-colors' + +export function WslServerRow({ + item, + health, + isActive, + subscribed, + multiServerEnabled, + onSelect, + onCheckHealth, + onToggleSubscribe, +}: { + item: WslServerItem + health: ServerHealth | null + isActive: boolean + subscribed: boolean + multiServerEnabled: boolean + onSelect: () => void + /** 仅就绪条目由父组件传入(健康检查探测的是连接地址) */ + onCheckHealth?: () => void + onToggleSubscribe: () => void +}) { + const { t } = useTranslation(['settings', 'common']) + const wsl = useWslStore() + const { defaultServerId } = useServerStore() + const [confirmDelete, setConfirmDelete] = useState(false) + + const ready = item.runtime.kind === 'ready' + const check = wsl?.opencodeChecks[item.config.distro] + const opencodeAction = wslOpencodeAction(check) + const busy = wsl?.job?.kind === 'install-opencode' && wsl.job.distro === item.config.distro + + return ( + <> +
+ + +
+ {ready && ( + + )} + {ready && onCheckHealth && } + {/* 默认服务器偏好(官方 defaultKey):就绪后启动自动切回该服务器 */} + {defaultServerId === item.config.id ? ( + + ) : ( + + )} + {/* 行内安装/更新 opencode 入口(官方 wslOpencodeAction 驱动) */} + {opencodeAction && ( + + )} + {/* 重试启动:仅 failed/stopped 可重试(官方 wslRuntimeRetryable) */} + {wslRuntimeRetryable(item.runtime) && ( + + )} + {/* 彻底删除(区别于普通服务器的「移除连接」):连同 WSL 侧服务一起移除 */} + +
+
+ + setConfirmDelete(false)} + onConfirm={() => { + setConfirmDelete(false) + wslApi.removeServer(item.config.id).catch((err: unknown) => requestError(err)) + }} + title={t('wsl.deleteServerTitle')} + description={t('wsl.deleteServerConfirm', { name: item.config.distro })} + confirmText={t('common:delete')} + cancelText={t('common:cancel')} + variant="danger" + /> + + ) +} diff --git a/src/features/wsl/settings-model.test.ts b/src/features/wsl/settings-model.test.ts new file mode 100644 index 00000000..c5fcaad7 --- /dev/null +++ b/src/features/wsl/settings-model.test.ts @@ -0,0 +1,167 @@ +// ============================================ +// WSL 添加服务器视图模型核心契约测试 +// 对齐官方 packages/app/src/wsl/settings-model.test.ts 的关键场景 +// ============================================ + +import { describe, expect, it } from 'vitest' +import { + addableProbePlan, + addServerProbePlan, + addServerViewModel, + createProbeFailureGate, + isWslServerId, + wslDistroReady, +} from './settings-model' +import type { WslDistroProbe, WslServersState } from './types' + +const probe = (over: Partial = {}): WslDistroProbe => ({ + name: 'Ubuntu', + canExecute: true, + hasBash: true, + hasCurl: true, + error: null, + ...over, +}) + +const baseState = (over: Partial = {}): WslServersState => ({ + runtime: { available: true, version: '2.6.2', error: null }, + installed: [{ name: 'Ubuntu', version: 2, isDefault: true }], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + ...over, +}) + +describe('addServerDistroStatus (via addServerViewModel)', () => { + it('ready probe + opencode check → ready', () => { + const state = baseState({ + distroProbes: { Ubuntu: probe() }, + opencodeChecks: { + Ubuntu: { + distro: 'Ubuntu', + resolvedPath: '/home/u/.opencode/bin/opencode', + version: '1.0.0', + expectedVersion: null, + matchesDesktop: null, + error: null, + }, + }, + }) + const model = addServerViewModel({ state, view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + expect(model.distroStatuses['Ubuntu']?.label.key).toBe('wsl.onboarding.distroStatus.ready') + expect(model.distroStatuses['Ubuntu']?.tone).toBe('success') + }) + + it('probe exists but canExecute=false → openDistroOnce(不能用 undefined 误判)', () => { + // 回归:serde 字段名不匹配曾使 canExecute 恒为 undefined + const state = baseState({ distroProbes: { Ubuntu: probe({ canExecute: false, error: 'timed out' }) } }) + const model = addServerViewModel({ state, view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + expect(model.distroStatuses['Ubuntu']?.label.key).toBe('wsl.onboarding.openDistroOnce') + }) + + it('probe missing + probing → checking;probe missing + idle → undefined', () => { + const probing = addServerViewModel({ state: baseState(), view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: true }) + expect(probing.distroStatuses['Ubuntu']?.label.key).toBe('wsl.onboarding.distroStatus.checking') + const idle = addServerViewModel({ state: baseState(), view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + expect(idle.distroStatuses['Ubuntu']).toBeUndefined() + }) + + it('missing bash → missingTools', () => { + const state = baseState({ distroProbes: { Ubuntu: probe({ hasBash: false }) } }) + const model = addServerViewModel({ state, view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + expect(model.distroStatuses['Ubuntu']?.label.key).toBe('wsl.onboarding.distroStatus.missingTools') + }) + + it('opencode check without resolvedPath → opencodeMissing', () => { + const state = baseState({ + distroProbes: { Ubuntu: probe() }, + opencodeChecks: { Ubuntu: { distro: 'Ubuntu', resolvedPath: null, version: null, expectedVersion: null, matchesDesktop: null, error: 'opencode is not installed' } }, + }) + const model = addServerViewModel({ state, view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + expect(model.distroStatuses['Ubuntu']?.label.key).toBe('wsl.onboarding.distroStatus.opencodeMissing') + }) + + it('primaryButton: ready 全链路 → add;缺 opencode → install-opencode', () => { + const ready = addServerViewModel({ + state: baseState({ + distroProbes: { Ubuntu: probe() }, + opencodeChecks: { Ubuntu: { distro: 'Ubuntu', resolvedPath: '/bin/oc', version: '1.0.0', expectedVersion: null, matchesDesktop: null, error: null } }, + }), + view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false, + }) + expect(ready.primaryButton.action).toBe('add') + + const missing = addServerViewModel({ + state: baseState({ + distroProbes: { Ubuntu: probe() }, + opencodeChecks: { Ubuntu: { distro: 'Ubuntu', resolvedPath: null, version: null, expectedVersion: null, matchesDesktop: null, error: 'not installed' } }, + }), + view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false, + }) + expect(missing.primaryButton.action).toBe('install-opencode') + }) +}) + +describe('probe plans', () => { + it('addableProbePlan 只计划未探测/未检查的 distro', () => { + const state = baseState({ distroProbes: { Ubuntu: probe() } }) + const plan = addableProbePlan({ state, view: 'main', adding: false, selectedDistro: null, addableInstalledDistros: state.installed }) + // probe 就绪但缺 opencode check → 计划 opencode 检查 + expect(plan?.key).toBe('opencode:Ubuntu') + + const done = baseState({ + distroProbes: { Ubuntu: probe() }, + opencodeChecks: { Ubuntu: { distro: 'Ubuntu', resolvedPath: '/bin/oc', version: '1', expectedVersion: null, matchesDesktop: null, error: null } }, + }) + expect(addableProbePlan({ state: done, view: 'main', adding: false, selectedDistro: null, addableInstalledDistros: done.installed })).toBeUndefined() + }) + + it('autoProbePlan:无 runtime → probeRuntime;runtime 可用且无列表 → refreshDistros', () => { + const plan = addServerProbePlan({ state: baseState({ runtime: null }), view: 'main', adding: false, busy: false, selectedDistro: null, addableInstalledDistros: [] }) + expect(plan?.key).toBe('auto:runtime') + const distros = addServerProbePlan({ state: baseState({ installed: [] }), view: 'main', adding: false, busy: false, selectedDistro: null, addableInstalledDistros: [] }) + expect(distros?.key).toBe('auto:distros') + }) + + it('wslDistroReady 要求 WSL2 + 三项能力齐备', () => { + const state = baseState({ distroProbes: { Ubuntu: probe() } }) + expect(wslDistroReady(state, 'Ubuntu')).toBe(true) + const wsl1 = baseState({ installed: [{ name: 'Ubuntu', version: 1, isDefault: true }], distroProbes: { Ubuntu: probe() } }) + expect(wslDistroReady(wsl1, 'Ubuntu')).toBe(false) + }) +}) + +describe('distrosChecking', () => { + const vm = (state: WslServersState) => + addServerViewModel({ state, view: 'main', selectedDistro: null, catalogSearch: '', catalogTarget: null, adding: false, probingAddable: false }) + + it('distros job 在途 → true;其他 job / 无 job → false', () => { + expect(vm(baseState({ installed: [], job: { kind: 'distros', startedAt: 0 } })).distrosChecking).toBe(true) + expect(vm(baseState({ installed: [], job: { kind: 'runtime', startedAt: 0 } })).distrosChecking).toBe(false) + expect(vm(baseState({ installed: [] })).distrosChecking).toBe(false) + }) +}) + +describe('createProbeFailureGate', () => { + it('同 key 失败后拒绝,reset 后放行;成功不影响', () => { + const gate = createProbeFailureGate() + expect(gate.accepts('a')).toBe(true) + gate.settle('a', new Error('timeout')) + expect(gate.accepts('a')).toBe(false) + expect(gate.accepts('b')).toBe(true) + gate.settle('a') // 成功 settle 不记录失败 + gate.reset() + expect(gate.accepts('a')).toBe(true) + }) +}) + +describe('isWslServerId', () => { + // 后端确定性 id 约定:wsl: + it('识别 wsl: 前缀,排除普通服务器 id', () => { + expect(isWslServerId('wsl:Ubuntu-22.04')).toBe(true) + expect(isWslServerId('some-local-server')).toBe(false) + }) +}) diff --git a/src/features/wsl/settings-model.ts b/src/features/wsl/settings-model.ts new file mode 100644 index 00000000..7940d40d --- /dev/null +++ b/src/features/wsl/settings-model.ts @@ -0,0 +1,359 @@ +// ============================================ +// WSL 添加服务器视图模型 —— 与官方 packages/app/src/wsl/settings-model.ts 一一对应 +// 全部为纯函数:输入 WslServersState,输出 UI 状态;i18n 以 key+params 表达, +// 由 React 渲染层翻译 +// ============================================ + +import fuzzysort from 'fuzzysort' +import type { + WslDistroProbe, + WslInstalledDistro, + WslOnlineDistro, + WslOpencodeCheck, + WslServerRuntime, + WslServersState, +} from './types' + +export type AddServerText = { + key: string + params?: Record +} + +export type DistroStatusTone = 'success' | 'warning' | 'muted' + +export type DistroStatus = { + label: AddServerText + tone: DistroStatusTone +} + +export type AddServerPrimaryButton = { + variant: 'neutral' | 'contrast' + label: AddServerText + disabled: boolean + action: 'install-opencode' | 'add' | null + loading: boolean + width: string | null +} + +export type AddServerRuntimeState = 'loading' | 'pendingRestart' | 'checking' | 'unavailable' | 'ready' + +export type AddableProbePlan = { + key: string + distros: string[] +} + +export type AutoProbePlan = { key: 'runtime'; action: 'probeRuntime' } | { key: 'distros'; action: 'refreshDistros' } + +export type AddServerProbePlan = + | { kind: 'auto'; key: string; plan: AutoProbePlan } + | { kind: 'addable'; key: string; plan: AddableProbePlan } + +export type WslAddServerView = 'main' | 'catalog' + +export function isHiddenDistro(name: string) { + return /^docker-desktop(?:-data)?$/i.test(name) +} + +/** 判定是否为 WSL 服务器:后端注册 WSL 服务器时用确定性 id 约定 `wsl:`, + * 前端(侧边栏徽标、设置页过滤等)据此识别,无需依赖 store 状态 */ +export function isWslServerId(id: string): boolean { + return id.startsWith('wsl:') +} + +export const wslRuntimeRetryable = (runtime: WslServerRuntime) => runtime.kind === 'failed' || runtime.kind === 'stopped' + +export function wslOpencodeAction(check?: WslOpencodeCheck) { + if (!check) return + if (!check.resolvedPath) return 'wsl.onboarding.installOpencode' + if (check.matchesDesktop === false) return 'wsl.onboarding.updateOpencode' +} + +export function wslDistroReady(state: WslServersState | undefined, name: string) { + const installed = state?.installed.find(item => item.name === name) + const probe = state?.distroProbes[name] + if (!probe || !installed) return false + if (installed.version === 1) return false + return probe.canExecute && probe.hasBash && probe.hasCurl +} + +export function addServerViewModel(input: { + state: WslServersState | undefined + view: WslAddServerView + selectedDistro: string | null + catalogSearch: string + catalogTarget: string | null + adding: boolean + probingAddable: boolean +}) { + const state = input.state + const visibleInstalledDistros = (state?.installed ?? []).filter(item => !isHiddenDistro(item.name)) + const visibleOnlineDistros = (state?.online ?? []).filter(item => !isHiddenDistro(item.name)) + const existingServerDistros = new Set((state?.servers ?? []).map(item => item.config.distro)) + const addableInstalledDistros = visibleInstalledDistros.filter(item => !existingServerDistros.has(item.name)) + const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros) + const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null + const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros) + const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch) + const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros) + const busy = !!state?.job || input.adding || input.probingAddable + // 发行版列表正在后台刷新:runtime 已就绪但列表尚未到达时, + // 用它区分「检测中」和「确实没有发行版」,避免冷启动时误报"尚未检测到" + const distrosChecking = state?.job?.kind === 'distros' + + return { + busy, + distrosChecking, + runtimeState: addServerRuntimeState(state), + visibleInstalledDistros, + visibleOnlineDistros, + addableInstalledDistros, + selectedDistro, + opencodeCheck, + wslReady: !!state?.runtime?.available && !state?.pendingRestart, + distroStatuses: Object.fromEntries( + addableInstalledDistros.flatMap(item => { + const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable }) + if (!status) return [] + return [[item.name, status]] + }), + ) as Record, + primaryButton: addServerPrimaryButton({ + state, + selectedDistro, + opencodeCheck, + adding: input.adding, + probingAddable: input.probingAddable, + }), + installableDistros, + filteredInstallableDistros, + catalogTarget, + installingCatalogDistro: state?.job?.kind === 'install-distro', + } +} + +function addServerSelectedDistro( + selected: string | null, + visibleInstalledDistros: WslInstalledDistro[], + addableInstalledDistros: WslInstalledDistro[], +) { + if (selected && addableInstalledDistros.some(item => item.name === selected && item.version !== 1)) return selected + const defaultDistro = visibleInstalledDistros.find(item => item.isDefault) + if ( + defaultDistro && + defaultDistro.version !== 1 && + addableInstalledDistros.some(item => item.name === defaultDistro.name) + ) { + return defaultDistro.name + } + return addableInstalledDistros.find(item => item.version !== 1)?.name ?? null +} + +function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState { + if (!state) return 'loading' + if (state.pendingRestart) return 'pendingRestart' + if (!state.runtime) return 'checking' + if (!state.runtime.available) return 'unavailable' + return 'ready' +} + +function addServerDistroStatus(input: { + state: WslServersState | undefined + name: string + probingAddable: boolean +}): DistroStatus | undefined { + const installed = input.state?.installed.find(item => item.name === input.name) + if (installed?.version === 1) return { label: { key: 'wsl.onboarding.distroStatus.unsupported' }, tone: 'muted' } + const job = input.state?.job + const probe = input.state?.distroProbes[input.name] + if (!probe) { + if (input.probingAddable || (job?.kind === 'probe-addable' && job.distros.includes(input.name))) { + return checkingStatus() + } + return + } + if (!probe.canExecute) { + if (!installed) { + return { label: { key: 'wsl.onboarding.distroNotInstalled', params: { distro: input.name } }, tone: 'warning' } + } + return { label: { key: 'wsl.onboarding.openDistroOnce', params: { distro: input.name } }, tone: 'warning' } + } + if (!probe.hasBash || !probe.hasCurl) { + return { label: { key: 'wsl.onboarding.distroStatus.missingTools' }, tone: 'warning' } + } + const check = input.state?.opencodeChecks[input.name] + if (!check) { + if (input.probingAddable || (job?.kind === 'probe-addable' && job.distros.includes(input.name))) { + return checkingStatus() + } + return + } + if (check.matchesDesktop === false) return { label: { key: 'wsl.onboarding.updateOpencode' }, tone: 'warning' } + if (!check.resolvedPath) return { label: { key: 'wsl.onboarding.distroStatus.opencodeMissing' }, tone: 'warning' } + if (check.error) return { label: { key: 'wsl.onboarding.installOpencode' }, tone: 'warning' } + return { label: { key: 'wsl.onboarding.distroStatus.ready' }, tone: 'success' } +} + +function checkingStatus(): DistroStatus { + return { label: { key: 'wsl.onboarding.distroStatus.checking' }, tone: 'muted' } +} + +function addServerPrimaryButton(input: { + state: WslServersState | undefined + selectedDistro: string | null + opencodeCheck: WslOpencodeCheck | null + adding: boolean + probingAddable: boolean +}): AddServerPrimaryButton { + const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro) + const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro) + const probingOpencode = + probingSelected || + (ready && + (!input.opencodeCheck || + (!!input.selectedDistro && + input.state?.job?.kind === 'probe-addable' && + input.state.job.distros.includes(input.selectedDistro)))) + const installingOpencode = + input.state?.job?.kind === 'install-opencode' && input.state.job.distro === input.selectedDistro + if (!ready || probingOpencode) { + return { + variant: 'contrast', + label: probingSelected ? { key: 'wsl.onboarding.distroStatus.checking' } : { key: 'wsl.server.add' }, + disabled: true, + action: null, + loading: probingSelected, + width: null, + } + } + if (!addServerOpencodeReady(input.opencodeCheck)) { + const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false + return { + variant: 'neutral', + label: installingOpencode + ? { key: 'wsl.onboarding.updatingOpencode' } + : update + ? { key: 'wsl.onboarding.updateOpencode' } + : { key: 'wsl.onboarding.installOpencode' }, + disabled: !!input.state?.job || input.adding, + action: 'install-opencode', + loading: installingOpencode, + width: update ? '138px' : '129px', + } + } + return { + variant: 'contrast', + label: input.adding ? { key: 'wsl.onboarding.adding' } : { key: 'wsl.server.add' }, + disabled: input.adding || !!input.state?.job, + action: 'add', + loading: input.adding, + width: null, + } +} + +function addServerOpencodeReady(check: WslOpencodeCheck | null) { + return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error +} + +function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) { + if (!selectedDistro) return false + const installed = state?.installed.find(item => item.name === selectedDistro) + if (installed?.version === 1) return false + if (!state?.distroProbes[selectedDistro]) return false + if (!wslDistroReady(state, selectedDistro)) return true + return !!state.opencodeChecks[selectedDistro] +} + +function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) { + const installed = new Set(installedDistros.map(item => item.name)) + const hasVersionedUbuntu = onlineDistros.some(item => /^Ubuntu-\d/.test(item.name)) + return onlineDistros + .filter(item => !installed.has(item.name)) + .filter(item => item.name !== 'Ubuntu' || !hasVersionedUbuntu) +} + +function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) { + const query = search.trim() + if (!query) return installableDistros + return fuzzysort.go(query, installableDistros, { keys: ['label', 'name'] }).map(item => item.obj) +} + +function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) { + if (target && distros.some(item => item.name === target)) return target + return distros[0]?.name ?? null +} + +// ============================================ +// 自动探测计划(官方 autoProbePlan / addableProbePlan / addServerProbePlan) +// 弹窗的 effect 每次状态变化重算:该探什么、探什么 key,全部由纯函数决定 +// ============================================ + +export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) { + if (!input.state || input.busy || input.state.pendingRestart) return + if (!input.state.runtime) return { key: 'runtime', action: 'probeRuntime' } satisfies AutoProbePlan + if (!input.state.runtime.available) return + if (input.state.installed.length || input.state.online.length) return + return { key: 'distros', action: 'refreshDistros' } satisfies AutoProbePlan +} + +export function addableProbePlan(input: { + state: WslServersState | undefined + view: WslAddServerView + adding: boolean + selectedDistro: string | null + addableInstalledDistros: WslInstalledDistro[] +}) { + const state = input.state + if (!state?.runtime?.available || state.pendingRestart || input.view !== 'main' || input.adding) return + if (state.job) return + const ordered = input.selectedDistro + ? [ + ...input.addableInstalledDistros.filter(item => item.name === input.selectedDistro), + ...input.addableInstalledDistros.filter(item => item.name !== input.selectedDistro), + ] + : input.addableInstalledDistros + const pending = ordered.flatMap(item => { + if (item.version === 1) return [] + if (!state.distroProbes[item.name]) return [`distro:${item.name}`] + if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`] + return [] + }) + if (!pending.length) return + return { + key: pending.join('|'), + distros: ordered.filter(item => item.version !== 1).map(item => item.name), + } satisfies AddableProbePlan +} + +export function addServerProbePlan(input: { + state: WslServersState | undefined + view: WslAddServerView + adding: boolean + busy: boolean + selectedDistro: string | null + addableInstalledDistros: WslInstalledDistro[] +}) { + const auto = autoProbePlan({ state: input.state, busy: input.busy }) + if (auto) return { kind: 'auto', key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan + const addable = addableProbePlan(input) + if (addable) return { kind: 'addable', key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan +} + +/** 失败门控(官方 createProbeFailureGate):同 key 失败后不再自动重试, + * 用户显式操作(checkAgain 等)时 reset 解除 */ +export function createProbeFailureGate() { + let failed: string | undefined + return { + accepts(key: string) { + return key !== failed + }, + settle(key: string, error?: unknown) { + if (error) failed = key + }, + reset() { + failed = undefined + }, + } +} + +// 重新导出探测结果类型(供组件使用) +export type { WslDistroProbe } diff --git a/src/features/wsl/types.ts b/src/features/wsl/types.ts new file mode 100644 index 00000000..34d36db9 --- /dev/null +++ b/src/features/wsl/types.ts @@ -0,0 +1,102 @@ +// ============================================ +// WSL 类型定义 —— 与官方桌面端 packages/app/src/wsl/types.ts 一一对应 +// Rust 端序列化为 camelCase,此处字段名与 JSON 完全一致 +// ============================================ + +/** WSL 运行时检查结果 */ +export interface WslRuntimeCheck { + available: boolean + version: string | null + error: string | null +} + +/** 已安装的 WSL 发行版 */ +export interface WslInstalledDistro { + name: string + version: number | null // WSL 1 或 2 + isDefault: boolean +} + +/** 在线可用的发行版 */ +export interface WslOnlineDistro { + name: string + label: string +} + +/** 发行版探测结果 */ +export interface WslDistroProbe { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + error: string | null +} + +/** OpenCode 检查结果(matchesDesktop/expectedVersion 结构保留,见 Rust 端说明) */ +export interface WslOpencodeCheck { + distro: string + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} + +/** WSL 服务器配置(官方同款:确定性 id = `wsl:`,端口每次启动动态分配) */ +export interface WslServerConfig { + id: string + distro: string +} + +/** WSL 服务器运行时状态 */ +export type WslServerRuntime = + | { kind: 'starting' } + | { kind: 'ready'; url: string; username: string | null; password: string | null } + | { kind: 'failed'; message: string } + | { kind: 'stopped' } + +/** WSL 服务器项 */ +export interface WslServerItem { + config: WslServerConfig + runtime: WslServerRuntime +} + +/** WSL 任务类型 */ +export type WslJob = + | { kind: 'runtime'; startedAt: number } + | { kind: 'distros'; startedAt: number } + | { kind: 'install-wsl'; startedAt: number } + | { kind: 'install-distro'; distro: string; startedAt: number } + | { kind: 'probe-addable'; distros: string[]; startedAt: number } + | { kind: 'install-opencode'; distro: string; startedAt: number } + +/** WSL 服务器全局状态 */ +export interface WslServersState { + runtime: WslRuntimeCheck | null + installed: WslInstalledDistro[] + online: WslOnlineDistro[] + distroProbes: Record + opencodeChecks: Record + pendingRestart: boolean + servers: WslServerItem[] + job: WslJob | null +} + +/** 后端推送的状态事件 */ +export type WslServersEvent = { type: 'state'; state: WslServersState } + +/** 前端可用的 WSL 平台能力(官方 WslServersPlatform) */ +export interface WslServersPlatform { + getState(): Promise + subscribe(cb: (event: WslServersEvent) => void): () => void + probeRuntime(): Promise + refreshDistros(): Promise + installWsl(): Promise + installDistro(name: string): Promise + probeAddable(distros: string[]): Promise + installOpencode(name: string): Promise + openTerminal(name: string): Promise + addServer(distro: string): Promise + removeServer(id: string): Promise + startServer(id: string): Promise +} diff --git a/src/hooks/useServerStore.ts b/src/hooks/useServerStore.ts index ac93a36c..e714e593 100644 --- a/src/hooks/useServerStore.ts +++ b/src/hooks/useServerStore.ts @@ -28,6 +28,13 @@ export function useServerStore() { () => serverStore.getAllHealth(), ) + // 启动默认服务器偏好(setDefaultServer 内部会 notify,这里能正常感知) + const defaultServerId = useSyncExternalStore( + serverStore.subscribe.bind(serverStore), + () => serverStore.getDefaultServerId(), + () => serverStore.getDefaultServerId(), + ) + const addServer = useCallback((config: Omit) => { return serverStore.addServer(config) }, []) @@ -63,6 +70,7 @@ export function useServerStore() { servers, activeServer, healthMap, + defaultServerId, addServer, updateServer, removeServer, diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 414605fb..b41bc9a9 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -70,7 +70,9 @@ "multiServerModeOffHint": "When enabled, only the servers you subscribe to appear in the sidebar session list.", "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", - "enableMultiServerFirst": "Enable multi-server mode first" + "enableMultiServerFirst": "Enable multi-server mode first", + "addRemote": "Add remote server", + "addWsl": "Add WSL server" }, "chat": { "pathsFormatting": "Paths & Formatting", @@ -440,5 +442,55 @@ "message": "Message", "permission": "Permission" } + }, + "wsl": { + "addWslServer": "Add WSL Server", + "deleteServerTitle": "Delete WSL Server", + "deleteServerConfirm": "Delete {{name}}? Any running instance will be stopped.", + "server": { + "add": "Add WSL Server", + "label": "WSL", + "retryStart": "Retry start", + "updating": "Updating...", + "setDefault": "Set as default server", + "unsetDefault": "Unset default server", + "defaultTag": "Default" + }, + "onboarding": { + "installWsl": "Install WSL", + "restartRequired": "Windows needs to restart to finish installing WSL.", + "windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.", + "wslNotInstalled": { + "title": "WSL is not installed", + "description": "OpenCode needs WSL (Windows Subsystem for Linux) to add WSL servers" + }, + "wslUnavailable": { + "title": "WSL is unavailable", + "description": "OpenCode could not verify WSL on this device." + }, + "installedDistros": "Installed distributions", + "checkAgain": "Check again", + "allDistrosAdded": "All installed distributions have been added.", + "checkingDistros": "Detecting distributions…", + "noDistros": "No distributions detected.", + "openDistroOnce": "Open {{distro}} once to finish setup.", + "installOpencode": "Install OpenCode", + "installDistro": "Install distribution", + "searchDistros": "Search distributions", + "adding": "Adding...", + "needAnotherDistro": "Need another distribution?", + "needAnotherDistroHint": "Install a Linux distribution from the WSL catalog", + "openTerminal": "Open terminal", + "distroStatus": { + "ready": "Ready", + "checking": "Checking...", + "opencodeMissing": "OpenCode not installed", + "missingTools": "Missing bash, curl", + "unsupported": "Unsupported · WSL 2 required" + }, + "distroNotInstalled": "{{distro}} is not installed yet.", + "updateOpencode": "Update OpenCode", + "updatingOpencode": "Updating OpenCode..." + } } } diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index ee7e7af7..8f145a5c 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -70,7 +70,9 @@ "multiServerModeOffHint": "开启后,只有你订阅的服务器会出现在侧栏会话列表中。", "subscribe": "订阅", "unsubscribe": "取消订阅", - "enableMultiServerFirst": "请先开启多服务器模式" + "enableMultiServerFirst": "请先开启多服务器模式", + "addRemote": "添加远程服务器", + "addWsl": "添加 WSL 服务器" }, "chat": { "pathsFormatting": "路径和格式", @@ -440,5 +442,55 @@ "message": "消息", "permission": "权限" } + }, + "wsl": { + "addWslServer": "添加 WSL 服务器", + "deleteServerTitle": "删除 WSL 服务器", + "deleteServerConfirm": "确定删除 {{name}} 吗?正在运行的实例将被停止。", + "server": { + "add": "添加 WSL 服务器", + "label": "WSL", + "retryStart": "重试启动", + "updating": "正在更新...", + "setDefault": "设为默认服务器", + "unsetDefault": "取消默认服务器", + "defaultTag": "默认" + }, + "onboarding": { + "installWsl": "安装 WSL", + "restartRequired": "Windows 需要重启才能完成 WSL 安装。", + "windowsRestartRequired": "重启 Windows 以完成 WSL 安装,然后重新打开 OpenCode。", + "wslNotInstalled": { + "title": "未安装 WSL", + "description": "OpenCode 需要 WSL(Windows Subsystem for Linux)才能添加 WSL 服务器" + }, + "wslUnavailable": { + "title": "WSL 不可用", + "description": "OpenCode 无法验证此设备上的 WSL。" + }, + "installedDistros": "已安装的发行版", + "checkAgain": "重新检查", + "allDistrosAdded": "所有已安装的发行版均已添加。", + "checkingDistros": "正在检测发行版…", + "noDistros": "尚未检测到发行版。", + "openDistroOnce": "打开一次 {{distro}} 以完成设置。", + "installOpencode": "安装 OpenCode", + "installDistro": "安装发行版", + "searchDistros": "搜索发行版", + "adding": "正在添加...", + "needAnotherDistro": "需要其他发行版?", + "needAnotherDistroHint": "从 WSL 目录安装 Linux 发行版", + "openTerminal": "打开终端", + "distroStatus": { + "ready": "已就绪", + "checking": "检查中...", + "opencodeMissing": "未安装 OpenCode", + "missingTools": "缺少 bash、curl", + "unsupported": "不支持 · 请使用 WSL 2" + }, + "distroNotInstalled": "{{distro}} 尚未安装。", + "updateOpencode": "更新 OpenCode", + "updatingOpencode": "正在更新 OpenCode..." + } } } diff --git a/src/main.tsx b/src/main.tsx index b3209ba2..a39530d6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import { themeStore } from './store/themeStore' import { serverStore } from './store/serverStore' import { autoApproveStore } from './store/autoApproveStore' import { serviceStore } from './store/serviceStore' +import { wslStore } from './store/wslStore' import { reconnectSSE } from './api/events' import { getSDKClientAsync, invalidateSDKClient } from './api/sdk' import { resetPathModeCache } from './utils/directoryUtils' @@ -47,6 +48,9 @@ if ('scrollRestoration' in history) { // 初始化主题系统(在 React 渲染前注入 CSS 变量,避免闪烁) themeStore.init() +// 订阅 WSL 后端状态推送(Windows 桌面端:事件驱动,非 Tauri 环境下静默) +wslStore.start() + // 全局 overlay 滚动条 — 等 DOM 就绪后启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initOverlayScrollbars) diff --git a/src/store/serverStore.test.ts b/src/store/serverStore.test.ts index e5000800..32649389 100644 --- a/src/store/serverStore.test.ts +++ b/src/store/serverStore.test.ts @@ -115,6 +115,301 @@ describe('serverStore local runtime URL', () => { }) }) +describe('serverStore WSL persistence', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + it('does not persist wsl: servers into localStorage', async () => { + const { serverStore } = await import('./serverStore') + + serverStore.upsertServer({ + id: 'wsl:Ubuntu-22.04', + name: 'Ubuntu-22.04', + url: 'http://127.0.0.1:58231', + auth: { username: 'opencode', password: 'one-time' }, + }) + serverStore.upsertServer({ id: 'local', name: 'Local', url: 'http://127.0.0.1:4096' }) + + const stored = JSON.parse(localStorage.getItem('opencode-servers') ?? '[]') as Array<{ id: string }> + expect(stored.some(server => server.id.startsWith('wsl:'))).toBe(false) + expect(stored.map(server => server.id)).toContain('local') + // 内存里仍然可用(本会话照常连接) + expect(serverStore.getServer('wsl:Ubuntu-22.04')).not.toBeNull() + }) + + it('cleans up legacy persisted wsl: entries on load', async () => { + localStorage.setItem( + 'opencode-servers', + JSON.stringify([ + { id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }, + { + id: 'wsl:Ubuntu-22.04', + name: 'Ubuntu-22.04', + url: 'http://127.0.0.1:11111', + auth: { username: 'opencode', password: 'stale' }, + }, + ]), + ) + + const { serverStore } = await import('./serverStore') + + expect(serverStore.getStoredServers().some(server => server.id.startsWith('wsl:'))).toBe(false) + expect(serverStore.getServer('wsl:Ubuntu-22.04')).toBeNull() + }) + + it('keeps a persisted wsl: active id as boot intent and falls back to a list server', async () => { + localStorage.setItem( + 'opencode-servers', + JSON.stringify([{ id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }]), + ) + localStorage.setItem('opencode-active-server', 'wsl:Ubuntu-22.04') + + const { serverStore } = await import('./serverStore') + + expect(serverStore.getBootIntentServerId()).toBe('wsl:Ubuntu-22.04') + expect(serverStore.getActiveServerId()).toBe('local') + }) +}) + +describe('serverStore upsertServer runtime change', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + it('notifies server-runtime-updated when the active server url changes', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://127.0.0.1:11111' }) + serverStore.setActiveServer(remote.id) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + serverStore.upsertServer({ id: remote.id, name: 'Remote', url: 'http://127.0.0.1:22222' }) + + expect(listener).toHaveBeenCalledWith(remote.id, 'server-runtime-updated') + expect(serverStore.getActiveBaseUrl()).toBe('http://127.0.0.1:22222') + }) + + it('notifies server-runtime-updated when only the active server auth changes', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://127.0.0.1:11111' }) + serverStore.setActiveServer(remote.id) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + serverStore.upsertServer({ + id: remote.id, + name: 'Remote', + url: 'http://127.0.0.1:11111', + auth: { username: 'opencode', password: 'rotated' }, + }) + + expect(listener).toHaveBeenCalledWith(remote.id, 'server-runtime-updated') + }) + + it('does not notify when url and auth are unchanged', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ + name: 'Remote', + url: 'http://127.0.0.1:11111', + auth: { username: 'opencode', password: 'same' }, + }) + serverStore.setActiveServer(remote.id) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + // 尾部斜杠应被归一化掉,改名 + 相同 url/auth 不构成 runtime 变更 + serverStore.upsertServer({ + id: remote.id, + name: 'Remote renamed', + url: 'http://127.0.0.1:11111/', + auth: { username: 'opencode', password: 'same' }, + }) + + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify when a non-active server url changes', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://127.0.0.1:11111' }) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + serverStore.upsertServer({ id: remote.id, name: 'Remote', url: 'http://127.0.0.1:22222' }) + + expect(listener).not.toHaveBeenCalled() + }) +}) + +describe('serverStore default server preference', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + it('persists the preference without touching the active server', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + + serverStore.setDefaultServer(remote.id) + + expect(serverStore.getDefaultServerId()).toBe(remote.id) + expect(localStorage.getItem('opencode-default-server')).toBe(remote.id) + expect(serverStore.getActiveServerId()).toBe('local') + }) + + it('accepts a wsl: preference that is not in the list yet and can be cleared', async () => { + const { serverStore } = await import('./serverStore') + + serverStore.setDefaultServer('wsl:Ubuntu-22.04') + expect(serverStore.getDefaultServerId()).toBe('wsl:Ubuntu-22.04') + + serverStore.setDefaultServer(null) + expect(serverStore.getDefaultServerId()).toBeNull() + expect(localStorage.getItem('opencode-default-server')).toBeNull() + }) + + it('applies a list default preference over the persisted active id on load', async () => { + localStorage.setItem( + 'opencode-servers', + JSON.stringify([ + { id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }, + { id: 'remote', name: 'Remote', url: 'http://remote.test' }, + ]), + ) + localStorage.setItem('opencode-active-server', 'remote') + localStorage.setItem('opencode-default-server', 'local') + + const { serverStore } = await import('./serverStore') + + expect(serverStore.getActiveServerId()).toBe('local') + }) + + it('keeps a wsl: default preference without forcing it as active on load', async () => { + localStorage.setItem( + 'opencode-servers', + JSON.stringify([{ id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }]), + ) + localStorage.setItem('opencode-default-server', 'wsl:Ubuntu-22.04') + + const { serverStore } = await import('./serverStore') + + expect(serverStore.getDefaultServerId()).toBe('wsl:Ubuntu-22.04') + expect(serverStore.getActiveServerId()).toBe('local') + }) + + it('drops a default preference that points to a removed server', async () => { + localStorage.setItem( + 'opencode-servers', + JSON.stringify([{ id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }]), + ) + localStorage.setItem('opencode-default-server', 'gone') + + const { serverStore } = await import('./serverStore') + + expect(serverStore.getDefaultServerId()).toBeNull() + }) +}) + +describe('serverStore settings backup', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + it('exports without wsl: servers and with the default preference', async () => { + const { serverStore, exportServerSettingsBackup } = await import('./serverStore') + serverStore.upsertServer({ id: 'wsl:Ubuntu-22.04', name: 'Ubuntu-22.04', url: 'http://127.0.0.1:58231' }) + serverStore.setDefaultServer('wsl:Ubuntu-22.04') + + const backup = exportServerSettingsBackup() + + expect(backup.servers.some(server => server.id.startsWith('wsl:'))).toBe(false) + expect(backup.defaultServerId).toBe('wsl:Ubuntu-22.04') + }) + + it('imports a legacy backup without defaultServerId and cleans wsl: entries', async () => { + const serverStoreModule = await import('./serverStore') + serverStoreModule.importServerSettingsBackup({ + servers: [ + { id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }, + { id: 'wsl:Ubuntu-22.04', name: 'Ubuntu-22.04', url: 'http://127.0.0.1:11111' }, + ], + activeServerId: 'wsl:Ubuntu-22.04', + }) + + // 导入即清洗:持久层落盘时就不带 wsl: 条目 + const stored = JSON.parse(localStorage.getItem('opencode-servers') ?? '[]') as Array<{ id: string }> + expect(stored.map(server => server.id)).toEqual(['local']) + expect(localStorage.getItem('opencode-default-server')).toBeNull() + + // 重新加载 store:active 回退到列表内服务器,偏好为空 + vi.resetModules() + const { serverStore } = await import('./serverStore') + expect(serverStore.getStoredServers().some(server => server.id.startsWith('wsl:'))).toBe(false) + expect(serverStore.getActiveServerId()).toBe('local') + expect(serverStore.getDefaultServerId()).toBeNull() + }) + + it('imports a backup with defaultServerId', async () => { + const serverStoreModule = await import('./serverStore') + serverStoreModule.importServerSettingsBackup({ + servers: [{ id: 'local', name: 'Local', url: 'http://127.0.0.1:4096', isDefault: true }], + activeServerId: 'local', + defaultServerId: 'wsl:Ubuntu-22.04', + }) + + expect(localStorage.getItem('opencode-default-server')).toBe('wsl:Ubuntu-22.04') + }) +}) + +describe('serverStore removeServer active fallback', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + it('notifies server-switch to the fallback server when the active server is removed', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + serverStore.setActiveServer(remote.id) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + expect(serverStore.removeServer(remote.id)).toBe(true) + + // SSE 等消费方依赖该通知从死掉/被删的服务器优雅降级到回退目标 + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith('local', 'server-switch') + expect(serverStore.getActiveServerId()).toBe('local') + }) + + it('does not notify when removing a non-active server', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + + const listener = vi.fn() + serverStore.onServerChange(listener) + + expect(serverStore.removeServer(remote.id)).toBe(true) + + expect(listener).not.toHaveBeenCalled() + expect(serverStore.getActiveServerId()).toBe('local') + }) +}) + describe('serverStore health check', () => { beforeEach(() => { vi.resetModules() diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index ee2d5afa..b2fe9c06 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -54,6 +54,8 @@ export interface ServerHealth { export interface ServerSettingsBackup { servers: ServerConfig[] activeServerId: string | null + /** 启动默认服务器偏好;旧备份没有该字段,导入时按无偏好处理 */ + defaultServerId?: string | null } interface ServerClockCalibration { @@ -62,7 +64,7 @@ interface ServerClockCalibration { } type Listener = () => void -export type ServerChangeReason = 'server-switch' | 'local-runtime-url' +export type ServerChangeReason = 'server-switch' | 'local-runtime-url' | 'server-runtime-updated' function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) @@ -129,8 +131,19 @@ function formatExceptionDiagnostics(url: string, err: unknown): string { const STORAGE_KEY = 'opencode-servers' const ACTIVE_SERVER_KEY = 'opencode-active-server' +/** 启动默认服务器偏好(官方 defaultKey 等价物):决定下次启动优先连谁 */ +const DEFAULT_SERVER_KEY = 'opencode-default-server' +/** + * WSL 服务器 id 前缀(单一事实源,wslStore 以 `wsl:` 生成稳定 id)。 + * WSL 服务器端口/密码每次启动都变(一次性凭据),由后端全权管理,前端从不持久化其连接信息。 + */ +export const WSL_SERVER_PREFIX = 'wsl:' export const LOCAL_SERVER_ID = 'local' +function isSameServerAuth(a: ServerAuth | undefined, b: ServerAuth | undefined): boolean { + return a?.username === b?.username && a?.password === b?.password +} + /** * Server Store * 管理多个 OpenCode 后端服务器配置 @@ -143,6 +156,10 @@ class ServerStore { private clockCalibrationMap = new Map() private listeners: Set = new Set() private localServerUrlOverride: string | null = null + // 上次关闭时用户停留的 WSL 服务器 id(wslStore 就绪后据此恢复 active) + private bootIntentServerId: string | null = null + // 启动默认服务器偏好(内存缓存,随 notify 快照供 React 订阅) + private defaultServerId: string | null = null // server 切换监听器(用于触发 SSE 重连等副作用,避免循环依赖) private serverChangeListeners: Set<(newServerId: string, reason: ServerChangeReason) => void> = new Set() @@ -166,10 +183,19 @@ class ServerStore { private loadFromStorage(): void { try { - // 加载服务器列表 + // 启动意图:先于校验捕获「上次关闭时用户在用的服务器 id」原始值。 + // 若是 wsl: 前缀,它在清洗后的列表里必然校验失败(正常回退),但 wslStore 需要这个 + // 原始 id 才能在服务器就绪后把 active 切回去 + const persistedActiveId = sessionStorage.getItem(ACTIVE_SERVER_KEY) ?? localStorage.getItem(ACTIVE_SERVER_KEY) + this.bootIntentServerId = persistedActiveId?.startsWith(WSL_SERVER_PREFIX) ? persistedActiveId : null + + // 加载服务器列表。WSL 条目不入持久层(见 saveToStorage),这里同步清洗历史版本 + // 遗留的 wsl: 条目,避免老用户下次启动先落在死地址上 const stored = localStorage.getItem(STORAGE_KEY) if (stored) { - this.servers = JSON.parse(stored) + this.servers = (JSON.parse(stored) as ServerConfig[]).filter( + server => typeof server?.id === 'string' && !server.id.startsWith(WSL_SERVER_PREFIX), + ) } // 如果没有服务器,添加默认的本地服务器 @@ -184,12 +210,24 @@ class ServerStore { ] } - // 加载当前选中的服务器 - // 优先从 sessionStorage 读取(per-window 隔离,刷新保持) - // 回退到 localStorage(新窗口首次打开时继承上次默认) - const activeId = sessionStorage.getItem(ACTIVE_SERVER_KEY) ?? localStorage.getItem(ACTIVE_SERVER_KEY) - if (activeId && this.servers.some(s => s.id === activeId)) { - this.activeServerId = activeId + // 加载默认服务器偏好。wsl: 前缀同样合法——该服务器此刻可能尚未注册, + // 由 wslStore 在就绪后接入 + const defaultPref = localStorage.getItem(DEFAULT_SERVER_KEY) + this.defaultServerId = + defaultPref && (defaultPref.startsWith(WSL_SERVER_PREFIX) || this.servers.some(s => s.id === defaultPref)) + ? defaultPref + : null + + // 解析 active:默认偏好是列表内的普通服务器时优先于上次 active; + // 偏好是 wsl: 前缀时不改动 active(wslStore 负责就绪后切回) + if (this.defaultServerId && !this.defaultServerId.startsWith(WSL_SERVER_PREFIX)) { + this.activeServerId = this.defaultServerId + } else if ( + persistedActiveId && + !persistedActiveId.startsWith(WSL_SERVER_PREFIX) && + this.servers.some(s => s.id === persistedActiveId) + ) { + this.activeServerId = persistedActiveId } else { // 默认选中第一个 this.activeServerId = this.servers[0]?.id ?? null @@ -205,14 +243,20 @@ class ServerStore { }, ] this.activeServerId = this.DEFAULT_SERVER_ID + this.bootIntentServerId = null + this.defaultServerId = null } } private saveToStorage(): void { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.servers)) + // WSL 服务器端口/密码一次性,持久化只会产生下次启动落在死地址上的幽灵条目, + // 因此只持久化非 WSL 服务器 + const persistable = this.servers.filter(s => !s.id.startsWith(WSL_SERVER_PREFIX)) + localStorage.setItem(STORAGE_KEY, JSON.stringify(persistable)) if (this.activeServerId) { - // 写入 sessionStorage(当前窗口刷新保持)+ localStorage(新窗口默认值) + // 写入 sessionStorage(当前窗口刷新保持)+ localStorage(新窗口默认值)。 + // activeId 即便是 wsl: 前缀也照写——它是下次启动的「启动意图」,loadFromStorage 依赖它 sessionStorage.setItem(ACTIVE_SERVER_KEY, this.activeServerId) localStorage.setItem(ACTIVE_SERVER_KEY, this.activeServerId) } @@ -370,6 +414,44 @@ class ServerStore { return calibration.serverTimestamp + (performance.now() - calibration.calibratedAtMonotonic) } + /** + * 上次关闭时用户停留的 WSL 服务器 id。 + * 语义:仅启动意图,wslStore 在该服务器就绪后消费一次并切回 active;非 WSL 服务器不走此通道。 + */ + getBootIntentServerId(): string | null { + return this.bootIntentServerId + } + + // ============================================ + // 默认服务器偏好(官方 defaultKey 等价物) + // ============================================ + + /** + * 启动默认服务器偏好。校验在 loadFromStorage 完成:id 在列表中,或以 wsl: 开头 + * (WSL 服务器启动时可能尚未注册)。 + */ + getDefaultServerId(): string | null { + return this.defaultServerId + } + + /** + * 设置启动默认服务器偏好。只持久化偏好,不改动当前 active; + * 写入后 notify(),让 useSyncExternalStore 订阅方(useServerStore.defaultServerId)能感知。 + */ + setDefaultServer(id: string | null): void { + this.defaultServerId = id + try { + if (id) { + localStorage.setItem(DEFAULT_SERVER_KEY, id) + } else { + localStorage.removeItem(DEFAULT_SERVER_KEY) + } + } catch { + // 持久化失败只影响下次启动的默认选择,不回滚本次会话的偏好 + } + this.notify() + } + // ============================================ // Mutations // ============================================ @@ -390,6 +472,36 @@ class ServerStore { return server } + /** + * 幂等注册:按 id 更新(存在时),不存在则添加。 + * 供 WSL 等由外部模块管理生命周期的服务器使用,id 需要跨重启稳定。 + */ + upsertServer(config: ServerConfig): ServerConfig { + const server: ServerConfig = { + ...config, + url: config.url.replace(/\/+$/, ''), // 移除尾部斜杠 + } + const index = this.servers.findIndex(s => s.id === server.id) + if (index === -1) { + this.servers.push(server) + this.saveToStorage() + this.notify() + return server + } + + // 同 id 更新:WSL sidecar 每次启动端口/密码都变。若连接参数变化且是当前 active, + // 活动中的会话(SSE 等)还挂在旧地址上,必须在 notify 之后通知消费方重连 + const previous = this.servers[index] + const runtimeChanged = previous.url !== server.url || !isSameServerAuth(previous.auth, server.auth) + this.servers[index] = server + this.saveToStorage() + this.notify() + if (runtimeChanged && server.id === this.activeServerId) { + this.notifyServerChange(server.id, 'server-runtime-updated') + } + return server + } + /** * 更新服务器配置 */ @@ -434,18 +546,29 @@ class ServerStore { const server = this.servers.find(s => s.id === id) if (!server || server.isDefault) return false + // 先采样:下面的删除动作会改变 active,之后无法判断「被删的是否是 active」 + const wasActive = this.activeServerId === id + this.servers = this.servers.filter(s => s.id !== id) this.healthMap.delete(id) this.healthCheckSeqMap.delete(id) this.clockCalibrationMap.delete(id) // 如果删除的是当前选中的,切换到默认 - if (this.activeServerId === id) { + if (wasActive) { this.activeServerId = this.servers[0]?.id ?? null } this.saveToStorage() this.notify() + + // 被删的是 active:SSE 等消费方还挂在死掉/被删的服务器上(WSL sidecar 退出是典型场景), + // 必须通知它们跟着切到回退目标,否则连接永远指向死地址。 + // local 是 isDefault 永不可删,回退目标恒存在;getActiveServerId() 对 null 兜底为 'local' + if (wasActive) { + this.notifyServerChange(this.getActiveServerId(), 'server-switch') + } + return true } @@ -632,7 +755,9 @@ function normalizeServerBackup(raw: unknown): ServerSettingsBackup { typeof item === 'object' && typeof (item as Record).id === 'string' && typeof (item as Record).name === 'string' && - typeof (item as Record).url === 'string', + typeof (item as Record).url === 'string' && + // WSL 条目含一次性端口/密码,导入即清洗(兼容旧版本导出的备份文件) + !((item as Record).id as string).startsWith(WSL_SERVER_PREFIX), ) .map(item => ({ id: item.id, @@ -665,19 +790,34 @@ function normalizeServerBackup(raw: unknown): ServerSettingsBackup { ? parsed.activeServerId : (normalizedServers[0]?.id ?? null) + // 默认偏好:旧备份没有该字段 → undefined(视为无偏好);普通 id 必须在清洗后的列表内, + // wsl: 前缀直接放行(该服务器由后端管理,导入时可能尚未注册) + const rawDefaultServerId = parsed?.defaultServerId + const defaultServerId = + typeof rawDefaultServerId === 'string' && + (rawDefaultServerId.startsWith(WSL_SERVER_PREFIX) || normalizedServers.some(server => server.id === rawDefaultServerId)) + ? rawDefaultServerId + : undefined + return { servers: normalizedServers, activeServerId, + defaultServerId, } } export function exportServerSettingsBackup(): ServerSettingsBackup { return { - servers: serverStore.getStoredServers().map(server => ({ - ...server, - auth: server.auth ? { ...server.auth } : undefined, - })), + // WSL 服务器不进备份:端口/密码一次性,导出只会泄漏一次性凭据 + servers: serverStore + .getStoredServers() + .filter(server => !server.id.startsWith(WSL_SERVER_PREFIX)) + .map(server => ({ + ...server, + auth: server.auth ? { ...server.auth } : undefined, + })), activeServerId: serverStore.getActiveServerId(), + defaultServerId: serverStore.getDefaultServerId(), } } @@ -691,6 +831,12 @@ export function importServerSettingsBackup(raw: unknown): void { localStorage.removeItem(ACTIVE_SERVER_KEY) sessionStorage.removeItem(ACTIVE_SERVER_KEY) } + // 导入语义为「完全替换」:旧备份没有 defaultServerId 字段时清除现有偏好 + if (normalized.defaultServerId) { + localStorage.setItem(DEFAULT_SERVER_KEY, normalized.defaultServerId) + } else { + localStorage.removeItem(DEFAULT_SERVER_KEY) + } } /** diff --git a/src/store/wslStore.test.ts b/src/store/wslStore.test.ts new file mode 100644 index 00000000..a74d3fb2 --- /dev/null +++ b/src/store/wslStore.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { reduceWslRestore, resolveWslBootTarget, type WslRestoreState } from './wslStore' + +// 启动/恢复判定都是纯函数:不需要 mock Tauri,只验证判定契约 +describe('resolveWslBootTarget', () => { + it('prefers the wsl default server preference over the boot intent', () => { + expect(resolveWslBootTarget({ defaultServerId: 'wsl:Ubuntu', bootIntentServerId: 'wsl:Debian' })).toBe('wsl:Ubuntu') + }) + + it('falls back to the boot intent server when there is no default preference', () => { + expect(resolveWslBootTarget({ defaultServerId: null, bootIntentServerId: 'wsl:Debian' })).toBe('wsl:Debian') + }) + + it('ignores non-wsl ids from both sources', () => { + expect(resolveWslBootTarget({ defaultServerId: 'remote', bootIntentServerId: 'local' })).toBeNull() + }) + + it('returns null when neither source is set', () => { + expect(resolveWslBootTarget({ defaultServerId: null, bootIntentServerId: null })).toBeNull() + }) +}) + +const IDLE: WslRestoreState = { bootTarget: null, pendingRestoreId: null } + +describe('reduceWslRestore', () => { + it('records the active server as pending restore when it goes unready (died)', () => { + const outcome = reduceWslRestore(IDLE, { kind: 'unready', serverId: 'wsl:Ubuntu', isActive: true }) + + expect(outcome.state.pendingRestoreId).toBe('wsl:Ubuntu') + expect(outcome.shouldRestore).toBe(false) + }) + + it('does not record when a non-active server goes unready', () => { + const outcome = reduceWslRestore(IDLE, { kind: 'unready', serverId: 'wsl:Ubuntu', isActive: false }) + + expect(outcome.state).toEqual(IDLE) + }) + + it('overwrites the pending restore with the most recent active death', () => { + const first = reduceWslRestore(IDLE, { kind: 'unready', serverId: 'wsl:Ubuntu', isActive: true }) + const outcome = reduceWslRestore(first.state, { kind: 'unready', serverId: 'wsl:Debian', isActive: true }) + + expect(outcome.state.pendingRestoreId).toBe('wsl:Debian') + }) + + it('restores a revived server that was active before it died', () => { + const died = reduceWslRestore(IDLE, { kind: 'unready', serverId: 'wsl:Ubuntu', isActive: true }) + const outcome = reduceWslRestore(died.state, { kind: 'ready', serverId: 'wsl:Ubuntu', isActive: false }) + + expect(outcome.shouldRestore).toBe(true) + expect(outcome.state.pendingRestoreId).toBeNull() + }) + + it('does not restore when the revived server is already active but still consumes the memory', () => { + const died = reduceWslRestore(IDLE, { kind: 'unready', serverId: 'wsl:Ubuntu', isActive: true }) + const outcome = reduceWslRestore(died.state, { kind: 'ready', serverId: 'wsl:Ubuntu', isActive: true }) + + expect(outcome.shouldRestore).toBe(false) + expect(outcome.state.pendingRestoreId).toBeNull() + }) + + it('restores and consumes the one-shot boot target when it becomes ready', () => { + const state: WslRestoreState = { bootTarget: 'wsl:Ubuntu', pendingRestoreId: null } + const outcome = reduceWslRestore(state, { kind: 'ready', serverId: 'wsl:Ubuntu', isActive: false }) + + expect(outcome.shouldRestore).toBe(true) + expect(outcome.state.bootTarget).toBeNull() + }) + + it('does not consume the boot target when a different server becomes ready', () => { + const state: WslRestoreState = { bootTarget: 'wsl:Ubuntu', pendingRestoreId: 'wsl:Debian' } + const outcome = reduceWslRestore(state, { kind: 'ready', serverId: 'wsl:Debian', isActive: false }) + + expect(outcome.shouldRestore).toBe(true) + expect(outcome.state.bootTarget).toBe('wsl:Ubuntu') + expect(outcome.state.pendingRestoreId).toBeNull() + }) + + it('keeps the boot target across unready events of other servers', () => { + const state: WslRestoreState = { bootTarget: 'wsl:Ubuntu', pendingRestoreId: null } + const outcome = reduceWslRestore(state, { kind: 'unready', serverId: 'wsl:Debian', isActive: false }) + + expect(outcome.state.bootTarget).toBe('wsl:Ubuntu') + expect(outcome.shouldRestore).toBe(false) + }) + + it('consumes both memories when the same server hits boot target and pending restore', () => { + const state: WslRestoreState = { bootTarget: 'wsl:Ubuntu', pendingRestoreId: 'wsl:Ubuntu' } + const outcome = reduceWslRestore(state, { kind: 'ready', serverId: 'wsl:Ubuntu', isActive: false }) + + expect(outcome.shouldRestore).toBe(true) + expect(outcome.state).toEqual({ bootTarget: null, pendingRestoreId: null }) + }) +}) diff --git a/src/store/wslStore.ts b/src/store/wslStore.ts new file mode 100644 index 00000000..62ef5c65 --- /dev/null +++ b/src/store/wslStore.ts @@ -0,0 +1,204 @@ +// ============================================ +// WSL Store —— 对齐官方 packages/app/src/wsl/context.tsx: +// 单一状态树由后端事件全量推送,store 只做订阅与粘合 +// (把 WSL 服务器的 runtime 同步进 serverStore,供聊天界面选择服务器) +// ============================================ + +import { useSyncExternalStore } from 'react' +import type { WslServersState, WslServersEvent, WslServerItem } from '../features/wsl/types' +import { wslApi, subscribeWslState } from '../api/wsl' +import { serverStore, WSL_SERVER_PREFIX } from './serverStore' +import { multiServerStore } from './multiServerStore' +import { isTauri } from '../utils/tauri' + +type Listener = () => void + +interface WslStoreSnapshot { + state: WslServersState | null +} + +/** + * 启动恢复判定(纯函数,独立导出便于测试): + * 默认服务器偏好优先(官方 defaultKey 语义:默认服务器是 WSL → 就绪后切回), + * 否则回退「上次关闭时用户停留的 WSL 服务器」。只关心 WSL 目标——非 WSL 的 + * 启动偏好在 serverStore 加载时已直接生效,无需恢复。 + */ +export function resolveWslBootTarget(input: { + defaultServerId: string | null + bootIntentServerId: string | null +}): string | null { + if (input.defaultServerId?.startsWith(WSL_SERVER_PREFIX)) return input.defaultServerId + if (input.bootIntentServerId?.startsWith(WSL_SERVER_PREFIX)) return input.bootIntentServerId + return null +} + +/** 恢复状态机状态:bootTarget 启动时算好(一次性消费);pendingRestoreId 进程内累积 */ +export interface WslRestoreState { + /** 启动恢复目标:默认服务器偏好是 WSL,或上次关闭时停留在的 WSL 服务器 */ + bootTarget: string | null + /** 待恢复:死亡(非 ready)前是 active 的 WSL 服务器。覆盖语义,只记最近一个 */ + pendingRestoreId: string | null +} + +/** 服务器生命周期事件:runtime 变为 ready 或离开 ready(starting/failed/stopped 统称 unready) */ +export type WslRestoreEvent = { + kind: 'ready' | 'unready' + serverId: string + /** 事件发生时该服务器是否是 active(采样时机由调用方保证:unready 需在 removeServer 之前采样) */ + isActive: boolean +} + +export interface WslRestoreOutcome { + state: WslRestoreState + /** true 表示应把 active 切回该服务器 */ + shouldRestore: boolean +} + +/** + * WSL 恢复判定状态机(纯函数 reducer,覆盖「启动切回」与「中途死亡-复活切回」两类场景): + * - unready:死亡前是 active 的服务器记入 pendingRestoreId(非 active 死亡不记录); + * bootTarget 不受死亡事件影响。 + * - ready:命中 bootTarget(一次性消费,目标就绪即清)或 pendingRestoreId(消费即清), + * 且当前 active 不是它 → 切回;active 已是它则只消费记忆不切回。 + * 启动恢复与中途恢复共用同一套判定,避免两套并行逻辑。 + */ +export function reduceWslRestore(state: WslRestoreState, event: WslRestoreEvent): WslRestoreOutcome { + if (event.kind === 'unready') { + const pendingRestoreId = event.isActive ? event.serverId : state.pendingRestoreId + return { state: { ...state, pendingRestoreId }, shouldRestore: false } + } + + const hitBoot = state.bootTarget === event.serverId + const hitPending = state.pendingRestoreId === event.serverId + return { + state: { + bootTarget: hitBoot ? null : state.bootTarget, + pendingRestoreId: hitPending ? null : state.pendingRestoreId, + }, + shouldRestore: (hitBoot || hitPending) && !event.isActive, + } +} + +class WslStore { + private _state: WslServersState | null = null + private _listeners: Set = new Set() + private _snapshot: WslStoreSnapshot + private _started = false + // 恢复状态机:bootTarget 启动时算好,pendingRestoreId 随生命周期事件演进 + private _restoreState: WslRestoreState = { bootTarget: null, pendingRestoreId: null } + + constructor() { + this._snapshot = this._makeSnapshot() + } + + /** 应用启动时调用一次:订阅后端状态推送并拉取初始状态 */ + start() { + if (this._started || !isTauri()) return + this._started = true + + // 计算启动恢复目标:默认服务器偏好是 WSL,或上次关闭时停留在 WSL → 该服务器就绪后自动切回 + this._restoreState = { + bootTarget: resolveWslBootTarget({ + defaultServerId: serverStore.getDefaultServerId(), + bootIntentServerId: serverStore.getBootIntentServerId(), + }), + pendingRestoreId: null, + } + + // 订阅周期与应用一致,无需退订 + subscribeWslState((event: WslServersEvent) => { + this._state = event.state + this._syncServers() + this._emit() + }) + void wslApi + .getState() + .then(state => { + this._state = state + this._syncServers() + this._emit() + }) + .catch(error => { + console.error('Failed to load WSL state:', error) + }) + } + + private _makeSnapshot(): WslStoreSnapshot { + return { state: this._state } + } + + private _emit() { + this._snapshot = this._makeSnapshot() + for (const listener of this._listeners) { + listener() + } + } + + subscribe = (listener: Listener): (() => void) => { + this._listeners.add(listener) + return () => { + this._listeners.delete(listener) + } + } + + getSnapshot = (): WslStoreSnapshot => { + return this._snapshot + } + + /** 把 WSL 服务器的 runtime 同步进 serverStore(聊天界面服务器列表粘合层): + * Ready → 注册带鉴权的连接;Failed/Stopped/Starting 未就绪 → 移除 */ + private _syncServers() { + const state = this._state + if (!state) return + for (const item of state.servers) { + this._syncServer(item) + } + } + + private _syncServer(item: WslServerItem) { + const { config, runtime } = item + if (runtime.kind === 'ready') { + serverStore.upsertServer({ + id: config.id, + name: config.distro, + url: runtime.url, + auth: + runtime.username && runtime.password + ? { username: runtime.username, password: runtime.password } + : undefined, + }) + // 就绪的 WSL 服务器自动进入多服务器侧边栏(setSubscribed 幂等,重复调用无副作用) + multiServerStore.setSubscribed(config.id, true) + + // 恢复判定:命中启动目标或「死亡前是 active」记忆 → 切回(覆盖安装 opencode 后 + // sidecar 自动重启等中途死亡-复活场景)。必须 upsert 之后判定,active 状态才反映最新事实 + this._applyRestore({ kind: 'ready', serverId: config.id }) + return + } + // 非 ready(starting/failed/stopped):先采样 isActive 并记入恢复状态机—— + // removeServer 会把 active 优雅降级(见 serverStore.removeServer),之后采样就失真了; + // 然后再把未就绪的 WSL 服务器从连接列表里移除(幂等) + this._applyRestore({ kind: 'unready', serverId: config.id }) + serverStore.removeServer(config.id) + } + + /** 应用恢复状态机:采样 isActive → 演进状态 → 需要时把 active 切回 */ + private _applyRestore(event: { kind: 'ready' | 'unready'; serverId: string }) { + const outcome = reduceWslRestore(this._restoreState, { + ...event, + isActive: serverStore.getActiveServerId() === event.serverId, + }) + this._restoreState = outcome.state + if (outcome.shouldRestore) { + serverStore.setActiveServer(event.serverId) + } + } +} + +export const wslStore = new WslStore() + +/** React 绑定:组件里用 useWslStore() 拿整个状态树 */ +export function useWslStore(): WslServersState | null { + const snapshot = useSyncExternalStore(wslStore.subscribe, wslStore.getSnapshot) + return snapshot.state +} From b5a2166542e0519a62f726b07397507e2e187069 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Sun, 30 Aug 2026 16:27:09 +0800 Subject: [PATCH 4/9] fix(wsl): sidebar group and SSE follow WSL server registered after boot - upsertServer broadcasts server-runtime-updated on any endpoint change (first registration included), decoupled from the active server - SidePanel subscribed filter tracks the serverStore snapshot so the WSL group appears the moment the server registers, no manual re-subscribe - per-server SSE rebuilds the single affected connection on runtime change: the boot-window fallback connection (WSL not yet registered) stays healthy on the wrong address and never self-heals via auto-reconnect --- src/features/chat/sidebar/SidePanel.tsx | 11 +++++--- src/hooks/useGlobalEvents.test.tsx | 37 +++++++++++++++++-------- src/hooks/useGlobalEvents.ts | 12 ++++++++ src/store/serverStore.test.ts | 20 +++++++++++-- src/store/serverStore.ts | 21 +++++++------- 5 files changed, 73 insertions(+), 28 deletions(-) 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/hooks/useGlobalEvents.test.tsx b/src/hooks/useGlobalEvents.test.tsx index f6770c2a..1494acc0 100644 --- a/src/hooks/useGlobalEvents.test.tsx +++ b/src/hooks/useGlobalEvents.test.tsx @@ -28,10 +28,15 @@ const { getActiveServerIdMock, checkHealthMock, onServerChangeMock, + serverChangeListeners, autoApproveStoreMock, clearSessionRuntimeStateMock, clearPaneSessionMock, -} = vi.hoisted(() => ({ +} = vi.hoisted(() => { + // onServerChange 是多播语义(effect 里注册多个监听器),mock 必须保存全部监听器; + // 覆盖式单槽实现会静默丢掉先注册的监听器,让「只有最后注册者生效」的隐式依赖漏过测试 + const serverChangeListeners = new Set<(serverId: string, reason?: string) => void>() + return { subscribeToEventsMock: vi.fn(), getSessionStatusMock: vi.fn<(directory?: string) => Promise>>(() => Promise.resolve({})), getPendingPermissionsMock: vi.fn(() => @@ -48,7 +53,13 @@ const { applyServerConnectedTimestampMock: vi.fn(), getActiveServerIdMock: vi.fn(() => 'local'), checkHealthMock: vi.fn(() => Promise.resolve({ status: 'online' })), - onServerChangeMock: vi.fn((_listener: (serverId: string) => void) => vi.fn()), + onServerChangeMock: vi.fn((listener: (serverId: string, reason?: string) => void) => { + serverChangeListeners.add(listener) + return () => { + serverChangeListeners.delete(listener) + } + }), + serverChangeListeners, clearSessionRuntimeStateMock: vi.fn(), clearPaneSessionMock: vi.fn(), getSoundSnapshotMock: vi.fn(() => ({ @@ -75,7 +86,8 @@ const { claimAutoReply: vi.fn((_requestId: string) => true), releaseAutoReply: vi.fn((_requestId: string) => undefined), }, -})) + } +}) vi.mock('../api', () => ({ subscribeToEvents: subscribeToEventsMock, @@ -192,7 +204,13 @@ describe('useGlobalEvents', () => { isSystemEnabledMock.mockImplementation((type: string) => type !== 'permission') getActiveServerIdMock.mockReturnValue('local') checkHealthMock.mockResolvedValue({ status: 'online' }) - onServerChangeMock.mockReturnValue(vi.fn()) + serverChangeListeners.clear() + onServerChangeMock.mockImplementation(listener => { + serverChangeListeners.add(listener) + return () => { + serverChangeListeners.delete(listener) + } + }) getSessionAndDescendantsMock.mockImplementation((sessionId: string) => [sessionId]) autoApproveStoreMock.subscribe.mockReturnValue(vi.fn()) autoApproveStoreMock.getPaneFullAutoMode.mockReturnValue('off') @@ -224,18 +242,13 @@ describe('useGlobalEvents', () => { }) it('refreshes health for the selected server when active server changes', async () => { - let onServerChange: ((serverId: string) => void) | undefined - onServerChangeMock.mockImplementation(listener => { - onServerChange = listener - return vi.fn() - }) - renderHook(() => useGlobalEvents()) - await waitFor(() => expect(onServerChange).toBeDefined()) + await waitFor(() => expect(onServerChangeMock).toHaveBeenCalled()) checkHealthMock.mockClear() - onServerChange!('remote') + // 多播广播:effect 里注册的每个监听器(健康检查、runtime 重连等)都会收到信号 + serverChangeListeners.forEach(listener => listener('remote')) expect(checkHealthMock).toHaveBeenCalledWith('remote') }) diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index 60fd36ac..24be4e5a 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -765,9 +765,20 @@ export function useGlobalEvents(directories?: string[]) { } } + // 索引与 activeServerIds 一一对应,端点变化时按索引定向替换单条订阅 const unsubscribes = activeServerIds.map(serverId => subscribeToServerEvents(serverId, buildServerCallbacks(serverId)), ) + // WSL 服务器端点变化(sidecar 重启换端口 / 启动窗口期后注册就绪):在订阅集合内 + // 时定向拆旧建新。不能指望自动重连——启动窗口期 wsl 服务器尚未注册进 serverStore, + // SSE 会回退连到 local 地址且这条连接一直「健康」,不断线就永远不会自愈 + const offRuntimeChange = serverStore.onServerChange((changedId, reason) => { + if (reason !== 'server-runtime-updated') return + const index = activeServerIdsRef.current.indexOf(changedId) + if (index === -1) return + unsubscribes[index]?.() + unsubscribes[index] = subscribeToServerEvents(changedId, buildServerCallbacks(changedId)) + }) activeServerIds.forEach(serverId => { fetchAndInitialize(serverId) refreshServerHealth(serverId) @@ -777,6 +788,7 @@ export function useGlobalEvents(directories?: string[]) { return () => { disposed = true refreshRef.current = null + offRuntimeChange() unsubscribes.forEach(unsubscribe => unsubscribe()) unsubscribeAutoApprove() unsubscribeServerChange() diff --git a/src/store/serverStore.test.ts b/src/store/serverStore.test.ts index 32649389..e8315d7e 100644 --- a/src/store/serverStore.test.ts +++ b/src/store/serverStore.test.ts @@ -236,7 +236,9 @@ describe('serverStore upsertServer runtime change', () => { expect(listener).not.toHaveBeenCalled() }) - it('does not notify when a non-active server url changes', async () => { + it('notifies server-runtime-updated when a non-active server url changes', async () => { + // 信号语义是「端点事实变化」而非「active 在用谁」:非 active 的 WSL 服务器 + // sidecar 换端口后,侧边栏会话事件流(SSE)同样挂在其端点上,必须重连 const { serverStore } = await import('./serverStore') const remote = serverStore.addServer({ name: 'Remote', url: 'http://127.0.0.1:11111' }) @@ -245,7 +247,21 @@ describe('serverStore upsertServer runtime change', () => { serverStore.upsertServer({ id: remote.id, name: 'Remote', url: 'http://127.0.0.1:22222' }) - expect(listener).not.toHaveBeenCalled() + expect(listener).toHaveBeenCalledWith(remote.id, 'server-runtime-updated') + }) + + it('notifies server-runtime-updated when a new server is registered', async () => { + // WSL 就绪注册:端点事实从无到有,消费方(SSE/SDK)需按新端点重建, + // 否则启动窗口期建立的回退连接永远指向错误地址 + const { serverStore } = await import('./serverStore') + + const listener = vi.fn() + serverStore.onServerChange(listener) + + serverStore.upsertServer({ id: 'wsl:Ubuntu-22.04', name: 'Ubuntu', url: 'http://127.0.0.1:33333' }) + + expect(listener).toHaveBeenCalledWith('wsl:Ubuntu-22.04', 'server-runtime-updated') + expect(serverStore.getServer('wsl:Ubuntu-22.04')?.url).toBe('http://127.0.0.1:33333') }) }) diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index b2fe9c06..f57fe100 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -482,21 +482,22 @@ class ServerStore { url: config.url.replace(/\/+$/, ''), // 移除尾部斜杠 } const index = this.servers.findIndex(s => s.id === server.id) + // 新增与更新共用同一判定:端点/凭据是否变化。新增时 previous 为空, + // 「可用端点从无到有」对消费方与变更等价(WSL 就绪注册是典型场景) + const previous = index === -1 ? undefined : this.servers[index] + const runtimeChanged = + !previous || previous.url !== server.url || !isSameServerAuth(previous.auth, server.auth) if (index === -1) { this.servers.push(server) - this.saveToStorage() - this.notify() - return server + } else { + this.servers[index] = server } - - // 同 id 更新:WSL sidecar 每次启动端口/密码都变。若连接参数变化且是当前 active, - // 活动中的会话(SSE 等)还挂在旧地址上,必须在 notify 之后通知消费方重连 - const previous = this.servers[index] - const runtimeChanged = previous.url !== server.url || !isSameServerAuth(previous.auth, server.auth) - this.servers[index] = server this.saveToStorage() this.notify() - if (runtimeChanged && server.id === this.activeServerId) { + // 端点变化无条件广播(不限定 active):SSE/SDK 消费方建连时现读 serverStore, + // 收到信号按 serverId 定向重建即拿到新值。active 与否只决定「谁正在用」, + // 不改变「端点事实已变化」本身——非 active 服务器换端口同样需要这条信号 + if (runtimeChanged) { this.notifyServerChange(server.id, 'server-runtime-updated') } return server From 68afa5b3504084383c06dc337ed490759201e043 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Tue, 1 Sep 2026 22:56:01 +0800 Subject: [PATCH 5/9] =?UTF-8?q?perf(wsl):=20=E6=8C=89=E9=9C=80=E9=A2=84?= =?UTF-8?q?=E7=83=AD=E2=80=94=E2=80=94=E5=90=AF=E5=8A=A8=E9=9B=B6=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E3=80=81=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=84=8F=E5=9B=BE?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E3=80=81=E5=9C=A8=E7=BA=BF=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=20TTL=20=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - initialize_wsl 瘦身为仅恢复配置+拉起已有服务器,删启动全量 opencode 检查与预热块 - 新增 prewarm_wsl 命令,设置页打开时按需补齐 runtime/发行版列表/opencode 检查 - 在线目录 stale-while-revalidate 缓存(wsl-online-cache.json, TTL 24h):联网目录持久化,过期先展示后后台刷新 - 对话框「重新检测」按钮走 force=true 绕过缓存,autoProbePlan 走缓存 - 未添加过 WSL 服务器的机器启动路径零开销 --- src-tauri/src/app/commands/wsl_commands.rs | 344 ++++++++++++++---- src-tauri/src/app/commands/wsl_types.rs | 2 +- src-tauri/src/app/mod.rs | 7 +- src/api/wsl.ts | 9 +- .../settings/components/ServersSettings.tsx | 10 + .../wsl/components/DialogAddWslServer.tsx | 3 +- 6 files changed, 289 insertions(+), 86 deletions(-) diff --git a/src-tauri/src/app/commands/wsl_commands.rs b/src-tauri/src/app/commands/wsl_commands.rs index 1a1ce8f8..0b70add2 100644 --- a/src-tauri/src/app/commands/wsl_commands.rs +++ b/src-tauri/src/app/commands/wsl_commands.rs @@ -70,6 +70,70 @@ fn normalize_persisted_server(value: WslServerConfig) -> Option 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 核对 map 里的句柄是否仍是自己,不是则保持静默。 @@ -177,6 +241,12 @@ impl WslState { } } + /// 只读快照访问:锁中毒按默认值(空状态)处理——调用方均为 + /// 尽力而为的判定(如 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) @@ -314,6 +384,12 @@ async fn probe_addable_distros(state: &WslState, distros: &[String], token: &Can /// 探测 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(), }); @@ -335,36 +411,121 @@ pub async fn probe_wsl_runtime(state: tauri::State<'_, WslState>) -> Result<(), } /// 刷新发行版列表(官方 refreshDistros)。 -/// 同时清掉 can_execute=false 的探测记录(checkAgain 语义): -/// 探测失败可能是 WSL 冷启动等瞬时问题,重试应重新探测而不是永远卡在 -/// "打开一次完成设置"的提示上。 +/// force=true(用户显式「重新检测」)绕过在线目录缓存强制联网; +/// 默认走 stale-while-revalidate,详见 refresh_distros_inner #[command] -pub async fn refresh_wsl_distros(state: tauri::State<'_, WslState>) -> Result<(), String> { +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 = tokio::join!( - wsl_runtime::list_installed_distros(Some(&token)), - wsl_runtime::list_online_distros(Some(&token)) - ); - match result { - (Ok(installed), Ok(online)) => { - state.set_state(|s| { - s.installed = installed; - s.online = online; - // checkAgain 重新探测失败过的 distro - s.distro_probes.retain(|_, p| p.can_execute); - }); - state.end_job(&token); + 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(()) } - (Err(e), _) | (_, Err(e)) => { - state.end_job(&token); - Err(e) + 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) { + 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); + } + }); +} + +/// 设置→服务器页打开时的按需预热(意图信号驱动,取代旧的无条件启动预热): +/// - 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 挂钩自行刷新) + let missing: Vec<(String, String)> = state.read_state(|s| { + s.servers + .iter() + .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> { @@ -1037,70 +1198,93 @@ pub async fn stop_all_wsl_servers(state: &tauri::State<'_, WslState>) { } } -/// 应用启动初始化(官方 initialize:恢复配置 + 刷新 opencode 检查 + 全部拉起) +/// 应用启动初始化:只做「恢复用户上次状态」的最小工作——读持久化配置并 +/// 拉起全部已添加服务器(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(); - let ids = state.persisted_server_ids(); - // 对全部已有服务器后台刷新 opencode 检查(官方 refreshOpencodeChecks) - { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - let distros: Vec = { - let s = match state.state.lock() { - Ok(s) => s, - Err(_) => return, - }; - s.servers.iter().map(|item| item.config.distro.clone()).collect() - }; - let checks = futures_util::future::join_all(distros.iter().map(|d| check_opencode(d, None))).await; - let valid_checks: Vec<(String, WslOpencodeCheck)> = checks - .into_iter() - .filter_map(|r| r.ok()) - .filter(|check| { - state - .state - .lock() - .map(|s| s.servers.iter().any(|item| item.config.distro == check.distro)) - .unwrap_or(false) - }) - .map(|check| (check.distro.clone(), check)) - .collect(); - if !valid_checks.is_empty() { - state.set_state(|s| { - for (distro, check) in valid_checks { - s.opencode_checks.insert(distro, check); - } - }); - } - }); + for id in state.persisted_server_ids() { + start_server_internal(app, &id); } - // 启动预热(官方 initialize 没做这一步,但官方靠"启动即拉起服务器"焐热 WSL; - // 无服务器的机器首开添加对话框会暴露完整的冷启动空窗):后台探测 runtime, - // 可用时拉取发行版列表,用户打开对话框时即是热数据。 - // 与对话框自身的 autoProbePlan 撞车时由 begin_job 的取消语义兜底 - { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - if probe_wsl_runtime(app.state::()).await.is_ok() { - let available = { - let state = app.state::(); - state - .state - .lock() - .ok() - .and_then(|s| s.runtime.as_ref().map(|r| r.available)) - .unwrap_or(false) - }; - if available { - let _ = refresh_wsl_distros(app.state::()).await; - } - } - }); +} + +// ============================================ +// 单元测试 —— 在线目录缓存的纯决策逻辑 +// ============================================ + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + + fn online(name: &str) -> WslOnlineDistro { + WslOnlineDistro { + name: name.to_string(), + label: format!("{name} label"), + } } - // 全部服务器自动拉起(官方 wslServerIdsToStartOnInitialize = 全部) - for id in ids { - start_server_internal(app, &id); + + 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"); } } diff --git a/src-tauri/src/app/commands/wsl_types.rs b/src-tauri/src/app/commands/wsl_types.rs index 40e8ed16..74d40249 100644 --- a/src-tauri/src/app/commands/wsl_types.rs +++ b/src-tauri/src/app/commands/wsl_types.rs @@ -25,7 +25,7 @@ pub struct WslInstalledDistro { } /// 在线可用的发行版 -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WslOnlineDistro { pub name: String, pub label: String, diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 02ab59aa..e2a1101f 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -369,8 +369,9 @@ pub fn run() { #[cfg(target_os = "windows")] app.manage(commands::wsl_commands::WslState::new(app.handle().clone())); - // Windows: 官方 initialize 对齐——恢复持久化配置 + 后台刷新 opencode - // 检查 + 自动拉起全部服务器(wslServerIdsToStartOnInitialize 返回全部 id) + // Windows: 启动只做「恢复上次状态」——恢复持久化配置并拉起全部已添加 + // 服务器;WSL 探测(runtime/发行版/opencode 检查)为按需成本,由设置页 + // 打开时前端调用 prewarm_wsl 触发(未添加过 WSL 服务器的机器启动零开销) #[cfg(target_os = "windows")] commands::wsl_commands::initialize_wsl(app.handle()); @@ -487,6 +488,8 @@ pub fn run() { #[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, diff --git a/src/api/wsl.ts b/src/api/wsl.ts index bbdb89bb..bd968f3e 100644 --- a/src/api/wsl.ts +++ b/src/api/wsl.ts @@ -12,8 +12,13 @@ export const wslApi = { /** 探测 WSL 运行时 */ probeRuntime: () => invoke('probe_wsl_runtime'), - /** 刷新发行版列表(本地 + 在线) */ - refreshDistros: () => invoke('refresh_wsl_distros'), + /** 刷新发行版列表(本地 + 在线)。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 }), diff --git a/src/features/settings/components/ServersSettings.tsx b/src/features/settings/components/ServersSettings.tsx index 32424e6b..eeb024ac 100644 --- a/src/features/settings/components/ServersSettings.tsx +++ b/src/features/settings/components/ServersSettings.tsx @@ -13,6 +13,7 @@ import { settingsFieldClass, SettingsSection, SettingRow, Toggle } from './Setti 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' @@ -533,6 +534,15 @@ export function ServersSettings() { checkAllHealth() }, [checkAllHealth]) + // WSL 按需预热(意图信号):启动路径不做任何 WSL 探测,用户走到服务器 + // 设置页才补齐 runtime / 发行版列表 / 已添加服务器的 opencode 检查。 + // 后端命令幂等,状态已就位时零成本;失败静默(添加对话框自身的探测 + // 状态机兜底),不打扰页面 + useEffect(() => { + if (!isWindowsDesktop) return + void wslApi.prewarm().catch(err => console.error('WSL prewarm failed:', err)) + }, [isWindowsDesktop]) + // M3: 下拉菜单外部点击关闭(trigger 与 portal 菜单内容都算「里面」) useEffect(() => { if (!addMenuOpen) return diff --git a/src/features/wsl/components/DialogAddWslServer.tsx b/src/features/wsl/components/DialogAddWslServer.tsx index fafa6bed..13d6b079 100644 --- a/src/features/wsl/components/DialogAddWslServer.tsx +++ b/src/features/wsl/components/DialogAddWslServer.tsx @@ -119,7 +119,8 @@ export function DialogAddWslServer({ isOpen, onClose, onAdded }: DialogWslServer const refreshDistros = () => { gateRef.current.reset() - run(() => wslApi.refreshDistros()) + // 用户显式「重新检测」:绕过在线目录缓存强制联网刷新 + run(() => wslApi.refreshDistros(true)) } const installDistro = (name: string) => { From 59885c1991bd4e251c36547de45a25b53310524e Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Tue, 1 Sep 2026 22:56:14 +0800 Subject: [PATCH 6/9] =?UTF-8?q?perf(sessions):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=BA=A7=E8=81=94=E2=80=94=E2=80=94=E6=9C=AA?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E3=80=81upsert=20=E7=9F=AD=E8=B7=AF=E3=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E9=9B=86=E5=90=88=E5=A2=9E=E9=87=8F=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collectActiveServerIds 过滤未注册服务器,消灭 WSL 就绪前请求回退 local 的数据串服 - 注册事件订阅 serverStore,WSL 就绪时自动入集触发接入 - upsertServer 无变更短路,wsl-state 全量推送不再触发 localStorage 写入与全体重渲染 - 订阅集合改增量 diff(Map 按 serverId),集合变化不再全量拆建 SSE 与全量重拉 - useModels 按 reason 门控,非 active 服务器端点变化不再触发模型重拉 - 补增量接入与定向重建订阅的契约测试 --- src/hooks/useGlobalEvents.test.tsx | 101 ++++++++++++++++++++++++++++- src/hooks/useGlobalEvents.ts | 73 +++++++++++++++------ src/hooks/useModels.ts | 9 ++- src/store/serverStore.test.ts | 16 +++++ src/store/serverStore.ts | 17 +++-- 5 files changed, 189 insertions(+), 27 deletions(-) diff --git a/src/hooks/useGlobalEvents.test.tsx b/src/hooks/useGlobalEvents.test.tsx index 1494acc0..7b2bbd00 100644 --- a/src/hooks/useGlobalEvents.test.tsx +++ b/src/hooks/useGlobalEvents.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { registerSessionConsumer, useGlobalEvents } from './useGlobalEvents' @@ -29,6 +29,10 @@ const { checkHealthMock, onServerChangeMock, serverChangeListeners, + registeredServerIds, + serverStoreListeners, + multiServerMock, + multiServerListeners, autoApproveStoreMock, clearSessionRuntimeStateMock, clearPaneSessionMock, @@ -36,6 +40,12 @@ const { // onServerChange 是多播语义(effect 里注册多个监听器),mock 必须保存全部监听器; // 覆盖式单槽实现会静默丢掉先注册的监听器,让「只有最后注册者生效」的隐式依赖漏过测试 const serverChangeListeners = new Set<(serverId: string, reason?: string) => void>() + // serverStore.getServer 的注册表:collectActiveServerIds 过滤未注册 id 的判定源 + const registeredServerIds = new Set(['local']) + const serverStoreListeners = new Set<() => void>() + // 多服务器订阅白名单:可控开关 + 白名单集合,模拟 WSL 就绪注册时序 + const multiServerMock = { enabled: false, subscribedIds: [] as string[] } + const multiServerListeners = new Set<() => void>() return { subscribeToEventsMock: vi.fn(), getSessionStatusMock: vi.fn<(directory?: string) => Promise>>(() => Promise.resolve({})), @@ -60,6 +70,10 @@ const { } }), serverChangeListeners, + registeredServerIds, + serverStoreListeners, + multiServerMock, + multiServerListeners, clearSessionRuntimeStateMock: vi.fn(), clearPaneSessionMock: vi.fn(), getSoundSnapshotMock: vi.fn(() => ({ @@ -91,12 +105,26 @@ const { vi.mock('../api', () => ({ subscribeToEvents: subscribeToEventsMock, - subscribeToServerEvents: (_serverId: string, cb: unknown) => subscribeToEventsMock(cb), + // 透传 serverId 作为第二参数:测试需要区分「哪个服务器建了订阅」 + subscribeToServerEvents: (serverId: string, cb: unknown) => subscribeToEventsMock(cb, serverId), getSessionStatus: getSessionStatusMock, getPendingPermissions: getPendingPermissionsMock, getPendingQuestions: getPendingQuestionsMock, })) +vi.mock('../store/multiServerStore', () => ({ + multiServerStore: { + isEnabled: () => multiServerMock.enabled, + getSubscribedServerIds: () => multiServerMock.subscribedIds, + subscribe: (listener: () => void) => { + multiServerListeners.add(listener) + return () => { + multiServerListeners.delete(listener) + } + }, + }, +})) + vi.mock('../api/permission', () => ({ replyPermission: replyPermissionMock, })) @@ -130,6 +158,14 @@ vi.mock('../store', () => ({ getActiveServerId: getActiveServerIdMock, checkHealth: checkHealthMock, onServerChange: onServerChangeMock, + // collectActiveServerIds 会过滤未注册服务器:mock 只认 local 与显式注册的 id + getServer: (id: string) => (registeredServerIds.has(id) ? { id } : null), + subscribe: (listener: () => void) => { + serverStoreListeners.add(listener) + return () => { + serverStoreListeners.delete(listener) + } + }, }, })) @@ -205,6 +241,12 @@ describe('useGlobalEvents', () => { getActiveServerIdMock.mockReturnValue('local') checkHealthMock.mockResolvedValue({ status: 'online' }) serverChangeListeners.clear() + registeredServerIds.clear() + registeredServerIds.add('local') + serverStoreListeners.clear() + multiServerMock.enabled = false + multiServerMock.subscribedIds = [] + multiServerListeners.clear() onServerChangeMock.mockImplementation(listener => { serverChangeListeners.add(listener) return () => { @@ -777,4 +819,59 @@ describe('useGlobalEvents', () => { expect(playNotificationSoundDedupedMock).not.toHaveBeenCalled() }, ) + + it('does not subscribe unregistered whitelist servers and attaches them incrementally on registration', async () => { + // 启动竞态:WSL 白名单 id 在 sidecar 就绪前就存在于持久化配置。 + // 未注册时不得订阅(getServerBaseUrl 会回退 local → 数据串服); + // 注册进 serverStore 后由 notify 触发增量接入,且不重连已有服务器 + multiServerMock.enabled = true + multiServerMock.subscribedIds = ['local', 'wsl:Ubuntu'] + const unsubscribes: Record void>> = { local: [], 'wsl:Ubuntu': [] } + subscribeToEventsMock.mockImplementation((_cb, serverId: string) => { + const off = vi.fn() + unsubscribes[serverId].push(off) + return off + }) + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(subscribeToEventsMock).toHaveBeenCalledTimes(1)) + expect(subscribeToEventsMock.mock.calls[0][1]).toBe('local') + + // WSL 就绪注册:入注册表 + notify → 集合重算 → 只新增 wsl 订阅,local 连接不动 + registeredServerIds.add('wsl:Ubuntu') + act(() => { + multiServerListeners.forEach(listener => listener()) + serverStoreListeners.forEach(listener => listener()) + }) + + await waitFor(() => expect(subscribeToEventsMock).toHaveBeenCalledTimes(2)) + expect(subscribeToEventsMock.mock.calls[1][1]).toBe('wsl:Ubuntu') + expect(unsubscribes.local[0]).not.toHaveBeenCalled() + }) + + it('rebuilds only the changed server subscription on server-runtime-updated', async () => { + // sidecar 重启换端口:定向拆旧建新,其余服务器的 SSE 不受牵连 + multiServerMock.enabled = true + multiServerMock.subscribedIds = ['local', 'wsl:Ubuntu'] + registeredServerIds.add('wsl:Ubuntu') + const unsubscribes: Record void>> = { local: [], 'wsl:Ubuntu': [] } + subscribeToEventsMock.mockImplementation((_cb, serverId: string) => { + const off = vi.fn() + unsubscribes[serverId].push(off) + return off + }) + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(unsubscribes['wsl:Ubuntu']).toHaveLength(1)) + checkHealthMock.mockClear() + + serverChangeListeners.forEach(listener => listener('wsl:Ubuntu', 'server-runtime-updated')) + + await waitFor(() => expect(unsubscribes['wsl:Ubuntu']).toHaveLength(2)) + expect(unsubscribes['wsl:Ubuntu'][0]).toHaveBeenCalledTimes(1) + expect(unsubscribes.local).toHaveLength(1) + expect(unsubscribes.local[0]).not.toHaveBeenCalled() + }) }) diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index 24be4e5a..ac1e0454 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -301,7 +301,10 @@ function collectActiveServerIds(): string[] { } ids.add(serverStore.getActiveServerId()) } - return Array.from(ids) + // 过滤未注册服务器:WSL 白名单 id 在 sidecar 就绪前就存在于持久化配置中, + // 而 getServerBaseUrl 对未知 id 静默回退 local——不过滤就会拿 local 的数据 + // 写到 wsl: 键下(数据串服)。服务器注册进 serverStore 时 notify 触发重算,自然入集 + return Array.from(ids).filter(id => serverStore.getServer(id) !== null) } export function useGlobalEvents(directories?: string[]) { @@ -322,6 +325,9 @@ export function useGlobalEvents(directories?: string[]) { }, []) const activeServerIdsRef = useRef(activeServerIds) + // 订阅集合增量同步入口:主 effect 挂载时写入,集合变化 effect 调用 + const serverSyncRef = useRef<(() => void) | null>(null) + useEffect(() => { activeServerIdsRef.current = activeServerIds }, [activeServerIds]) @@ -334,9 +340,15 @@ export function useGlobalEvents(directories?: string[]) { const unsubscribeMulti = multiServerStore.subscribe(() => { updateActiveServerIds() }) + // 服务器注册/注销(WSL sidecar 就绪注册进 serverStore)也要重算: + // collectActiveServerIds 现在会过滤未注册 id,就绪事件是它们入集的唯一信号 + const unsubscribeServerStore = serverStore.subscribe(() => { + updateActiveServerIds() + }) return () => { unsubscribeLayout() unsubscribeMulti() + unsubscribeServerStore() } }, [updateActiveServerIds]) @@ -765,35 +777,58 @@ export function useGlobalEvents(directories?: string[]) { } } - // 索引与 activeServerIds 一一对应,端点变化时按索引定向替换单条订阅 - const unsubscribes = activeServerIds.map(serverId => - subscribeToServerEvents(serverId, buildServerCallbacks(serverId)), - ) - // WSL 服务器端点变化(sidecar 重启换端口 / 启动窗口期后注册就绪):在订阅集合内 - // 时定向拆旧建新。不能指望自动重连——启动窗口期 wsl 服务器尚未注册进 serverStore, - // SSE 会回退连到 local 地址且这条连接一直「健康」,不断线就永远不会自愈 + // 订阅集合增量管理(Map 按 serverId 索引):服务器加入 → 只连新的 + // (建订阅 + 初始拉取 + 健康检查),移出 → 只拆旧的。集合变化不再 + // 触发全量 SSE 拆建与全量重拉(WSL 就绪注册、active 切换是典型场景) + const subscriptions = new Map void>() + const syncSubscriptions = () => { + const desired = activeServerIdsRef.current + let attached = false + for (const [serverId, unsubscribe] of subscriptions) { + if (!desired.includes(serverId)) { + unsubscribe() + subscriptions.delete(serverId) + } + } + for (const serverId of desired) { + if (subscriptions.has(serverId)) continue + subscriptions.set(serverId, subscribeToServerEvents(serverId, buildServerCallbacks(serverId))) + fetchAndInitialize(serverId) + refreshServerHealth(serverId) + attached = true + } + // 有新服务器接入时补一次全局自动审批(该服务器上的 pending 权限也要被处理) + if (attached) approveGlobalPendingPermissions() + } + serverSyncRef.current = syncSubscriptions + syncSubscriptions() + + // WSL 服务器端点变化(sidecar 重启换端口):对已在订阅集合内的定向拆旧建新。 + // 不能指望自动重连——旧连接一直「健康」地连着死地址,不断线就永远不会自愈。 + // 不在集合内的无需处理:注册事件本身会触发集合重算 → 建订阅时 URL 现读 serverStore const offRuntimeChange = serverStore.onServerChange((changedId, reason) => { if (reason !== 'server-runtime-updated') return - const index = activeServerIdsRef.current.indexOf(changedId) - if (index === -1) return - unsubscribes[index]?.() - unsubscribes[index] = subscribeToServerEvents(changedId, buildServerCallbacks(changedId)) - }) - activeServerIds.forEach(serverId => { - fetchAndInitialize(serverId) - refreshServerHealth(serverId) + const unsubscribe = subscriptions.get(changedId) + if (!unsubscribe) return + subscriptions.set(changedId, subscribeToServerEvents(changedId, buildServerCallbacks(changedId))) + unsubscribe() }) - approveGlobalPendingPermissions() return () => { disposed = true refreshRef.current = null + serverSyncRef.current = null offRuntimeChange() - unsubscribes.forEach(unsubscribe => unsubscribe()) + subscriptions.forEach(unsubscribe => unsubscribe()) unsubscribeAutoApprove() unsubscribeServerChange() } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 活跃服务器集合变化 → 增量同步订阅(ref 由前面的 effect 更新, + // syncSubscriptions 由主 effect 挂载;主 effect 本身只在 mount 跑一次) + useEffect(() => { + serverSyncRef.current?.() }, [activeServerIds]) useLayoutEffect(() => { diff --git a/src/hooks/useModels.ts b/src/hooks/useModels.ts index 78ef7f2e..58bd490e 100644 --- a/src/hooks/useModels.ts +++ b/src/hooks/useModels.ts @@ -77,8 +77,13 @@ export function refreshModels(serverId?: string) { // 预取活动服务器的模型 — 组件挂载前模型就已就绪 _fetchModels(serverStore.getActiveServerId()) -serverStore.onServerChange(newServerId => { - void refreshModels(newServerId) +serverStore.onServerChange((serverId, reason) => { + // 只为「UI 正在消费的服务器」刷新模型: + // - server-switch:active 变了 → 需要新服务器的模型列表 + // - server-runtime-updated:仅当变的是 active 自身(WSL 换端口重启)才值得重拉; + // 非 active 服务器换端点与模型栏无关,切过去时 server-switch 会拉到 + if (reason === 'server-runtime-updated' && serverStore.getActiveServerId() !== serverId) return + void refreshModels(serverId) }) function _subscribe(listener: Listener) { diff --git a/src/store/serverStore.test.ts b/src/store/serverStore.test.ts index e8315d7e..dfba5169 100644 --- a/src/store/serverStore.test.ts +++ b/src/store/serverStore.test.ts @@ -263,6 +263,22 @@ describe('serverStore upsertServer runtime change', () => { expect(listener).toHaveBeenCalledWith('wsl:Ubuntu-22.04', 'server-runtime-updated') expect(serverStore.getServer('wsl:Ubuntu-22.04')?.url).toBe('http://127.0.0.1:33333') }) + + it('short-circuits a no-change upsert without notifying or rewriting storage', async () => { + // wsl-state 每次全量推送都会重同步全部服务器:无变更必须零副作用, + // 否则每次推送都触发 localStorage 写入 + 全体 React 订阅方重渲染 + const { serverStore } = await import('./serverStore') + serverStore.upsertServer({ id: 'wsl:Ubuntu', name: 'Ubuntu', url: 'http://127.0.0.1:33333' }) + + const listener = vi.fn() + serverStore.subscribe(listener) + const snapshotBefore = serverStore.getServers() + + serverStore.upsertServer({ id: 'wsl:Ubuntu', name: 'Ubuntu', url: 'http://127.0.0.1:33333' }) + + expect(listener).not.toHaveBeenCalled() + expect(serverStore.getServers()).toBe(snapshotBefore) + }) }) describe('serverStore default server preference', () => { diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index f57fe100..7876ba06 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -482,11 +482,20 @@ class ServerStore { url: config.url.replace(/\/+$/, ''), // 移除尾部斜杠 } const index = this.servers.findIndex(s => s.id === server.id) - // 新增与更新共用同一判定:端点/凭据是否变化。新增时 previous 为空, - // 「可用端点从无到有」对消费方与变更等价(WSL 就绪注册是典型场景) const previous = index === -1 ? undefined : this.servers[index] - const runtimeChanged = - !previous || previous.url !== server.url || !isSameServerAuth(previous.auth, server.auth) + // 无变更 → 零副作用短路:wsl-state 每次全量推送都会重同步全部服务器, + // 不短路则每次推送都触发 localStorage 写入 + 全体 React 订阅方重渲染 + if ( + previous && + previous.name === server.name && + previous.url === server.url && + isSameServerAuth(previous.auth, server.auth) + ) { + return previous + } + // 端点/凭据是否变化。新增时 previous 为空,「可用端点从无到有」 + // 对消费方与变更等价(WSL 就绪注册是典型场景) + const runtimeChanged = !previous || previous.url !== server.url || !isSameServerAuth(previous.auth, server.auth) if (index === -1) { this.servers.push(server) } else { From 3e7110ee901f7d77c9d1dcf4ff0ac87eaa907ca8 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Tue, 1 Sep 2026 22:56:26 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(sidebar):=20=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=8A=A0=E8=BD=BD=E6=80=81=E4=B8=8E=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=80=81=E2=80=94=E2=80=94=E9=87=8D=E8=AF=95=E9=97=B4=E9=9A=99?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=97=AA=E7=8E=B0=E7=A9=BA=E6=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSessions 重试改为显式循环,loading 生命周期=循环生命周期,重试等待期不落地 - 重试耗尽才落 error 终态,空态文案不再伪装成「没有对话」的加载失败 - FolderRecentList 空态前增加 error 分支,加载失败显示可点击重试 - 新增 locale 键 sidebar.loadFailed - 补重试期保持 loading 与耗尽落 error 的契约测试 --- .../chat/sidebar/FolderRecentList.tsx | 28 +++++- src/hooks/useSessions.test.tsx | 77 +++++++++++++++ src/hooks/useSessions.ts | 98 +++++++++++-------- src/locales/en/chat.json | 1 + src/locales/zh-CN/chat.json | 1 + 5 files changed, 159 insertions(+), 46 deletions(-) diff --git a/src/features/chat/sidebar/FolderRecentList.tsx b/src/features/chat/sidebar/FolderRecentList.tsx index 9bbb7af1..babe65f6 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) => { diff --git a/src/hooks/useSessions.test.tsx b/src/hooks/useSessions.test.tsx index 1be061d9..4ae54761 100644 --- a/src/hooks/useSessions.test.tsx +++ b/src/hooks/useSessions.test.tsx @@ -266,4 +266,81 @@ describe('useSessions', () => { expect(result.current.sessions.map(session => session.id)).toEqual(['fresh']) }) + + it('keeps loading during retry backoff and lands error only at terminal failure', async () => { + getSessionsMock.mockRejectedValue(new Error('service not ready')) + + const { result } = renderHook(() => useSessions({ directory: '/workspace/demo' })) + + // 首次失败:进入重试等待期,必须仍是 loading(空列表 + 非 loading 会闪现空态文案) + await act(async () => { + vi.runOnlyPendingTimers() + await Promise.resolve() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(1) + expect(result.current.isLoading).toBe(true) + expect(result.current.error).toBeNull() + + // 前两次退避到期后仍在加载(等待第三次重试),第三次退避会耗尽重试 + for (const backoff of [500, 1500]) { + await act(async () => { + vi.advanceTimersByTime(backoff) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + expect(result.current.isLoading).toBe(true) + } + + // 第三次退避到期 → 第四次尝试 → 耗尽 → 终态 + await act(async () => { + vi.advanceTimersByTime(3000) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(4) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.sessions).toEqual([]) + + // 重试耗尽(500+1500+3000 后第四次尝试):终态 = 非 loading + error,而非「没有对话」 + await act(async () => { + vi.advanceTimersByTime(3000) + await Promise.resolve() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(4) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.sessions).toEqual([]) + }) + + it('lands success after a retry and clears error', async () => { + getSessionsMock + .mockRejectedValueOnce(new Error('service not ready')) + .mockResolvedValueOnce([makeSession('session-1')]) + + const { result } = renderHook(() => useSessions({ directory: '/workspace/demo' })) + + await act(async () => { + vi.runOnlyPendingTimers() + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + vi.advanceTimersByTime(500) + await Promise.resolve() + await Promise.resolve() + }) + + expect(result.current.sessions.map(session => session.id)).toEqual(['session-1']) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) }) diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index 9fd8d461..353a0ea8 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -12,6 +12,16 @@ import { serverStore } from '../store/serverStore' import { pinnedSessionsStore } from '../store/pinnedSessionsStore' import { autoDetectPathStyle, isSameDirectory } from '../utils' +// 会话列表加载失败的重试退避(毫秒)。退避间隔内仍算「加载中」, +// 不落空态——消费方据此区分「还在加载」与「确实没有对话」 +const SESSION_RETRY_DELAYS_MS = [500, 1500, 3000] + +function delay(ms: number): Promise { + return new Promise(resolve => { + window.setTimeout(resolve, ms) + }) +} + interface UseSessionsOptions { /** 每页数量 */ pageSize?: number @@ -73,10 +83,9 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult // 防止 onReconnected 密集触发时重复请求 const isFetchingRef = useRef(false) const queuedReconnectRefreshRef = useRef(false) - const retryTimerRef = useRef(null) - const fetchSessionsRef = useRef< - (params?: SessionListParams & { append?: boolean; retryAttempt?: number }) => Promise - >(() => Promise.resolve()) + const fetchSessionsRef = useRef<(params?: SessionListParams & { append?: boolean }) => Promise>( + () => Promise.resolve(), + ) useEffect(() => { searchRef.current = search @@ -90,11 +99,13 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult // 获取会话列表 // append 仅用于控制 loading 状态:true 时用 isLoadingMore,false 时用 isLoading // 数据始终全量替换(递增 limit 策略) + // 重试为显式循环而非 setTimeout 自调用:loading 的生命周期 = 循环的生命周期, + // 重试空档不再以「空列表 + 非 loading」示人(空态文案闪现的根因) const fetchSessions = useCallback( - async (params: SessionListParams & { append?: boolean; retryAttempt?: number } = {}) => { + async (params: SessionListParams & { append?: boolean } = {}) => { if (!enabled) return - const { append = false, retryAttempt = 0, ...queryParams } = params + const { append = false, ...queryParams } = params const requestId = ++requestIdRef.current isFetchingRef.current = true @@ -106,41 +117,47 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult } try { - const data = await getSessions( - { - roots: rootsOnly, - limit: currentLimitRef.current, - directory: normalizedDirectory, - ...queryParams, - }, - serverId, - ) - - // 检查是否是最新的请求 - if (requestId !== requestIdRef.current) return - - if (data.length > 0 && data[0].directory) { - // 按服务器记录路径风格(多服务器连不同操作系统时互不干扰) - autoDetectPathStyle(data[0].directory, serverId) - } - - setSessions(data) - setHasMore(data.length >= currentLimitRef.current) - } catch (e) { - if (requestId !== requestIdRef.current) return - setError(e instanceof Error ? e : new Error('Failed to fetch sessions')) - if (!append) { - if (retryAttempt < 3) { - if (retryTimerRef.current) clearTimeout(retryTimerRef.current) - retryTimerRef.current = window.setTimeout(() => { - if (requestId !== requestIdRef.current) return - void fetchSessions({ ...queryParams, retryAttempt: retryAttempt + 1 }) - }, [500, 1500, 3000][retryAttempt]) - } else { - setSessions([]) - setHasMore(false) + for (let retryAttempt = 0; ; retryAttempt++) { + try { + const data = await getSessions( + { + roots: rootsOnly, + limit: currentLimitRef.current, + directory: normalizedDirectory, + ...queryParams, + }, + serverId, + ) + + // 已被更新的请求覆盖:静默退出,不碰任何状态 + if (requestId !== requestIdRef.current) return + + if (data.length > 0 && data[0].directory) { + // 按服务器记录路径风格(多服务器连不同操作系统时互不干扰) + autoDetectPathStyle(data[0].directory, serverId) + } + + setSessions(data) + setHasMore(data.length >= currentLimitRef.current) + setError(null) + return + } catch (e) { + // 过期请求不打断外层竞态保护,直接交回 finally + if (requestId !== requestIdRef.current) throw e + const exhausted = retryAttempt >= SESSION_RETRY_DELAYS_MS.length + if (append || exhausted) { + if (requestId === requestIdRef.current) { + setError(e instanceof Error ? e : new Error('Failed to fetch sessions')) + } + throw e + } + // 重试未耗尽 = 仍在加载:loading 不落地,按退避表等待后进入下一轮 + await delay(SESSION_RETRY_DELAYS_MS[retryAttempt]) + if (requestId !== requestIdRef.current) return } } + } catch { + // 失败终态(或过期请求):error 已在上方落定,这里统一恢复取数状态 } finally { if (requestId === requestIdRef.current) { isFetchingRef.current = false @@ -186,9 +203,6 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult if (searchTimerRef.current) { clearTimeout(searchTimerRef.current) } - if (retryTimerRef.current) { - clearTimeout(retryTimerRef.current) - } } }, [search, fetchSessions, enabled, pageSize]) diff --git a/src/locales/en/chat.json b/src/locales/en/chat.json index a335a02e..b3c0af68 100644 --- a/src/locales/en/chat.json +++ b/src/locales/en/chat.json @@ -148,6 +148,7 @@ "moveFolderUp": "Move folder up", "moveFolderDown": "Move folder down", "noChatsInFolder": "No chats in this folder", + "loadFailed": "Failed to load, click to retry", "showMoreChats": "Show more chats", "selectedSessions": "{{count}} selected", "selectedProjects": "{{count}} projects", diff --git a/src/locales/zh-CN/chat.json b/src/locales/zh-CN/chat.json index 91423b53..5af5d6ed 100644 --- a/src/locales/zh-CN/chat.json +++ b/src/locales/zh-CN/chat.json @@ -148,6 +148,7 @@ "moveFolderUp": "上移文件夹", "moveFolderDown": "下移文件夹", "noChatsInFolder": "此文件夹中没有对话", + "loadFailed": "加载失败,点击重试", "showMoreChats": "展开更多会话", "selectedSessions": "已选 {{count}}", "selectedProjects": "已选 {{count}} 个项目", From 0659e1121cabea211aa2bdf723e61bc3ea244a33 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Tue, 1 Sep 2026 23:34:25 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(wsl):=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E2=80=94=E2=80=94=E5=9C=A8=E7=BA=BF=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=20revalidate=20=E5=8E=BB=E9=87=8D=E3=80=81prewarm=20=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E5=B0=B1=E7=BB=AA=E6=9C=8D=E5=8A=A1=E5=99=A8=E3=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=97=A8=E6=8E=A7=E7=AE=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台 revalidate 加在途标志,避免每次 ServeStale 并发拉起会挂住的 wsl --list --online - prewarm 只补未就绪发行版的 opencode 检查,就绪服务器由 Ready 挂钩刷新,不再重复 spawn - 单元测试去掉冗余 target_os 门控(模块本身已按 Windows 编译) --- src-tauri/src/app/commands/wsl_commands.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/app/commands/wsl_commands.rs b/src-tauri/src/app/commands/wsl_commands.rs index 0b70add2..aa5fcb08 100644 --- a/src-tauri/src/app/commands/wsl_commands.rs +++ b/src-tauri/src/app/commands/wsl_commands.rs @@ -174,6 +174,9 @@ pub struct WslState { job_token: Mutex>, /// job 代数计数器 job_generation: std::sync::atomic::AtomicU64, + /// 在线目录后台再验证在途标志:防止多次 ServeStale 并发拉起 + /// 多个会挂住的 `wsl --list --online` 进程(离线/网络失败时尤其浪费) + online_revalidating: std::sync::atomic::AtomicBool, } impl WslState { @@ -185,6 +188,7 @@ impl WslState { 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), } } @@ -481,12 +485,19 @@ fn commit_distros(state: &WslState, installed: Vec, online: /// 后台再验证在线目录(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); }); } @@ -512,10 +523,13 @@ pub async fn prewarm_wsl(state: tauri::State<'_, WslState>) -> Result<(), String let _ = refresh_distros_impl(&state, false).await; } - // 为已添加但缺检查的发行版补 opencode 检查(就绪服务器由 Ready 挂钩自行刷新) + // 为已添加但缺检查的发行版补 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() @@ -1215,9 +1229,10 @@ pub fn initialize_wsl(app: &AppHandle) { // ============================================ // 单元测试 —— 在线目录缓存的纯决策逻辑 +// 模块已在 mod.rs 按 target_os = "windows" 门控,测试只需 cfg(test) // ============================================ -#[cfg(all(test, target_os = "windows"))] +#[cfg(test)] mod tests { use super::*; From e0601b710caa1e238483ae61df3ef00e46d062d5 Mon Sep 17 00:00:00 2001 From: Rayn <395924181@qq.com> Date: Tue, 1 Sep 2026 23:34:25 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(sessions):=20=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E2=80=94=E2=80=94=E8=BF=87=E6=BB=A4=E5=90=8E?= =?UTF-8?q?=E5=85=9C=E5=BA=95=20active=20server=E3=80=81enabled=20?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E4=B8=AD=E6=96=AD=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E5=8E=BB=E9=99=A4=E7=A9=BA=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collectActiveServerIds 过滤后为空时兜底回退 active server,避免 SSE 全灭(补回归测试) - useSessions 重试等待期检查 enabledRef,懒加载闸门关闭时中断在途重试 - 重试终态改为 return 而非 throw 到空 catch,消除空 catch 块 --- src/hooks/useGlobalEvents.test.tsx | 24 +++++++++++++++++++++++- src/hooks/useGlobalEvents.ts | 5 ++++- src/hooks/useSessions.ts | 22 +++++++++++++--------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/hooks/useGlobalEvents.test.tsx b/src/hooks/useGlobalEvents.test.tsx index 7b2bbd00..2878d8bb 100644 --- a/src/hooks/useGlobalEvents.test.tsx +++ b/src/hooks/useGlobalEvents.test.tsx @@ -33,6 +33,7 @@ const { serverStoreListeners, multiServerMock, multiServerListeners, + paneLeavesMock, autoApproveStoreMock, clearSessionRuntimeStateMock, clearPaneSessionMock, @@ -46,6 +47,8 @@ const { // 多服务器订阅白名单:可控开关 + 白名单集合,模拟 WSL 就绪注册时序 const multiServerMock = { enabled: false, subscribedIds: [] as string[] } const multiServerListeners = new Set<() => void>() + // paneLayoutStore.allLeaves 的可控数据源:测试可注入特定 pane 会话 + const paneLeavesMock: { current: Array<{ sessionId?: string }> } = { current: [] } return { subscribeToEventsMock: vi.fn(), getSessionStatusMock: vi.fn<(directory?: string) => Promise>>(() => Promise.resolve({})), @@ -74,6 +77,7 @@ const { serverStoreListeners, multiServerMock, multiServerListeners, + paneLeavesMock, clearSessionRuntimeStateMock: vi.fn(), clearPaneSessionMock: vi.fn(), getSoundSnapshotMock: vi.fn(() => ({ @@ -150,7 +154,7 @@ vi.mock('../store', () => ({ paneLayoutStore: { getFocusedSessionId: getFocusedSessionIdMock, clearSession: clearPaneSessionMock, - allLeaves: () => [], + allLeaves: () => paneLeavesMock.current, subscribe: () => () => {}, }, serverStore: { @@ -247,6 +251,7 @@ describe('useGlobalEvents', () => { multiServerMock.enabled = false multiServerMock.subscribedIds = [] multiServerListeners.clear() + paneLeavesMock.current = [] onServerChangeMock.mockImplementation(listener => { serverChangeListeners.add(listener) return () => { @@ -874,4 +879,21 @@ describe('useGlobalEvents', () => { expect(unsubscribes.local).toHaveLength(1) expect(unsubscribes.local[0]).not.toHaveBeenCalled() }) + + it('backfills the active server when filtering leaves an empty set', async () => { + // F1 回归:pane 仍指向已失效的服务器(sidecar 崩溃后未清理), + // 单服务器模式下过滤后集合为空——必须兜底回退 active server, + // 否则全应用一条 SSE 都没有(聊天静默) + paneLeavesMock.current = [{ sessionId: 'wsl:Ubuntu::ses-1' }] + const unsubscribes: string[] = [] + subscribeToEventsMock.mockImplementation((_cb, serverId: string) => { + unsubscribes.push(serverId) + return vi.fn() + }) + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(subscribeToEventsMock).toHaveBeenCalledTimes(1)) + expect(unsubscribes).toEqual(['local']) + }) }) diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index ac1e0454..8fa0b03e 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -304,7 +304,10 @@ function collectActiveServerIds(): string[] { // 过滤未注册服务器:WSL 白名单 id 在 sidecar 就绪前就存在于持久化配置中, // 而 getServerBaseUrl 对未知 id 静默回退 local——不过滤就会拿 local 的数据 // 写到 wsl: 键下(数据串服)。服务器注册进 serverStore 时 notify 触发重算,自然入集 - return Array.from(ids).filter(id => serverStore.getServer(id) !== null) + const registered = Array.from(ids).filter(id => serverStore.getServer(id) !== null) + // 兜底:pane 可能仍指向已失效的服务器(如 WSL sidecar 崩溃后未清理), + // 过滤后为空会让 SSE 全灭——active server 恒注册,保住它 + return registered.length > 0 ? registered : [serverStore.getActiveServerId()] } export function useGlobalEvents(directories?: string[]) { diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index 353a0ea8..7701d10c 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -80,6 +80,8 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult // 当前 limit,loadMore 时递增(与 SessionContext 保持一致) const currentLimitRef = useRef(pageSize) const searchRef = useRef(search) + // enabled 实时值:重试循环等待期间读取(懒加载闸门关闭时中断在途重试) + const enabledRef = useRef(enabled) // 防止 onReconnected 密集触发时重复请求 const isFetchingRef = useRef(false) const queuedReconnectRefreshRef = useRef(false) @@ -87,6 +89,10 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult () => Promise.resolve(), ) + useEffect(() => { + enabledRef.current = enabled + }, [enabled]) + useEffect(() => { searchRef.current = search }, [search]) @@ -142,22 +148,20 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsResult setError(null) return } catch (e) { - // 过期请求不打断外层竞态保护,直接交回 finally - if (requestId !== requestIdRef.current) throw e + // 已被更新的请求覆盖:静默退出,状态恢复交给新请求的 finally + if (requestId !== requestIdRef.current) return const exhausted = retryAttempt >= SESSION_RETRY_DELAYS_MS.length if (append || exhausted) { - if (requestId === requestIdRef.current) { - setError(e instanceof Error ? e : new Error('Failed to fetch sessions')) - } - throw e + // 失败终态:error 落定后退出,finally 统一恢复 loading + setError(e instanceof Error ? e : new Error('Failed to fetch sessions')) + return } // 重试未耗尽 = 仍在加载:loading 不落地,按退避表等待后进入下一轮 await delay(SESSION_RETRY_DELAYS_MS[retryAttempt]) - if (requestId !== requestIdRef.current) return + // 等待期间可能被新请求覆盖,或组件已禁用(懒加载闸门回退) + if (requestId !== requestIdRef.current || !enabledRef.current) return } } - } catch { - // 失败终态(或过期请求):error 已在上方落定,这里统一恢复取数状态 } finally { if (requestId === requestIdRef.current) { isFetchingRef.current = false