diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33dab3f0b..09e180adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,11 @@ jobs: backend: name: Backend Checks - runs-on: ubuntu-22.04 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@v6 @@ -66,6 +70,7 @@ jobs: components: rustfmt, clippy - name: Install Linux system deps + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ @@ -87,6 +92,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo- - name: Create frontend dist placeholder + shell: bash run: mkdir -p dist - name: Check Rust formatting diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 4d99bf2a6..9e4f0e023 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -2137,7 +2137,8 @@ base_url = "https://proxy.example/v1" let env_sqlite_home = dir.path().join("env-sqlite-home"); let config_sqlite_home = dir.path().join("config-sqlite-home"); let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home); - let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display()); + // TOML 字面量字符串(单引号):Windows 路径含反斜杠,basic string 会解析失败。 + let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display()); let paths = codex_state_db_paths(&codex_dir, &config_text); diff --git a/src-tauri/src/codex_state_db.rs b/src-tauri/src/codex_state_db.rs index ba5ee4e28..da4d69d1f 100644 --- a/src-tauri/src/codex_state_db.rs +++ b/src-tauri/src/codex_state_db.rs @@ -83,7 +83,9 @@ mod tests { fn includes_config_sqlite_home() { let temp = tempdir().expect("tempdir"); let sqlite_home = temp.path().join("sqlite-home"); - let config_text = format!("sqlite_home = \"{}\"\n", sqlite_home.display()); + // 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号) + // 会把 `\U`/`\s` 等当作非法转义导致解析失败。 + let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display()); let paths = codex_state_db_paths(temp.path(), &config_text); diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 77fa0c955..aa6257c96 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -638,7 +638,7 @@ fn build_tool_action_line( // (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等—— // 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch` // 加上双引号,call 对带引号的路径解析正常。 - return Ok(format!("call {command}")); + Ok(format!("call {command}")) } #[cfg(not(target_os = "windows"))] @@ -1069,6 +1069,8 @@ fn default_flag_for_shell(shell: &str) -> &'static str { } } +// 以下 shell 解析辅助函数仅被 macOS/Linux 的终端启动逻辑使用;Windows 非 test 编译下为死代码。 +#[cfg_attr(windows, allow(dead_code))] fn fallback_user_shell() -> &'static str { if cfg!(target_os = "macos") { "/bin/zsh" @@ -1077,6 +1079,7 @@ fn fallback_user_shell() -> &'static str { } } +#[cfg_attr(windows, allow(dead_code))] fn valid_user_shell_path(shell: &str) -> bool { if shell.is_empty() || !shell.starts_with('/') @@ -1100,11 +1103,13 @@ fn is_executable_file(path: &std::path::Path) -> bool { } #[cfg(not(unix))] +#[cfg_attr(windows, allow(dead_code))] fn is_executable_file(path: &std::path::Path) -> bool { path.is_file() } /// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。 +#[cfg_attr(windows, allow(dead_code))] fn get_user_shell() -> String { std::env::var("SHELL") .ok() @@ -1113,6 +1118,7 @@ fn get_user_shell() -> String { } /// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。 +#[cfg_attr(windows, allow(dead_code))] fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { let quoted_shell = shell_single_quote(shell); @@ -1132,6 +1138,7 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { } /// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。 +#[cfg_attr(windows, allow(dead_code))] fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String { let claude_command = format!("claude --settings {}", shell_single_quote(config_path)); let command = cwd @@ -1152,6 +1159,7 @@ fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path ) } +#[cfg_attr(windows, allow(dead_code))] fn provider_command_flag_for_shell(shell: &str) -> &'static str { match shell.rsplit('/').next().unwrap_or(shell) { "dash" | "sh" => "-c", @@ -1160,6 +1168,7 @@ fn provider_command_flag_for_shell(shell: &str) -> &'static str { } } +#[cfg_attr(windows, allow(dead_code))] fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String { if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") { return String::new(); @@ -2731,7 +2740,7 @@ fn launch_terminal_with_env( #[cfg(target_os = "windows")] { launch_windows_terminal(&temp_dir, &config_file, cwd)?; - return Ok(()); + Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] @@ -3252,6 +3261,7 @@ del \"%~f0\" >nul 2>&1 result } +#[cfg_attr(windows, allow(dead_code))] fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } @@ -3838,11 +3848,8 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let volta_full = format!("{}\\volta.exe", sub.to_string_lossy()); - let expected = format!( - "{} update || call {} install @openai/codex", - expect_quoted_path(&bin_path), - expect_quoted_path(&volta_full) - ); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 + let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full)); assert_eq!(cmd.as_deref(), Some(expected.as_str())); } @@ -3854,9 +3861,9 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy()); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 let expected = format!( - "{} update || call {} add -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} add -g @openai/codex@latest", expect_quoted_path(&pnpm_full) ); assert_eq!(cmd.as_deref(), Some(expected.as_str())); @@ -3888,9 +3895,9 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 let expected = format!( - "{} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} i -g @openai/codex@latest", expect_quoted_path(&npm_full) ); assert_eq!(cmd.as_deref(), Some(expected.as_str())); @@ -3898,10 +3905,11 @@ mod tests { #[test] fn windows_no_sibling_uses_cli_update_without_package_fallback() { - // sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。 - // 只是没有包管理器 fallback。 - let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]); - let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); + // sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。 + // 只是没有包管理器 fallback。用 claude —— codex 自 5092fe51 起一律走 npm 锚定, + // 已不再有官方 self-update 分支,故改用仍在 prefers_official_update 的 claude 覆盖此路径。 + let (_dir, _sub, bin_path) = setup_sibling("", "claude.cmd", &[]); + let cmd = anchored_command_from_paths("claude", &bin_path, &bin_path); let expected = format!("{} update", expect_quoted_path(&bin_path)); assert_eq!(cmd.as_deref(), Some(expected.as_str())); } @@ -3977,9 +3985,9 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 走 npm 锚定(5092fe51),含空格的 npm 全路径仍必须被双引号包裹。 let expected = format!( - "{} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} i -g @openai/codex@latest", expect_quoted_path(&npm_full) ); assert_eq!(cmd.as_deref(), Some(expected.as_str())); @@ -4001,9 +4009,10 @@ mod tests { // 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义 // 会让 expected 漏引号、假失败)。 let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 走 npm 锚定(5092fe51):batch 行 = `call i -g ...`, + // 含字面 `%` 的 npm 路径仍须 4 倍转义。 let expected = format!( - "call {} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "call {} i -g @openai/codex@latest", expect_quoted_path(&npm_full) ); assert_eq!(batch_line, expected); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index ec508d792..f6cdb8111 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result "Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。" ) })?; - return Ok(true); + Ok(true) } #[cfg(not(target_os = "windows"))] diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 0e90e7bb7..fbf72bc86 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -374,20 +374,15 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option { /// 获取 `~/.agents/skills/` 目录(存在时返回) fn get_agents_skills_dir() -> Option { - dirs::home_dir() - .map(|h| h.join(".agents").join("skills")) - .filter(|p| p.exists()) + let dir = crate::config::get_home_dir().join(".agents").join("skills"); + dir.exists().then_some(dir) } /// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息 fn parse_agents_lock() -> HashMap { - let path = match dirs::home_dir() { - Some(h) => h.join(".agents").join(".skill-lock.json"), - None => { - log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); - return HashMap::new(); - } - }; + let path = crate::config::get_home_dir() + .join(".agents") + .join(".skill-lock.json"); let content = match fs::read_to_string(&path) { Ok(c) => c, Err(e) => { @@ -482,12 +477,7 @@ impl SkillService { let dir = match location { SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), SkillStorageLocation::Unified => { - let home = dirs::home_dir().context(format_skill_error( - "GET_HOME_DIR_FAILED", - &[], - Some("checkPermission"), - ))?; - home.join(".agents").join("skills") + crate::config::get_home_dir().join(".agents").join("skills") } }; fs::create_dir_all(&dir)?; @@ -538,12 +528,10 @@ impl SkillService { } } - // 默认路径:回退到用户主目录下的标准位置 - let home = dirs::home_dir().context(format_skill_error( - "GET_HOME_DIR_FAILED", - &[], - Some("checkPermission"), - ))?; + // 默认路径:回退到用户主目录下的标准位置。 + // 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir() + // 走 Known Folder API,测试无法隔离真实用户目录。 + let home = crate::config::get_home_dir(); Ok(match app { AppType::Claude => home.join(".claude").join("skills"), @@ -1167,8 +1155,7 @@ impl SkillService { let new_dir = match target { SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), SkillStorageLocation::Unified => { - let home = dirs::home_dir().context("Cannot determine home directory")?; - home.join(".agents").join("skills") + crate::config::get_home_dir().join(".agents").join("skills") } }; fs::create_dir_all(&new_dir)?; @@ -3069,6 +3056,36 @@ mod tests { .expect("write SKILL.md"); } + #[test] + // serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥, + // EnvGuard 只负责恢复不提供互斥。 + #[serial_test::serial] + fn get_app_skills_dir_honors_test_home_override() { + // 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME + // 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效 + // (tests/skill_sync.rs 扫到 runner 真实用户目录)。 + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + let temp = tempdir().expect("tempdir"); + let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + + let dir = + SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir"); + assert!( + dir.starts_with(temp.path()), + "skills dir must live under the overridden test home, got {}", + dir.display() + ); + } + #[test] fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8d99de06c..6d627ee53 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Write; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; @@ -660,6 +659,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { #[cfg(unix)] { use std::fs::OpenOptions; + use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = OpenOptions::new()