From d466953299cdcdbbef19ecb7d594a8e96eb367db Mon Sep 17 00:00:00 2001 From: XiaoLrrr Date: Thu, 15 Oct 2026 09:49:40 +0800 Subject: [PATCH 01/15] fix: suppress console window flashes on Windows subprocess spawns --- crates/hk-core/src/lib.rs | 2 ++ crates/hk-core/src/manager.rs | 31 ++++++++++---------- crates/hk-core/src/process.rs | 20 +++++++++++++ crates/hk-core/src/scanner.rs | 4 +-- crates/hk-core/src/service.rs | 2 +- crates/hk-core/src/skills_cli.rs | 3 +- crates/hk-desktop/src/commands/extensions.rs | 16 +++++----- crates/hk-desktop/src/commands/install.rs | 4 +-- 8 files changed, 51 insertions(+), 31 deletions(-) create mode 100644 crates/hk-core/src/process.rs diff --git a/crates/hk-core/src/lib.rs b/crates/hk-core/src/lib.rs index 03b6d028..47d71453 100644 --- a/crates/hk-core/src/lib.rs +++ b/crates/hk-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod kits; pub mod manager; pub mod marketplace; pub mod models; +pub mod process; pub mod sanitize; pub mod scanner; pub mod service; @@ -14,3 +15,4 @@ pub mod skills_cli; pub mod store; pub use error::HkError; +pub use process::command; diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index e5aa796b..04cc29e6 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -3,7 +3,6 @@ use crate::models::*; use crate::store::Store; use crate::{adapter, deployer, sanitize, scanner}; use std::path::{Path, PathBuf}; -use std::process::Command; /// Field names under which an MCP server entry stores a secret-bearing /// string map. Most agents use `"env"`; OpenCode's schema names the same @@ -896,7 +895,7 @@ pub fn check_update_with_cache( } pub fn get_remote_head(url: &str) -> Result { - let output = Command::new("git") + let output = crate::command("git") .args(["ls-remote", "--heads", "--", url]) .output() .map_err(|e| HkError::CommandFailed(format!("Failed to run git ls-remote: {e}")))?; @@ -941,7 +940,7 @@ pub fn install_from_git_with_id( .map_err(|e| HkError::Internal(format!("Failed to create temp directory: {e}")))?; let clone_dir = temp.path().join("repo"); - let output = Command::new("git") + let output = crate::command("git") .args([ "clone", "--depth", @@ -1331,7 +1330,7 @@ fn find_skill_dir_in_tree( /// Run `git rev-parse HEAD` in the given directory to capture the current revision. /// Returns None if the command fails (e.g. not a git repo). fn capture_git_revision(repo_dir: &Path) -> Option { - Command::new("git") + crate::command("git") .args(["rev-parse", "HEAD"]) .current_dir(repo_dir) .output() @@ -1752,14 +1751,14 @@ mod tests { let work = TempDir::new().unwrap(); // Init bare repo - Command::new("git") + crate::command("git") .args(["init", "--bare"]) .arg(bare.path()) .output() .unwrap(); // Clone, commit, push - Command::new("git") + crate::command("git") .args([ "clone", &bare.path().to_string_lossy(), @@ -1767,7 +1766,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1778,11 +1777,11 @@ mod tests { .output() .unwrap(); std::fs::write(work.path().join("README.md"), "hello").unwrap(); - Command::new("git") + crate::command("git") .args(["-C", &work.path().to_string_lossy(), "add", "."]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1796,7 +1795,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1807,7 +1806,7 @@ mod tests { .output() .unwrap(); - let out = Command::new("git") + let out = crate::command("git") .args(["-C", &work.path().to_string_lossy(), "rev-parse", "HEAD"]) .output() .unwrap(); @@ -1819,7 +1818,7 @@ mod tests { /// Helper: push a new commit to an existing bare repo on the given branch fn push_new_commit(bare: &Path, branch: &str) -> String { let work = TempDir::new().unwrap(); - Command::new("git") + crate::command("git") .args([ "clone", "-b", @@ -1830,11 +1829,11 @@ mod tests { .output() .unwrap(); std::fs::write(work.path().join("update.txt"), "updated").unwrap(); - Command::new("git") + crate::command("git") .args(["-C", &work.path().to_string_lossy(), "add", "."]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1848,7 +1847,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1859,7 +1858,7 @@ mod tests { .output() .unwrap(); - let out = Command::new("git") + let out = crate::command("git") .args(["-C", &work.path().to_string_lossy(), "rev-parse", "HEAD"]) .output() .unwrap(); diff --git a/crates/hk-core/src/process.rs b/crates/hk-core/src/process.rs new file mode 100644 index 00000000..a97fab54 --- /dev/null +++ b/crates/hk-core/src/process.rs @@ -0,0 +1,20 @@ +//! Subprocess helpers. +//! +//! On Windows every spawned process is created with `CREATE_NO_WINDOW` so the +//! Tauri desktop build (a GUI process with no console) never flashes a console +//! window when probing binaries (`where`, `git`, `npm`, …). + +use std::process::Command; + +/// Create a `Command` that never pops a console window on Windows. +/// No-op on non-Windows platforms. +pub fn command(program: impl AsRef) -> Command { + let mut cmd = Command::new(program); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index c2ad6cf5..85195403 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -662,7 +662,7 @@ pub(crate) fn run_which(name: &str) -> Option { #[cfg(not(target_os = "windows"))] const WHICH_CMD: &str = "which"; - std::process::Command::new(WHICH_CMD) + crate::command(WHICH_CMD) .arg(name) .output() .ok() @@ -731,7 +731,7 @@ fn get_binary_version(name: &str) -> Option { } static VERSION_RE: LazyLock = LazyLock::new(|| Regex::new(r"(\d+\.\d+(?:\.\d+)?)").unwrap()); - let output = std::process::Command::new(name) + let output = crate::command(name) .arg("--version") .output() .ok()?; diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index bfbfd384..bb4f87ef 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -740,7 +740,7 @@ fn read_plugin_content(plugin_path: &str) -> String { /// files, which is precisely what the agent's uninstaller exists to avoid. fn run_agent_uninstall(program: &str, args: &[String]) -> Result<(), HkError> { let display = format!("{program} {}", args.join(" ")); - match std::process::Command::new(program).args(args).output() { + match crate::command(program).args(args).output() { Ok(out) if out.status.success() => Ok(()), Ok(out) => Err(HkError::CommandFailed(format!( "`{display}` failed: {}", diff --git a/crates/hk-core/src/skills_cli.rs b/crates/hk-core/src/skills_cli.rs index 832e4a7b..554d28d1 100644 --- a/crates/hk-core/src/skills_cli.rs +++ b/crates/hk-core/src/skills_cli.rs @@ -16,7 +16,6 @@ //! symlink doesn't touch the CLI's canonical `~/.agents` copy (no lockfile drift), //! so native removal is both safe and more precise. -use std::process::Command; use crate::error::HkError; use crate::models::ConfigScope; @@ -65,7 +64,7 @@ fn run(args: &[String]) -> Result { /// can be unit-tested with real shell utilities. fn run_with(launchers: &[(&str, &[&str])], args: &[String]) -> Result { for (program, lead) in launchers { - let mut cmd = Command::new(program); + let mut cmd = crate::command(program); cmd.args(*lead).args(args); match cmd.output() { Ok(out) if out.status.success() => return Ok(true), diff --git a/crates/hk-desktop/src/commands/extensions.rs b/crates/hk-desktop/src/commands/extensions.rs index ecb11963..480c8a80 100644 --- a/crates/hk-desktop/src/commands/extensions.rs +++ b/crates/hk-desktop/src/commands/extensions.rs @@ -100,21 +100,21 @@ pub fn open_in_system(_state: State, path: String) -> Result<(), HkErr } #[cfg(target_os = "macos")] { - std::process::Command::new("open") + hk_core::command("open") .arg(&path) .spawn() .map_err(|e| HkError::CommandFailed(format!("Failed to open: {}", e)))?; } #[cfg(target_os = "windows")] { - std::process::Command::new("explorer") + hk_core::command("explorer") .arg(&path) .spawn() .map_err(|e| HkError::CommandFailed(format!("Failed to open: {}", e)))?; } #[cfg(target_os = "linux")] { - std::process::Command::new("xdg-open") + hk_core::command("xdg-open") .arg(&path) .spawn() .map_err(|e| HkError::CommandFailed(format!("Failed to open: {}", e)))?; @@ -131,7 +131,7 @@ pub fn reveal_in_file_manager(_state: State, path: String) -> Result<( } #[cfg(target_os = "macos")] { - std::process::Command::new("open") + hk_core::command("open") .arg("-R") .arg(&path) .spawn() @@ -139,7 +139,7 @@ pub fn reveal_in_file_manager(_state: State, path: String) -> Result<( } #[cfg(target_os = "windows")] { - std::process::Command::new("explorer") + hk_core::command("explorer") .arg(format!("/select,{}", path)) .spawn() .map_err(|e| HkError::CommandFailed(format!("Failed to reveal: {}", e)))?; @@ -147,7 +147,7 @@ pub fn reveal_in_file_manager(_state: State, path: String) -> Result<( #[cfg(target_os = "linux")] { let parent = file_path.parent().unwrap_or(file_path); - std::process::Command::new("xdg-open") + hk_core::command("xdg-open") .arg(parent) .spawn() .map_err(|e| HkError::CommandFailed(format!("Failed to reveal: {}", e)))?; @@ -477,7 +477,7 @@ pub async fn check_updates( Err(_) => continue, }; let clone_path = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args(["clone", "--depth", "1", "--", url, &clone_path.to_string_lossy()]) .output(); let ok = output.map(|o| o.status.success()).unwrap_or(false); @@ -634,7 +634,7 @@ pub async fn update_extension( // Clone the repo once let temp = tempfile::tempdir().map_err(|e| HkError::Internal(e.to_string()))?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args(["clone", "--depth", "1", "--", url, &clone_dir.to_string_lossy()]) .output() .map_err(|e| HkError::CommandFailed(format!("Failed to run git clone: {}", e)))?; diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index c7c506e9..e210a7ac 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -259,7 +259,7 @@ pub async fn scan_git_repo( let temp = tempfile::tempdir()?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args([ "clone", "--depth", @@ -515,7 +515,7 @@ pub async fn install_new_repo_skills( let temp = tempfile::tempdir() .map_err(|e| HkError::Internal(format!("Failed to create temp directory: {e}")))?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args([ "clone", "--depth", From 4e2d1b5d489fd7bef7dcbd9dad49d40c4b9cdd98 Mon Sep 17 00:00:00 2001 From: XiaoLrrr Date: Thu, 15 Oct 2026 09:49:40 +0800 Subject: [PATCH 02/15] feat: add Windows desktop support (window config + close behavior) --- crates/hk-desktop/src/main.rs | 15 +- crates/hk-desktop/tauri.windows.conf.json | 16 ++ package-lock.json | 261 ++++++++++++++++++++-- package.json | 1 + 4 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 crates/hk-desktop/tauri.windows.conf.json diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 34f78ac1..3a2ce6c5 100644 --- a/crates/hk-desktop/src/main.rs +++ b/crates/hk-desktop/src/main.rs @@ -100,9 +100,18 @@ fn main() { ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { - // Hide instead of quit on macOS red X - window.hide().unwrap_or_default(); - api.prevent_close(); + // macOS: hide instead of quit so the dock icon keeps the app + // alive. Windows/Linux: closing the window quits the app + // (there is no dock/tray to bring a hidden window back). + #[cfg(target_os = "macos")] + { + window.hide().unwrap_or_default(); + api.prevent_close(); + } + #[cfg(not(target_os = "macos"))] + { + let _ = (window, api); + } } }) .build(tauri::generate_context!()) diff --git a/crates/hk-desktop/tauri.windows.conf.json b/crates/hk-desktop/tauri.windows.conf.json new file mode 100644 index 00000000..e080d875 --- /dev/null +++ b/crates/hk-desktop/tauri.windows.conf.json @@ -0,0 +1,16 @@ +{ + "app": { + "windows": [ + { + "title": "HarnessKit", + "decorations": true, + "width": 1280, + "height": 800, + "minWidth": 900, + "minHeight": 600, + "transparent": false, + "titleBarStyle": "Visible" + } + ] + } +} diff --git a/package-lock.json b/package-lock.json index 19021219..7abb8ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@tauri-apps/cli": "^2.12.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -148,7 +149,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -672,7 +672,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -721,7 +720,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -743,7 +741,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2080,6 +2077,238 @@ "url": "https://opencollective.com/tauri" } }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz", + "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.12.2", + "@tauri-apps/cli-darwin-x64": "2.12.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.2", + "@tauri-apps/cli-linux-arm64-musl": "2.12.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-musl": "2.12.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.2", + "@tauri-apps/cli-win32-x64-msvc": "2.12.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz", + "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz", + "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz", + "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz", + "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz", + "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz", + "integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz", + "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz", + "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz", + "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz", + "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz", + "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -2217,7 +2446,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2345,7 +2575,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2356,7 +2585,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2755,7 +2983,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3116,15 +3343,15 @@ "version": "0.0.1595872", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.331", @@ -3588,7 +3815,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -4130,6 +4356,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4948,7 +5175,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4991,6 +5217,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5006,6 +5233,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5130,7 +5358,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5140,7 +5367,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5180,7 +5406,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -5789,7 +6016,6 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5976,7 +6202,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 77817581..74ec4143 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@tauri-apps/cli": "^2.12.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", From 93dc4ae4f34d8a63894fc0c9aa982fa06b996050 Mon Sep 17 00:00:00 2001 From: XiaoLrrr Date: Mon, 19 Oct 2026 09:58:26 +0800 Subject: [PATCH 03/15] fix(windows): resolve home dir via HARNESSKIT_HOME + cross-platform path/test fixes - add hk_core::home_dir() honoring HARNESSKIT_HOME (falls back to dirs::home_dir) - route adapters, sanitize, scanner, skills_cli through crate::home_dir() - openclaw: treat root-relative paths as resolved on Windows - make path assertions and launcher shims cross-platform; fix hk-web allowlist test --- crates/hk-core/src/adapter/antigravity.rs | 2 +- crates/hk-core/src/adapter/claude.rs | 2 +- crates/hk-core/src/adapter/codex.rs | 2 +- crates/hk-core/src/adapter/copilot.rs | 5 +- crates/hk-core/src/adapter/cursor.rs | 2 +- crates/hk-core/src/adapter/dsh.rs | 2 +- crates/hk-core/src/adapter/gemini.rs | 2 +- crates/hk-core/src/adapter/grok.rs | 4 +- crates/hk-core/src/adapter/hermes.rs | 2 +- crates/hk-core/src/adapter/kiro.rs | 2 +- crates/hk-core/src/adapter/omp.rs | 2 +- crates/hk-core/src/adapter/openclaw.rs | 8 +- crates/hk-core/src/adapter/opencode.rs | 2 +- crates/hk-core/src/adapter/penguin.rs | 2 +- crates/hk-core/src/adapter/pi.rs | 2 +- crates/hk-core/src/adapter/qoder_cn.rs | 2 +- crates/hk-core/src/adapter/windsurf.rs | 2 +- crates/hk-core/src/auditor/rules/cli.rs | 3 +- crates/hk-core/src/kits/paths.rs | 8 +- crates/hk-core/src/kits/tests/service.rs | 90 +++++++++++++++-------- crates/hk-core/src/lib.rs | 12 +++ crates/hk-core/src/manager.rs | 3 +- crates/hk-core/src/sanitize.rs | 18 ++--- crates/hk-core/src/scanner.rs | 55 ++++++++++---- crates/hk-core/src/skills_cli.rs | 12 +++ crates/hk-web/src/handlers/mod.rs | 8 +- crates/hk-web/tests/api_test.rs | 7 ++ 27 files changed, 174 insertions(+), 87 deletions(-) diff --git a/crates/hk-core/src/adapter/antigravity.rs b/crates/hk-core/src/adapter/antigravity.rs index 175dbcbb..5b7ec468 100644 --- a/crates/hk-core/src/adapter/antigravity.rs +++ b/crates/hk-core/src/adapter/antigravity.rs @@ -28,7 +28,7 @@ impl Default for AntigravityAdapter { impl AntigravityAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } #[cfg(test)] diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 5ecd55ef..4a5fdc36 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -23,7 +23,7 @@ impl Default for ClaudeAdapter { impl ClaudeAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs index 86f36fc3..aea28f81 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -52,7 +52,7 @@ impl Default for CodexAdapter { impl CodexAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index c0b6be72..f5bc8da3 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -70,7 +70,7 @@ impl Default for CopilotAdapter { impl CopilotAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } #[cfg(test)] @@ -500,6 +500,7 @@ mod tests { assert_eq!(hooks.len(), 2); } + #[test] fn read_vscode_agent_plugins() { let tmp = tempfile::tempdir().unwrap(); @@ -520,7 +521,7 @@ mod tests { &installed, format!( r#"{{"version":1,"installed":[{{"pluginUri":"file://{}","marketplace":"user/my-repo"}}]}}"#, - plugin_dir.to_string_lossy() + plugin_dir.to_string_lossy().replace('\\', "/") ), ).unwrap(); diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs index d0bbabc5..51788580 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -24,7 +24,7 @@ impl Default for CursorAdapter { impl CursorAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/dsh.rs b/crates/hk-core/src/adapter/dsh.rs index 5366e7ae..a4d41e4f 100644 --- a/crates/hk-core/src/adapter/dsh.rs +++ b/crates/hk-core/src/adapter/dsh.rs @@ -58,7 +58,7 @@ fn resolve_homes( impl DshAdapter { pub fn new() -> Self { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); let (dsh_home, agents_home) = resolve_homes( std::env::var_os("DSH_HOME"), std::env::var_os("DSH_AGENTS_HOME"), diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs index 2807f54d..e3c08152 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -65,7 +65,7 @@ impl Default for GeminiAdapter { impl GeminiAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } #[cfg(test)] diff --git a/crates/hk-core/src/adapter/grok.rs b/crates/hk-core/src/adapter/grok.rs index 3c173b30..1b691b76 100644 --- a/crates/hk-core/src/adapter/grok.rs +++ b/crates/hk-core/src/adapter/grok.rs @@ -456,7 +456,7 @@ impl Default for GrokAdapter { impl GrokAdapter { pub fn new() -> Self { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); Self { grok_home: resolve_grok_home(std::env::var_os("GROK_HOME"), Some(&home)) .unwrap_or_else(|| home.join(".grok")), @@ -481,7 +481,7 @@ impl GrokAdapter { /// moves neither — so don't scan skills or evaluate plugin enablement /// through an adapter built this way in tests. pub fn with_grok_home(grok_home: PathBuf) -> Self { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); Self { grok_home, agents_home: home.join(".agents"), diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index 9e0390d8..22296fb1 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -27,7 +27,7 @@ impl Default for HermesAdapter { impl HermesAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/kiro.rs b/crates/hk-core/src/adapter/kiro.rs index 2f103d92..eabfb7d9 100644 --- a/crates/hk-core/src/adapter/kiro.rs +++ b/crates/hk-core/src/adapter/kiro.rs @@ -26,7 +26,7 @@ impl Default for KiroAdapter { impl KiroAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/omp.rs b/crates/hk-core/src/adapter/omp.rs index eca765af..354e0c46 100644 --- a/crates/hk-core/src/adapter/omp.rs +++ b/crates/hk-core/src/adapter/omp.rs @@ -33,7 +33,7 @@ impl Default for OmpAdapter { impl OmpAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/openclaw.rs b/crates/hk-core/src/adapter/openclaw.rs index bf63b570..e9732003 100644 --- a/crates/hk-core/src/adapter/openclaw.rs +++ b/crates/hk-core/src/adapter/openclaw.rs @@ -75,7 +75,11 @@ fn user_path(home: &Path, raw: &str) -> Option { None => PathBuf::from(raw), }, }; - path.is_absolute().then_some(path) + // `is_absolute()` is false on Windows for POSIX-style absolute paths + // like `/srv/claw` (the gateway's own docs and default examples use + // that shape); treat any path with a root — including a drive-relative + // `\srv` — as resolved, so only truly relative values stay unset. + (path.is_absolute() || path.has_root()).then_some(path) } /// Agent ids are normalised before they name a directory @@ -94,7 +98,7 @@ impl OpenClawAdapter { pub fn new() -> Self { let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()); Self::from_env( - dirs::home_dir().unwrap_or_default(), + crate::home_dir(), var("OPENCLAW_STATE_DIR"), var("OPENCLAW_CONFIG_PATH"), var("OPENCLAW_WORKSPACE_DIR"), diff --git a/crates/hk-core/src/adapter/opencode.rs b/crates/hk-core/src/adapter/opencode.rs index edf9c418..2ebe94c9 100644 --- a/crates/hk-core/src/adapter/opencode.rs +++ b/crates/hk-core/src/adapter/opencode.rs @@ -18,7 +18,7 @@ impl Default for OpencodeAdapter { impl OpencodeAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/adapter/penguin.rs b/crates/hk-core/src/adapter/penguin.rs index 3bd8a0e9..f38e2d18 100644 --- a/crates/hk-core/src/adapter/penguin.rs +++ b/crates/hk-core/src/adapter/penguin.rs @@ -45,7 +45,7 @@ fn subdirs(dir: &Path) -> impl Iterator + use<> { impl PenguinAdapter { pub fn new() -> Self { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); Self { penguin_home: resolve_home(std::env::var_os("PENGUIN_HOME"), &home), } diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs index 36559d2d..019b7b0f 100644 --- a/crates/hk-core/src/adapter/pi.rs +++ b/crates/hk-core/src/adapter/pi.rs @@ -48,7 +48,7 @@ impl Default for PiAdapter { impl PiAdapter { pub fn new() -> Self { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR") .map(PathBuf::from) .unwrap_or_else(|| home.join(".pi").join("agent")); diff --git a/crates/hk-core/src/adapter/qoder_cn.rs b/crates/hk-core/src/adapter/qoder_cn.rs index cd1f9cad..64baa4fb 100644 --- a/crates/hk-core/src/adapter/qoder_cn.rs +++ b/crates/hk-core/src/adapter/qoder_cn.rs @@ -36,7 +36,7 @@ impl Default for QoderCnAdapter { impl QoderCnAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } #[cfg(test)] diff --git a/crates/hk-core/src/adapter/windsurf.rs b/crates/hk-core/src/adapter/windsurf.rs index b128f729..0cac4f63 100644 --- a/crates/hk-core/src/adapter/windsurf.rs +++ b/crates/hk-core/src/adapter/windsurf.rs @@ -36,7 +36,7 @@ impl Default for WindsurfAdapter { impl WindsurfAdapter { pub fn new() -> Self { Self { - home: dirs::home_dir().unwrap_or_default(), + home: crate::home_dir(), } } diff --git a/crates/hk-core/src/auditor/rules/cli.rs b/crates/hk-core/src/auditor/rules/cli.rs index b50d2c7d..9de512e3 100644 --- a/crates/hk-core/src/auditor/rules/cli.rs +++ b/crates/hk-core/src/auditor/rules/cli.rs @@ -22,8 +22,7 @@ impl AuditRule for CliCredentialStorage { if let Some(cred_path) = &meta.credentials_path { let expanded = if cred_path.starts_with("~/") { - dirs::home_dir() - .map(|h| h.join(&cred_path[2..]).to_string_lossy().to_string()) + Some(crate::home_dir().join(&cred_path[2..]).to_string_lossy().to_string()) .unwrap_or_else(|| cred_path.clone()) } else { cred_path.clone() diff --git a/crates/hk-core/src/kits/paths.rs b/crates/hk-core/src/kits/paths.rs index 94a1e401..2d00dfed 100644 --- a/crates/hk-core/src/kits/paths.rs +++ b/crates/hk-core/src/kits/paths.rs @@ -2,10 +2,12 @@ use crate::HkError; use std::path::PathBuf; /// Canonical directory holding all Kit zip files: `~/.harnesskit/kits/`. +/// +/// `HARNESSKIT_HOME` overrides the home root (used by tests on Windows, +/// where `crate::home_dir()` reads the shell profile folder and ignores +/// `HOME`/`USERPROFILE`, so temp-home redirection can't work). pub fn kits_dir() -> Result { - let home = dirs::home_dir() - .ok_or_else(|| HkError::Internal("home directory not found".into()))?; - Ok(home.join(".harnesskit").join("kits")) + Ok(crate::home_dir().join(".harnesskit").join("kits")) } /// Ensure `~/.harnesskit/kits/` exists; idempotent. diff --git a/crates/hk-core/src/kits/tests/service.rs b/crates/hk-core/src/kits/tests/service.rs index d840ae03..237f6f9e 100644 --- a/crates/hk-core/src/kits/tests/service.rs +++ b/crates/hk-core/src/kits/tests/service.rs @@ -28,10 +28,36 @@ impl Drop for RestoreHome { Some(v) => std::env::set_var("HOME", v), None => std::env::remove_var("HOME"), } + // Windows: `dirs::home_dir()` reads USERPROFILE, not HOME, so + // restoring the test home requires resetting both (no-op else). + #[cfg(target_os = "windows")] + match &self.0 { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + match &self.0 { + Some(v) => std::env::set_var("HARNESSKIT_HOME", v), + None => std::env::remove_var("HARNESSKIT_HOME"), + } } } } +/// Point both `HOME` and (on Windows) `USERPROFILE` at `dir` so +/// `dirs::home_dir()` resolves there on every platform. +pub(super) fn set_test_home(dir: &std::path::Path) { + // SAFETY: kit tests are `#[serial(kit_env)]`, so no concurrent env access. + unsafe { + std::env::set_var("HOME", dir); + #[cfg(target_os = "windows")] + std::env::set_var("USERPROFILE", dir); + // Kits root override: `dirs::home_dir()` can't be redirected on + // Windows (it reads the shell profile folder), so `kits_dir()` + // honors this explicit escape hatch. + std::env::set_var("HARNESSKIT_HOME", dir); + } +} + /// Create a basic single-config-file Kit named `name` for testing. /// Writes a CLAUDE.md file at `dir/CLAUDE.md` with `content`, then creates /// a Kit referencing it. Returns the created summary. @@ -70,7 +96,7 @@ fn create_kit_writes_zip_and_db_rows() { // SAFETY: this test is single-threaded at env mutation time; `#[serial(kit_env)]` // prevents concurrent mutation from other tests in this suite. let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -78,7 +104,10 @@ fn create_kit_writes_zip_and_db_rows() { assert_eq!(summary.name, "Empty-ext Test"); assert_eq!(summary.config_file_count, 1); - let zip_path = dir.path().join(".harnesskit/kits").join(format!("{}.hk-kit.zip", summary.id)); + // Kits root is `HARNESSKIT_HOME`-overridable (set by set_test_home), so + // resolve through the same helper production uses instead of guessing + // `~/.harnesskit` — which Windows can't redirect. + let zip_path = crate::kits::paths::zip_path_for(&summary.id).unwrap(); assert!(zip_path.exists(), "zip should exist at {}", zip_path.display()); let manifest = read_manifest_from_zip(&zip_path).unwrap(); @@ -95,7 +124,7 @@ fn create_kit_rejects_duplicate_name() { let dir = tempdir().unwrap(); // SAFETY: single-threaded env mutation; `#[serial(kit_env)]` prevents races. let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -127,7 +156,7 @@ fn create_kit_treats_name_collisions_case_insensitively() { // error this whole change is meant to avoid. let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -164,7 +193,7 @@ fn update_kit_allows_renaming_to_own_current_name() { // not be rejected by the duplicate-name check. let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -199,7 +228,7 @@ fn update_kit_allows_renaming_to_own_current_name() { fn list_kit_asset_candidates_filters_to_kit_able_kinds() { let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -213,7 +242,7 @@ fn list_kit_asset_candidates_filters_to_kit_able_kinds() { fn delete_kit_cascades_assets_and_stack_but_keeps_sync_records() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -246,7 +275,7 @@ fn delete_kit_cascades_assets_and_stack_but_keeps_sync_records() { fn preview_reports_conflict_when_target_exists() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -275,7 +304,7 @@ fn preview_reports_conflict_when_target_exists() { fn sync_extracts_files_and_writes_sync_record() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -315,7 +344,7 @@ fn sync_extracts_files_and_writes_sync_record() { fn sync_skips_conflict_unless_forced() { let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -381,7 +410,7 @@ fn sync_skips_conflict_unless_forced() { fn unsync_removes_files_and_record_idempotent_on_missing() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -426,7 +455,7 @@ fn unsync_removes_files_and_record_idempotent_on_missing() { fn export_is_byte_identical_copy() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let summary = make_basic_kit(&store, &adapters, dir.path(), "Export", "x"); @@ -443,7 +472,7 @@ fn export_is_byte_identical_copy() { fn import_generates_new_id_and_dedupes_name() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let original = make_basic_kit(&store, &adapters, dir.path(), "Shared", "x"); @@ -464,7 +493,7 @@ fn import_generates_new_id_and_dedupes_name() { fn import_rejects_path_traversal_in_manifest() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); // Build a hand-crafted bad zip with a single entry "../escape" @@ -501,7 +530,7 @@ fn list_kits_kind_counts_fall_back_to_manifest_for_cross_machine_imports() { // even though the kit clearly has extensions. let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); // Hand-craft a manifest with two extensions (a skill + an MCP) whose @@ -579,7 +608,7 @@ fn list_kits_kind_counts_fall_back_to_manifest_for_cross_machine_imports() { fn import_rejects_future_kit_format_version() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); // Build a zip with manifest claiming kit_format_version = 99 @@ -609,7 +638,7 @@ fn import_rejects_future_kit_format_version() { fn update_kit_changes_name_preserves_created_at() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let src = dir.path().join("CLAUDE.md"); @@ -723,7 +752,7 @@ fn make_mcp_kit( fn sync_mcp_merges_into_existing_mcp_json() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -801,7 +830,7 @@ fn sync_mcp_merges_into_existing_mcp_json() { fn sync_mcp_writes_wrapped_format_on_empty_project() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -854,7 +883,7 @@ fn sync_mcp_writes_wrapped_format_on_empty_project() { fn unsync_mcp_removes_only_named_entry() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -942,7 +971,7 @@ fn unsync_mcp_removes_only_named_entry() { fn preview_mcp_conflict_only_when_name_exists() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -1017,7 +1046,7 @@ fn create_kit_rejects_hook_extension() { // must cause sync_kit_to_project to return the Hook-unsupported error. let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -1155,7 +1184,7 @@ fn create_kit_packs_standalone_md_skill_as_folder_with_skill_md() { // proper folder skill the receiving agent's scanner can discover. let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); // Real .md skill on disk under claude's global skill dir. @@ -1201,10 +1230,7 @@ fn create_kit_packs_standalone_md_skill_as_folder_with_skill_md() { ) .expect("standalone .md skill must pack without 'Not a directory' failure"); - let zip_path = dir - .path() - .join(".harnesskit/kits") - .join(format!("{}.hk-kit.zip", summary.id)); + let zip_path = crate::kits::paths::zip_path_for(&summary.id).unwrap(); let manifest = read_manifest_from_zip(&zip_path).unwrap(); assert_eq!(manifest.extensions.len(), 1); // The zip must contain a SKILL.md entry under the extension's asset @@ -1234,7 +1260,7 @@ fn list_kit_asset_candidates_excludes_rows_with_missing_source_on_disk() { // it does NOT appear in candidates. let dir = tempdir().unwrap(); let orig_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -1362,7 +1388,7 @@ fn sync_merges_same_target_config_sources_with_blank_line_separator() { // second source silently overwriting the first. let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -1541,7 +1567,7 @@ fn make_skill_kit_with_url( fn sync_skill_writes_install_meta_url_from_manifest() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -1602,7 +1628,7 @@ fn sync_skill_writes_install_meta_url_from_manifest() { fn unsync_clears_install_meta_on_kit_installed_skills() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -1681,7 +1707,7 @@ fn unsync_clears_install_meta_on_kit_installed_skills() { fn sync_skill_leaves_install_meta_null_when_manifest_url_is_none() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); diff --git a/crates/hk-core/src/lib.rs b/crates/hk-core/src/lib.rs index 47d71453..e8e05e5f 100644 --- a/crates/hk-core/src/lib.rs +++ b/crates/hk-core/src/lib.rs @@ -16,3 +16,15 @@ pub mod store; pub use error::HkError; pub use process::command; + +/// Process-wide home directory used by every adapter and path helper. +/// +/// `HARNESSKIT_HOME` wins when set (test isolation on Windows, where +/// `dirs::home_dir()` reads the shell profile folder and cannot be +/// redirected); otherwise it is `dirs::home_dir()`. +pub fn home_dir() -> std::path::PathBuf { + if let Some(root) = std::env::var_os("HARNESSKIT_HOME").filter(|v| !v.is_empty()) { + return std::path::PathBuf::from(root); + } + dirs::home_dir().unwrap_or_default() +} \ No newline at end of file diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 04cc29e6..d906dfd7 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -592,8 +592,7 @@ fn toggle_plugin( store.set_disabled_config(&ext.id, None)?; } else if a.name() == "gemini" { let extensions_dir = a.base_dir().join("extensions"); - let home = dirs::home_dir() - .ok_or_else(|| HkError::Internal("Cannot determine home directory".into()))?; + let home = crate::home_dir(); deployer::set_gemini_extension_enabled(&extensions_dir, &ext.name, enabled, &home)?; store.set_disabled_config(&ext.id, None)?; } else if a.name() == "hermes" { diff --git a/crates/hk-core/src/sanitize.rs b/crates/hk-core/src/sanitize.rs index 7e04eca3..d846616c 100644 --- a/crates/hk-core/src/sanitize.rs +++ b/crates/hk-core/src/sanitize.rs @@ -124,12 +124,10 @@ pub fn is_windows_abs_path(s: &str) -> bool { /// - `Validation`: path does not exist, or equals home dir itself pub fn resolve_and_validate_config_path(path: &str) -> Result { let resolved = if path == "~" { - dirs::home_dir() - .map(|h| h.to_string_lossy().to_string()) + Some(crate::home_dir().to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()) } else if let Some(rest) = path.strip_prefix("~/") { - dirs::home_dir() - .map(|h| h.join(rest).to_string_lossy().to_string()) + Some(crate::home_dir().join(rest).to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()) } else { path.to_string() @@ -148,7 +146,7 @@ pub fn resolve_and_validate_config_path(path: &str) -> Result { // Compare canonical paths so trailing slashes, symlinks, and other // surface variants (e.g. `/Users/zoe/` vs `/Users/zoe`, or a symlink // pointing to home) all reject as "home itself". - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); if let (Ok(rp), Ok(hp)) = ( std::fs::canonicalize(resolved_path), std::fs::canonicalize(&home), @@ -325,10 +323,12 @@ mod tests { /// should be caught by the canonicalize-based comparison. #[test] fn config_path_rejects_home_with_trailing_slash() { - let home = match dirs::home_dir() { - Some(h) => h, - None => return, // skip on environments without a home dir - }; + // (Test) `crate::home_dir()` always returns a PathBuf; an empty one + // only happens in environments without any discoverable home. + let home = crate::home_dir(); + if home.as_os_str().is_empty() { + return; + } let with_trailing = format!("{}/", home.to_string_lossy()); let result = resolve_and_validate_config_path(&with_trailing); match result { diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 85195403..0f15f926 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -685,7 +685,7 @@ fn which_binary(name: &str) -> Option { return Some(path); } // Fallback: search common user-level bin directories - let home = dirs::home_dir()?; + let home = crate::home_dir(); let mut extra_dirs = vec![ home.join(".local/bin"), home.join(".cargo/bin"), @@ -771,7 +771,7 @@ fn detect_install_method(path: &str) -> Option { /// Expand `~` and `~/.config/` in credential path templates to platform-appropriate dirs. fn resolve_credential_path(template: &str) -> String { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::home_dir(); // On Windows, ~/.config/X → %APPDATA%/X (dirs::config_dir()) #[cfg(target_os = "windows")] @@ -3467,6 +3467,7 @@ mod config_tests { /// recursive rules loading: Claude (global + project), Copilot (global /// + project), Cursor (project, .mdc only). One fixture tree, one scan /// per adapter, flat files double as the no-regression check. + #[test] fn test_scan_agent_configs_nested_rules() { use crate::adapter::copilot::CopilotAdapter; @@ -3507,12 +3508,34 @@ mod config_tests { std::os::unix::fs::symlink(&rules, rules.join("loop")).unwrap(); } let claude = rules_of(&ClaudeAdapter::with_home(home.to_path_buf())); - assert!(claude.iter().any(|p| p.ends_with("rules/flat.md"))); - assert!(claude.iter().any(|p| p.ends_with("frontend/react.md"))); - assert!(claude.iter().any(|p| p.ends_with("rules/style.md"))); - assert!(claude.iter().any(|p| p.ends_with("backend/api.md"))); + // Path::ends_with compares whole components, so these work with both + // `/` (Unix) and `\` (Windows) separators. + assert!(claude + .iter() + .any(|p| std::path::Path::new(p).ends_with(std::path::Path::new( + "rules/flat.md" + )))); + assert!(claude + .iter() + .any(|p| std::path::Path::new(p).ends_with(std::path::Path::new( + "frontend/react.md" + )))); + assert!(claude + .iter() + .any(|p| std::path::Path::new(p).ends_with(std::path::Path::new( + "rules/style.md" + )))); + assert!(claude + .iter() + .any(|p| std::path::Path::new(p).ends_with(std::path::Path::new( + "backend/api.md" + )))); #[cfg(unix)] - assert!(claude.iter().any(|p| p.ends_with("linked/shared.md"))); + assert!(claude + .iter() + .any(|p| std::path::Path::new(p).ends_with(std::path::Path::new( + "linked/shared.md" + )))); // Copilot: global ~/.copilot/instructions and project // .github/instructions are both searched recursively; only @@ -3522,12 +3545,12 @@ mod config_tests { write(project.join(".github/instructions/general.instructions.md")); write(project.join(".github/instructions/frontend/react.instructions.md")); let copilot = rules_of(&CopilotAdapter::with_home(home.to_path_buf())); - assert!(copilot.iter().any(|p| p.ends_with("style/tone.instructions.md"))); - assert!(copilot.iter().any(|p| p.ends_with("general.instructions.md"))); + assert!(copilot.iter().any(|p| std::path::Path::new(p).ends_with("style/tone.instructions.md"))); + assert!(copilot.iter().any(|p| std::path::Path::new(p).ends_with("general.instructions.md"))); assert!(copilot .iter() - .any(|p| p.ends_with("frontend/react.instructions.md"))); - assert!(!copilot.iter().any(|p| p.ends_with("notes.md"))); + .any(|p| std::path::Path::new(p).ends_with("frontend/react.instructions.md"))); + assert!(!copilot.iter().any(|p| std::path::Path::new(p).ends_with("notes.md"))); // Cursor: nested .mdc rules are loaded; .md files anywhere in // .cursor/rules are ignored by Cursor and must stay invisible. @@ -3535,9 +3558,9 @@ mod config_tests { write(project.join(".cursor/rules/frontend/comp.mdc")); write(project.join(".cursor/rules/ignored.md")); let cursor = rules_of(&CursorAdapter::with_home(home.to_path_buf())); - assert!(cursor.iter().any(|p| p.ends_with("rules/top.mdc"))); - assert!(cursor.iter().any(|p| p.ends_with("frontend/comp.mdc"))); - assert!(!cursor.iter().any(|p| p.ends_with("ignored.md"))); + assert!(cursor.iter().any(|p| std::path::Path::new(p).ends_with("rules/top.mdc"))); + assert!(cursor.iter().any(|p| std::path::Path::new(p).ends_with("frontend/comp.mdc"))); + assert!(!cursor.iter().any(|p| std::path::Path::new(p).ends_with("ignored.md"))); // Windsurf and Antigravity: nested rules load too (binary-verified). // Flat-match regression for `**` is already covered above; only the @@ -3547,9 +3570,9 @@ mod config_tests { write(project.join(".windsurf/rules/frontend/ws-deep.md")); write(project.join(".agents/rules/frontend/ag-deep.md")); let windsurf = rules_of(&WindsurfAdapter::with_home(home.to_path_buf())); - assert!(windsurf.iter().any(|p| p.ends_with("frontend/ws-deep.md"))); + assert!(windsurf.iter().any(|p| std::path::Path::new(p).ends_with("frontend/ws-deep.md"))); let anti = rules_of(&AntigravityAdapter::with_home(home.to_path_buf())); - assert!(anti.iter().any(|p| p.ends_with("frontend/ag-deep.md"))); + assert!(anti.iter().any(|p| std::path::Path::new(p).ends_with("frontend/ag-deep.md"))); } /// One adapter naming a file under two spellings must collapse to one diff --git a/crates/hk-core/src/skills_cli.rs b/crates/hk-core/src/skills_cli.rs index 554d28d1..89ef588f 100644 --- a/crates/hk-core/src/skills_cli.rs +++ b/crates/hk-core/src/skills_cli.rs @@ -113,7 +113,13 @@ mod tests { #[test] fn run_with_falls_through_missing_launcher_to_present_one() { // First launcher missing, second present and exits 0 → Ok(true). + #[cfg(unix)] let launchers: [(&str, &[&str]); 2] = [("hk-no-such-binary-xyz", &[]), ("true", &[])]; + #[cfg(windows)] + let launchers: [(&str, &[&str]); 2] = [ + ("hk-no-such-binary-xyz", &[]), + ("cmd", &["/c", "exit", "0"]), + ]; assert!(run_with(&launchers, &[]).unwrap()); } @@ -121,7 +127,13 @@ mod tests { fn run_with_nonzero_exit_is_error_not_fallthrough() { // A launcher that runs but exits non-zero surfaces as Err — it must NOT // fall through to the next launcher (don't mask a real failure). + #[cfg(unix)] let launchers: [(&str, &[&str]); 2] = [("false", &[]), ("true", &[])]; + #[cfg(windows)] + let launchers: [(&str, &[&str]); 2] = [ + ("cmd", &["/c", "exit", "1"]), + ("cmd", &["/c", "exit", "0"]), + ]; assert!(run_with(&launchers, &[]).is_err()); } } diff --git a/crates/hk-web/src/handlers/mod.rs b/crates/hk-web/src/handlers/mod.rs index e7346c87..06aa063b 100644 --- a/crates/hk-web/src/handlers/mod.rs +++ b/crates/hk-web/src/handlers/mod.rs @@ -48,9 +48,11 @@ pub(crate) fn is_path_allowed(path: &Path, store: &Store) -> bool { .unwrap_or(false) }; - if let Some(home) = dirs::home_dir() - && under(&home) - { + // Same home resolution as hk-core (`HARNESSKIT_HOME` override for test + // isolation; plain home dir otherwise). Only treat a non-empty home as + // an allowed root. + let home = hk_core::home_dir(); + if !home.as_os_str().is_empty() && under(&home) { return true; } if let Ok(projects) = store.list_projects() { diff --git a/crates/hk-web/tests/api_test.rs b/crates/hk-web/tests/api_test.rs index 6149f4be..62b7d6d3 100644 --- a/crates/hk-web/tests/api_test.rs +++ b/crates/hk-web/tests/api_test.rs @@ -311,6 +311,13 @@ async fn list_skill_files_rejects_file_outside_allowed_roots() { let (state, tmp) = test_state(); // No project registered for this fixture dir, and temp dirs are outside // the home directory, so the path is not allowed. + // On Windows the temp dir lives UNDER the user profile, so the plain + // home-prefix check would accept it. Point the allowlist's home at an + // unrelated directory via the hk-core home override. + let fake_home = tempfile::tempdir().unwrap(); + // SAFETY: single-threaded tokio test; the env var is only read inside + // this request handler. + unsafe { std::env::set_var("HARNESSKIT_HOME", fake_home.path()) }; let file_path = tmp.path().join("stray-plugin.ts"); std::fs::write(&file_path, "// plugin\n").unwrap(); From 79a7b4c14c3e4f3600b45a8567a1c9ca39b5adfe Mon Sep 17 00:00:00 2001 From: XiaoLrrr Date: Mon, 19 Oct 2026 10:05:32 +0800 Subject: [PATCH 04/15] fix(windows): route remaining git spawns and data dir through hk_core helpers - hk-web install handlers: use hk_core::command(git) (CREATE_NO_WINDOW) for clone/probe - hk-desktop/hk-web/hk-cli: resolve ~/.harnesskit via hk_core::home_dir() - kits test: compare against crate::home_dir() for HARNESSKIT_HOME isolation --- crates/hk-cli/src/main.rs | 2 +- crates/hk-core/src/kits/tests/paths.rs | 2 +- crates/hk-desktop/src/main.rs | 4 +--- crates/hk-web/src/handlers/install.rs | 8 ++++---- crates/hk-web/src/lib.rs | 4 +--- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/hk-cli/src/main.rs b/crates/hk-cli/src/main.rs index b1dc7071..ea6924ef 100644 --- a/crates/hk-cli/src/main.rs +++ b/crates/hk-cli/src/main.rs @@ -193,7 +193,7 @@ fn main() -> Result<()> { } fn hk_data_dir() -> PathBuf { - dirs::home_dir().unwrap_or_default().join(".harnesskit") + hk_core::home_dir().join(".harnesskit") } /// Resolve the web access token for `hk serve`. diff --git a/crates/hk-core/src/kits/tests/paths.rs b/crates/hk-core/src/kits/tests/paths.rs index 27f0a44d..c42ea0b1 100644 --- a/crates/hk-core/src/kits/tests/paths.rs +++ b/crates/hk-core/src/kits/tests/paths.rs @@ -9,7 +9,7 @@ fn zip_path_for_uses_hk_kit_zip_suffix() { #[test] fn kits_dir_is_under_home() { let dir = kits_dir().unwrap(); - let home = dirs::home_dir().unwrap(); + let home = crate::home_dir(); assert!(dir.starts_with(home)); assert!(dir.ends_with(".harnesskit/kits") || dir.ends_with(r".harnesskit\kits")); } diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 3a2ce6c5..51fb4adb 100644 --- a/crates/hk-desktop/src/main.rs +++ b/crates/hk-desktop/src/main.rs @@ -10,9 +10,7 @@ use std::sync::Arc; use tauri::Manager; fn main() { - let data_dir = dirs::home_dir() - .expect("Cannot determine home directory — set HOME environment variable") - .join(".harnesskit"); + let data_dir = hk_core::home_dir().join(".harnesskit"); std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); let store = Store::open(&data_dir.join("metadata.db")).expect("Failed to open database"); diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index 81c4afe6..c53ba8a3 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -351,7 +351,7 @@ pub async fn update_extension( // Clone the repo once let temp = tempfile::tempdir().map_err(|e| hk_core::HkError::Internal(e.to_string()))?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args(["clone", "--depth", "1", "--", url, &clone_dir.to_string_lossy()]) .output() .map_err(|e| hk_core::HkError::CommandFailed(format!("Failed to run git clone: {}", e)))?; @@ -462,7 +462,7 @@ pub async fn scan_git_repo( blocking(move || { let temp = tempfile::tempdir()?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args(["clone", "--depth", "1", "--", ¶ms.url, &clone_dir.to_string_lossy()]) .output() .map_err(|e| hk_core::HkError::CommandFailed(format!("Failed to run git clone: {}", e)))?; @@ -620,7 +620,7 @@ pub async fn install_new_repo_skills( let temp = tempfile::tempdir() .map_err(|e| hk_core::HkError::Internal(format!("Failed to create temp directory: {e}")))?; let clone_dir = temp.path().join("repo"); - let output = std::process::Command::new("git") + let output = hk_core::command("git") .args(["clone", "--depth", "1", "--", ¶ms.url, &clone_dir.to_string_lossy()]) .output() .map_err(|e| hk_core::HkError::CommandFailed(format!("Failed to run git clone: {e}")))?; @@ -794,7 +794,7 @@ pub async fn check_updates( for (url, indices) in &url_to_indices { let temp = match tempfile::tempdir() { Ok(t) => t, Err(_) => continue }; let clone_path = temp.path().join("repo"); - let ok = std::process::Command::new("git") + let ok = hk_core::command("git") .args(["clone", "--depth", "1", "--", url, &clone_path.to_string_lossy()]) .output().map(|o| o.status.success()).unwrap_or(false); if !ok { continue; } diff --git a/crates/hk-web/src/lib.rs b/crates/hk-web/src/lib.rs index e9ef307e..37cf9089 100644 --- a/crates/hk-web/src/lib.rs +++ b/crates/hk-web/src/lib.rs @@ -47,9 +47,7 @@ fn reachable_ipv4_addrs() -> Vec { } pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { - let data_dir = dirs::home_dir() - .expect("Cannot determine home directory") - .join(".harnesskit"); + let data_dir = hk_core::home_dir().join(".harnesskit"); std::fs::create_dir_all(&data_dir)?; let store = Store::open(&data_dir.join("metadata.db"))?; From e3ad10e17125adb19e457a2522da4182a7d90751 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:35:21 +0800 Subject: [PATCH 05/15] chore: drop unused @tauri-apps/cli devDependency The repository drives Tauri through `cargo tauri` (CONTRIBUTING.md and release.yml both `cargo install tauri-cli`), so the npm CLI added with the Windows window config is never invoked. Restore package.json and package-lock.json to their state on main. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 261 ++++------------------------------------------ package.json | 1 - 2 files changed, 18 insertions(+), 244 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7abb8ee9..19021219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", - "@tauri-apps/cli": "^2.12.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -149,6 +148,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -672,6 +672,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -720,6 +721,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -741,6 +743,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2077,238 +2080,6 @@ "url": "https://opencollective.com/tauri" } }, - "node_modules/@tauri-apps/cli": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz", - "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.12.2", - "@tauri-apps/cli-darwin-x64": "2.12.2", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2", - "@tauri-apps/cli-linux-arm64-gnu": "2.12.2", - "@tauri-apps/cli-linux-arm64-musl": "2.12.2", - "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2", - "@tauri-apps/cli-linux-x64-gnu": "2.12.2", - "@tauri-apps/cli-linux-x64-musl": "2.12.2", - "@tauri-apps/cli-win32-arm64-msvc": "2.12.2", - "@tauri-apps/cli-win32-ia32-msvc": "2.12.2", - "@tauri-apps/cli-win32-x64-msvc": "2.12.2" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz", - "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz", - "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz", - "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz", - "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz", - "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz", - "integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz", - "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz", - "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz", - "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz", - "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz", - "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -2446,8 +2217,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2575,6 +2345,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2585,6 +2356,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2983,6 +2755,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3343,15 +3116,15 @@ "version": "0.0.1595872", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.331", @@ -3815,6 +3588,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -4356,7 +4130,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5175,6 +4948,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5217,7 +4991,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5233,7 +5006,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -5358,6 +5130,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5367,6 +5140,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5406,8 +5180,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-markdown": { "version": "10.1.0", @@ -6016,6 +5789,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6202,6 +5976,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 74ec4143..77817581 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", - "@tauri-apps/cli": "^2.12.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", From c18ba31560d5a21f48f7230bba8cfab579459d2c Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:38:20 +0800 Subject: [PATCH 06/15] refactor(process): hide console windows only when the desktop opts in `CREATE_NO_WINDOW` gives the child no console at all (Win32 docs: "the console handle for the application is not set"), so under the `hk` CLI or `hk serve` a child that needs the terminal, such as `git` prompting for credentials or `ssh` confirming a host key, could no longer prompt and would fail. Those console programs never flashed a window in the first place: their children inherit the existing console. Keep the shared `hk_core::command()` entry point but make the flag an opt-in (`hk_core::process::hide_console_windows()`) that the Tauri desktop app, the one GUI process, calls at startup. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/process.rs | 28 +++++++++++++++++++++++----- crates/hk-desktop/src/main.rs | 4 +++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/hk-core/src/process.rs b/crates/hk-core/src/process.rs index a97fab54..c22032a4 100644 --- a/crates/hk-core/src/process.rs +++ b/crates/hk-core/src/process.rs @@ -1,17 +1,35 @@ //! Subprocess helpers. //! -//! On Windows every spawned process is created with `CREATE_NO_WINDOW` so the -//! Tauri desktop build (a GUI process with no console) never flashes a console -//! window when probing binaries (`where`, `git`, `npm`, …). +//! Every subprocess HarnessKit spawns (`git`, `where`, `npm`, agent +//! uninstallers, …) goes through [`command`]. On Windows a GUI process such as +//! the Tauri desktop app has no console of its own, so each console child would +//! otherwise flash a conhost window; the desktop opts into +//! [`hide_console_windows`] at startup to suppress that. use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; -/// Create a `Command` that never pops a console window on Windows. +static HIDE_CONSOLE_WINDOWS: AtomicBool = AtomicBool::new(false); + +/// Spawn every subsequent [`command`] with `CREATE_NO_WINDOW` on Windows. +/// +/// Only a process without a console should call this: the desktop app, +/// which is console-less in release builds (`windows_subsystem = "windows"`). Console programs (`hk`, +/// `hk serve`) must keep the default: `CREATE_NO_WINDOW` gives the child no +/// console at all, so a child that needs the terminal — `git` asking for +/// credentials, `ssh` confirming a host key — cannot prompt and fails. /// No-op on non-Windows platforms. +pub fn hide_console_windows() { + HIDE_CONSOLE_WINDOWS.store(true, Ordering::Relaxed); +} + +/// Create a `Command`, hiding the console window on Windows when +/// [`hide_console_windows`] was called. No-op on non-Windows platforms. pub fn command(program: impl AsRef) -> Command { + #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] let mut cmd = Command::new(program); #[cfg(target_os = "windows")] - { + if HIDE_CONSOLE_WINDOWS.load(Ordering::Relaxed) { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; cmd.creation_flags(CREATE_NO_WINDOW); diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 51fb4adb..9193936d 100644 --- a/crates/hk-desktop/src/main.rs +++ b/crates/hk-desktop/src/main.rs @@ -10,11 +10,13 @@ use std::sync::Arc; use tauri::Manager; fn main() { + // GUI process: console children must not flash a conhost window. + hk_core::process::hide_console_windows(); + let data_dir = hk_core::home_dir().join(".harnesskit"); std::fs::create_dir_all(&data_dir).expect("Failed to create data dir"); let store = Store::open(&data_dir.join("metadata.db")).expect("Failed to open database"); - // NOTE: tauri.conf.json sets `macOSPrivateApi: true`. This is required for: // 1. Window transparency (`"transparent": true` in window config) // 2. Sidebar vibrancy effect (`"windowEffects": {"effects": ["sidebar"]}`) From 1f249be903046e1fb21353f4a7ccf16e262a96ac Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:39:27 +0800 Subject: [PATCH 07/15] test(kits): restore HARNESSKIT_HOME after each test and use it end to end The guard that undoes the test home put the *original HOME* back into `HARNESSKIT_HOME` (and `USERPROFILE`) instead of each variable's own previous value, so after the first kit test the override stayed set for the rest of the process. The two end_to_end tests still set only `HOME`, which `dirs::home_dir()` ignores on Windows, so they wrote kit zips into the real `~/.harnesskit/kits/` there. `RestoreHome` now records and restores `HOME` and `HARNESSKIT_HOME` separately, every kit test goes through `set_test_home`, and the `USERPROFILE` handling is dropped: dirs-sys resolves the Windows home via `SHGetKnownFolderPath(FOLDERID_Profile)` and never reads that variable. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/kits/paths.rs | 5 +- crates/hk-core/src/kits/tests/end_to_end.rs | 6 +- crates/hk-core/src/kits/tests/service.rs | 80 +++++++++------------ 3 files changed, 40 insertions(+), 51 deletions(-) diff --git a/crates/hk-core/src/kits/paths.rs b/crates/hk-core/src/kits/paths.rs index 2d00dfed..0bad7daf 100644 --- a/crates/hk-core/src/kits/paths.rs +++ b/crates/hk-core/src/kits/paths.rs @@ -3,9 +3,8 @@ use std::path::PathBuf; /// Canonical directory holding all Kit zip files: `~/.harnesskit/kits/`. /// -/// `HARNESSKIT_HOME` overrides the home root (used by tests on Windows, -/// where `crate::home_dir()` reads the shell profile folder and ignores -/// `HOME`/`USERPROFILE`, so temp-home redirection can't work). +/// The home root is `crate::home_dir()`, which tests redirect through +/// `HARNESSKIT_HOME` (the only override that works on Windows). pub fn kits_dir() -> Result { Ok(crate::home_dir().join(".harnesskit").join("kits")) } diff --git a/crates/hk-core/src/kits/tests/end_to_end.rs b/crates/hk-core/src/kits/tests/end_to_end.rs index fb5de0b3..38e0fc48 100644 --- a/crates/hk-core/src/kits/tests/end_to_end.rs +++ b/crates/hk-core/src/kits/tests/end_to_end.rs @@ -1,6 +1,6 @@ use crate::adapter::all_adapters; use crate::kits::service::{create_kit, export_kit, import_kit, sync_kit_to_project, unsync_kit_from_project}; -use crate::kits::tests::service::RestoreHome; +use crate::kits::tests::service::{set_test_home, RestoreHome}; use crate::kits::types::{ CreateKitRequest, KitConfigFileRef, SyncKitRequest, UnsyncKitRequest, }; @@ -20,7 +20,7 @@ fn open(dir: &std::path::Path) -> Mutex { fn full_lifecycle_create_install_uninstall() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = open(dir.path()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -74,7 +74,7 @@ fn full_lifecycle_create_install_uninstall() { fn export_import_round_trip_preserves_manifest_bytes() { let dir = tempdir().unwrap(); let _restore = RestoreHome::new(); - unsafe { std::env::set_var("HOME", dir.path()); } + set_test_home(dir.path()); let store = open(dir.path()); let adapters = all_adapters(); let src = dir.path().join("CLAUDE.md"); diff --git a/crates/hk-core/src/kits/tests/service.rs b/crates/hk-core/src/kits/tests/service.rs index 237f6f9e..41b7e0cb 100644 --- a/crates/hk-core/src/kits/tests/service.rs +++ b/crates/hk-core/src/kits/tests/service.rs @@ -11,49 +11,49 @@ use serial_test::serial; use std::fs; use tempfile::tempdir; -/// RAII guard that restores `HOME` on drop, so env pollution from one test -/// cannot affect later tests that run on other threads. -pub(super) struct RestoreHome(Option); +/// RAII guard that restores the test home (`HOME` and the `HARNESSKIT_HOME` +/// override) on drop, so env pollution from one test cannot affect later +/// tests that run on other threads. +pub(super) struct RestoreHome { + home: Option, + hk_home: Option, +} impl RestoreHome { pub(super) fn new() -> Self { - Self(std::env::var_os("HOME")) + Self { + home: std::env::var_os("HOME"), + hk_home: std::env::var_os("HARNESSKIT_HOME"), + } } } impl Drop for RestoreHome { fn drop(&mut self) { - // SAFETY: called from the same thread that set HOME; `#[serial(kit_env)]` + // SAFETY: called from the same thread that set the vars; `#[serial(kit_env)]` // ensures no other kit_env test runs concurrently at this point. unsafe { - match &self.0 { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - // Windows: `dirs::home_dir()` reads USERPROFILE, not HOME, so - // restoring the test home requires resetting both (no-op else). - #[cfg(target_os = "windows")] - match &self.0 { - Some(v) => std::env::set_var("USERPROFILE", v), - None => std::env::remove_var("USERPROFILE"), - } - match &self.0 { - Some(v) => std::env::set_var("HARNESSKIT_HOME", v), - None => std::env::remove_var("HARNESSKIT_HOME"), - } + restore_var("HOME", &self.home); + restore_var("HARNESSKIT_HOME", &self.hk_home); + } + } +} + +unsafe fn restore_var(name: &str, value: &Option) { + unsafe { + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), } } } -/// Point both `HOME` and (on Windows) `USERPROFILE` at `dir` so -/// `dirs::home_dir()` resolves there on every platform. +/// Point the test home at `dir`. `HOME` covers anything that still reads it +/// directly; `HARNESSKIT_HOME` is what `crate::home_dir()` honors, and the +/// only redirect that works on Windows, where `dirs::home_dir()` asks the +/// shell for the profile folder and ignores `HOME`. pub(super) fn set_test_home(dir: &std::path::Path) { // SAFETY: kit tests are `#[serial(kit_env)]`, so no concurrent env access. unsafe { std::env::set_var("HOME", dir); - #[cfg(target_os = "windows")] - std::env::set_var("USERPROFILE", dir); - // Kits root override: `dirs::home_dir()` can't be redirected on - // Windows (it reads the shell profile folder), so `kits_dir()` - // honors this explicit escape hatch. std::env::set_var("HARNESSKIT_HOME", dir); } } @@ -92,9 +92,7 @@ fn make_basic_kit( #[serial(kit_env)] fn create_kit_writes_zip_and_db_rows() { let dir = tempdir().unwrap(); - // Redirect HOME so kits land in our tempdir (avoid polluting real home). - // SAFETY: this test is single-threaded at env mutation time; `#[serial(kit_env)]` - // prevents concurrent mutation from other tests in this suite. + // Redirect the home so kits land in our tempdir (avoid polluting real home). let _restore = RestoreHome::new(); set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); @@ -122,10 +120,8 @@ fn create_kit_writes_zip_and_db_rows() { #[serial(kit_env)] fn create_kit_rejects_duplicate_name() { let dir = tempdir().unwrap(); - // SAFETY: single-threaded env mutation; `#[serial(kit_env)]` prevents races. - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -155,9 +151,8 @@ fn create_kit_treats_name_collisions_case_insensitively() { // pre-flight check and then trip the DB constraint with the gibberish // error this whole change is meant to avoid. let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -192,9 +187,8 @@ fn update_kit_allows_renaming_to_own_current_name() { // A no-op rename (saving the editor without changing the name) must // not be rejected by the duplicate-name check. let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -227,9 +221,8 @@ fn update_kit_allows_renaming_to_own_current_name() { #[serial(kit_env)] fn list_kit_asset_candidates_filters_to_kit_able_kinds() { let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let candidates = list_kit_asset_candidates(&store, &adapters).unwrap(); @@ -343,9 +336,8 @@ fn sync_extracts_files_and_writes_sync_record() { #[serial(kit_env)] fn sync_skips_conflict_unless_forced() { let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); let project = dir.path().join("proj"); @@ -1183,9 +1175,8 @@ fn create_kit_packs_standalone_md_skill_as_folder_with_skill_md() { // `SKILL.md` inside the zip so install-side recreates it as a // proper folder skill the receiving agent's scanner can discover. let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); // Real .md skill on disk under claude's global skill dir. let skill_path = dir @@ -1259,9 +1250,8 @@ fn list_kit_asset_candidates_excludes_rows_with_missing_source_on_disk() { // source path doesn't exist anywhere under the tempdir HOME, then asserts // it does NOT appear in candidates. let dir = tempdir().unwrap(); - let orig_home = std::env::var_os("HOME"); + let _restore = RestoreHome::new(); set_test_home(dir.path()); - let _restore = RestoreHome(orig_home); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); From fa5bec669f929e94dd8676c773fa68b0f7563db2 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:40:38 +0800 Subject: [PATCH 08/15] test(web): inject the allowlist home into WebState instead of an env var `list_skill_files_rejects_file_outside_allowed_roots` set `HARNESSKIT_HOME` from inside a test binary whose other tests run on parallel threads and construct adapters (which read that variable), and never unset it. Rust 2024 marks `set_var` unsafe for exactly this reason. Carry the home directory in `WebState` (`hk_core::home_dir()` in `serve`, a fixture sibling in tests) and pass it to `is_path_allowed`. The test keeps its Windows coverage, where the OS temp dir sits under the user profile, without touching the process environment. Co-Authored-By: Claude Fable 5.1 --- crates/hk-web/src/handlers/extensions.rs | 2 +- crates/hk-web/src/handlers/mod.rs | 43 ++++++++++++++---------- crates/hk-web/src/lib.rs | 4 ++- crates/hk-web/src/state.rs | 5 +++ crates/hk-web/tests/api_test.rs | 17 +++++----- 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/crates/hk-web/src/handlers/extensions.rs b/crates/hk-web/src/handlers/extensions.rs index ff47c594..9a92ef93 100644 --- a/crates/hk-web/src/handlers/extensions.rs +++ b/crates/hk-web/src/handlers/extensions.rs @@ -179,7 +179,7 @@ pub async fn list_skill_files( }); if !allowed { let store = state.store.lock(); - if !super::is_path_allowed(&canonical, &store) { + if !super::is_path_allowed(&canonical, &store, &state.home) { return Err(hk_core::HkError::PathNotAllowed( "Path is not within a known agent directory".into(), )); diff --git a/crates/hk-web/src/handlers/mod.rs b/crates/hk-web/src/handlers/mod.rs index 06aa063b..6c3af435 100644 --- a/crates/hk-web/src/handlers/mod.rs +++ b/crates/hk-web/src/handlers/mod.rs @@ -29,14 +29,13 @@ fn normalize(p: &Path) -> std::path::PathBuf { p.to_path_buf() } -/// Check if a path is within the home directory or any registered project path. +/// Check if a path is within `home` or any registered project path. /// /// Both the input path and each candidate root are canonicalized before /// comparison so that environments where the home directory is a symlink /// (e.g. Lumi's `/users/` → `/pfs/lustrep*/users/`) match -/// correctly. Non-existent paths fail canonicalization and are rejected, -/// matching the desktop's `is_path_within_allowed_dirs` behavior. -pub(crate) fn is_path_allowed(path: &Path, store: &Store) -> bool { +/// correctly. Non-existent paths fail canonicalization and are rejected. +pub(crate) fn is_path_allowed(path: &Path, store: &Store, home: &Path) -> bool { let Ok(canonical) = std::fs::canonicalize(path) else { return false; }; @@ -48,11 +47,9 @@ pub(crate) fn is_path_allowed(path: &Path, store: &Store) -> bool { .unwrap_or(false) }; - // Same home resolution as hk-core (`HARNESSKIT_HOME` override for test - // isolation; plain home dir otherwise). Only treat a non-empty home as - // an allowed root. - let home = hk_core::home_dir(); - if !home.as_os_str().is_empty() && under(&home) { + // `hk_core::home_dir()` yields an empty path when no home is known; + // only a non-empty home is an allowed root. + if !home.as_os_str().is_empty() && under(home) { return true; } if let Ok(projects) = store.list_projects() { @@ -65,18 +62,25 @@ pub(crate) fn is_path_allowed(path: &Path, store: &Store) -> bool { false } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; use chrono::Utc; use hk_core::models::Project; use std::fs; - use std::os::unix::fs::symlink; fn test_store(db_dir: &Path) -> Store { Store::open(&db_dir.join("test.db")).unwrap() } + /// A home directory unrelated to every fixture path, so only registered + /// projects can make a path allowed. + fn test_home(db_dir: &Path) -> std::path::PathBuf { + let home = db_dir.join("home"); + fs::create_dir_all(&home).unwrap(); + home + } + fn register_project(store: &Store, path: &Path) { store .insert_project(&Project { @@ -93,7 +97,10 @@ mod tests { /// even though canonicalize resolves it to a different real path. This is /// the Lumi case (`/users/` → `/pfs/lustrep*/users/`). #[test] + #[cfg(unix)] fn allows_path_under_symlinked_root() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); let real = tmp.path().join("real"); let link = tmp.path().join("link"); @@ -103,27 +110,28 @@ mod tests { fs::write(&file, "{}").unwrap(); let store = test_store(tmp.path()); + let home = test_home(tmp.path()); register_project(&store, &link); // Caller passes a path through the symlink; canonical resolves to real. let via_link = link.join("config.json"); - assert!(is_path_allowed(&via_link, &store)); + assert!(is_path_allowed(&via_link, &store, &home)); // Caller passes the already-canonical real path. - assert!(is_path_allowed(&file, &store)); + assert!(is_path_allowed(&file, &store, &home)); } - /// Non-existent path: canonicalize fails, function rejects. Matches the - /// desktop's `is_path_within_allowed_dirs` behavior. + /// Non-existent path: canonicalize fails, function rejects. #[test] fn rejects_nonexistent_path() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("project"); fs::create_dir_all(&project).unwrap(); let store = test_store(tmp.path()); + let home = test_home(tmp.path()); register_project(&store, &project); let ghost = project.join("does-not-exist.json"); - assert!(!is_path_allowed(&ghost, &store)); + assert!(!is_path_allowed(&ghost, &store, &home)); } /// Path outside any registered root: rejected. @@ -138,8 +146,9 @@ mod tests { fs::write(&stray, "{}").unwrap(); let store = test_store(tmp.path()); + let home = test_home(tmp.path()); register_project(&store, &project); - assert!(!is_path_allowed(&stray, &store)); + assert!(!is_path_allowed(&stray, &store, &home)); } } diff --git a/crates/hk-web/src/lib.rs b/crates/hk-web/src/lib.rs index 37cf9089..a66ae230 100644 --- a/crates/hk-web/src/lib.rs +++ b/crates/hk-web/src/lib.rs @@ -47,7 +47,8 @@ fn reachable_ipv4_addrs() -> Vec { } pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { - let data_dir = hk_core::home_dir().join(".harnesskit"); + let home = hk_core::home_dir(); + let data_dir = home.join(".harnesskit"); std::fs::create_dir_all(&data_dir)?; let store = Store::open(&data_dir.join("metadata.db"))?; @@ -59,6 +60,7 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { pending_clones: Arc::new(Mutex::new(HashMap::new())), token: options.token.clone(), node_name: node_name.clone(), + home, }; let app = router::build_router(state); diff --git a/crates/hk-web/src/state.rs b/crates/hk-web/src/state.rs index e7651520..f36a39a8 100644 --- a/crates/hk-web/src/state.rs +++ b/crates/hk-web/src/state.rs @@ -22,4 +22,9 @@ pub struct WebState { /// tabs pointing at different remote hosts are distinguishable. Defaults /// to the machine hostname; overridable via `hk serve --name`. pub node_name: String, + /// Home directory that path-allowlist checks accept as a root. + /// `hk_core::home_dir()` in production; tests inject a fixture directory + /// so the check is deterministic even where the OS temp dir lives under + /// the user profile (Windows). + pub home: std::path::PathBuf, } diff --git a/crates/hk-web/tests/api_test.rs b/crates/hk-web/tests/api_test.rs index 62b7d6d3..da6a7b14 100644 --- a/crates/hk-web/tests/api_test.rs +++ b/crates/hk-web/tests/api_test.rs @@ -12,12 +12,18 @@ fn test_state() -> (WebState, tempfile::TempDir) { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("test.db"); let store = Store::open(&db_path).unwrap(); + // The allowlist's home is a sibling of the fixtures, not the real home: + // on Windows the OS temp dir lives under the user profile, so fixture + // paths would otherwise be allowed by the home-prefix check. + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); let state = WebState { store: Arc::new(Mutex::new(store)), adapters: Arc::new(adapter::all_adapters()), pending_clones: Arc::new(Mutex::new(HashMap::new())), token: None, node_name: "test-node".to_string(), + home, }; (state, tmp) } @@ -309,15 +315,8 @@ async fn list_skill_files_returns_single_entry_for_file() { #[tokio::test] async fn list_skill_files_rejects_file_outside_allowed_roots() { let (state, tmp) = test_state(); - // No project registered for this fixture dir, and temp dirs are outside - // the home directory, so the path is not allowed. - // On Windows the temp dir lives UNDER the user profile, so the plain - // home-prefix check would accept it. Point the allowlist's home at an - // unrelated directory via the hk-core home override. - let fake_home = tempfile::tempdir().unwrap(); - // SAFETY: single-threaded tokio test; the env var is only read inside - // this request handler. - unsafe { std::env::set_var("HARNESSKIT_HOME", fake_home.path()) }; + // No project registered for this fixture dir, and the state's home is a + // sibling directory (see `test_state`), so the path is not allowed. let file_path = tmp.path().join("stray-plugin.ts"); std::fs::write(&file_path, "// plugin\n").unwrap(); From fa2dc410bd95e4acf54d0a0d5958981f69c548d9 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:41:34 +0800 Subject: [PATCH 09/15] style: drop no-op Option wrappers and stray blank lines `Some(x).unwrap_or_else(...)` left over from replacing `dirs::home_dir()` (which returned an Option) with the infallible `crate::home_dir()`; extra blank lines in scanner.rs, copilot.rs and skills_cli.rs; missing final newline in lib.rs. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/adapter/copilot.rs | 1 - crates/hk-core/src/auditor/rules/cli.rs | 5 ++--- crates/hk-core/src/lib.rs | 2 +- crates/hk-core/src/sanitize.rs | 6 ++---- crates/hk-core/src/scanner.rs | 1 - crates/hk-core/src/skills_cli.rs | 1 - 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index f5bc8da3..a05143a1 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -500,7 +500,6 @@ mod tests { assert_eq!(hooks.len(), 2); } - #[test] fn read_vscode_agent_plugins() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hk-core/src/auditor/rules/cli.rs b/crates/hk-core/src/auditor/rules/cli.rs index 9de512e3..9abb7651 100644 --- a/crates/hk-core/src/auditor/rules/cli.rs +++ b/crates/hk-core/src/auditor/rules/cli.rs @@ -21,9 +21,8 @@ impl AuditRule for CliCredentialStorage { }; if let Some(cred_path) = &meta.credentials_path { - let expanded = if cred_path.starts_with("~/") { - Some(crate::home_dir().join(&cred_path[2..]).to_string_lossy().to_string()) - .unwrap_or_else(|| cred_path.clone()) + let expanded = if let Some(rest) = cred_path.strip_prefix("~/") { + crate::home_dir().join(rest).to_string_lossy().to_string() } else { cred_path.clone() }; diff --git a/crates/hk-core/src/lib.rs b/crates/hk-core/src/lib.rs index e8e05e5f..da9536c4 100644 --- a/crates/hk-core/src/lib.rs +++ b/crates/hk-core/src/lib.rs @@ -27,4 +27,4 @@ pub fn home_dir() -> std::path::PathBuf { return std::path::PathBuf::from(root); } dirs::home_dir().unwrap_or_default() -} \ No newline at end of file +} diff --git a/crates/hk-core/src/sanitize.rs b/crates/hk-core/src/sanitize.rs index d846616c..28b78d05 100644 --- a/crates/hk-core/src/sanitize.rs +++ b/crates/hk-core/src/sanitize.rs @@ -124,11 +124,9 @@ pub fn is_windows_abs_path(s: &str) -> bool { /// - `Validation`: path does not exist, or equals home dir itself pub fn resolve_and_validate_config_path(path: &str) -> Result { let resolved = if path == "~" { - Some(crate::home_dir().to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()) + crate::home_dir().to_string_lossy().to_string() } else if let Some(rest) = path.strip_prefix("~/") { - Some(crate::home_dir().join(rest).to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()) + crate::home_dir().join(rest).to_string_lossy().to_string() } else { path.to_string() }; diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 0f15f926..88955d22 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -3467,7 +3467,6 @@ mod config_tests { /// recursive rules loading: Claude (global + project), Copilot (global /// + project), Cursor (project, .mdc only). One fixture tree, one scan /// per adapter, flat files double as the no-regression check. - #[test] fn test_scan_agent_configs_nested_rules() { use crate::adapter::copilot::CopilotAdapter; diff --git a/crates/hk-core/src/skills_cli.rs b/crates/hk-core/src/skills_cli.rs index 89ef588f..e5ff2f88 100644 --- a/crates/hk-core/src/skills_cli.rs +++ b/crates/hk-core/src/skills_cli.rs @@ -16,7 +16,6 @@ //! symlink doesn't touch the CLI's canonical `~/.agents` copy (no lockfile drift), //! so native removal is both safe and more precise. - use crate::error::HkError; use crate::models::ConfigScope; From a4b4df769afdd057275228543fd51287fdf26de7 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:41:35 +0800 Subject: [PATCH 10/15] ci: run the PR checks on Windows too Same steps as the macOS job on a windows-2025 runner, so the cross-platform path, process and home-directory handling added for the Windows desktop build is exercised on every pull request rather than only on a contributor's machine. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pr-checks.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index dc7b48da..d48c2e9f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -34,3 +34,35 @@ jobs: - name: Rust tests run: cargo test --workspace + + test-windows: + # HarnessKit ships a Windows CLI, and the desktop app now builds on Windows, + # so run the same checks there: path separators, process spawning and home + # resolution all differ, and macOS-only CI cannot catch regressions in them. + # Pinned to windows-2025 like release.yml; never windows-latest. + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + + - name: Install frontend dependencies + run: npm ci + + - name: Frontend tests + run: npm test + + - name: Build frontend + run: npm run build + + - name: Rust tests + run: cargo test --workspace From 513810e015c6934186864ed10857ec5254b25771 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:45:05 +0800 Subject: [PATCH 11/15] docs: note Windows desktop development and the HARNESSKIT_HOME test override Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27f91897..250fe829 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,13 @@ cargo run -p hk-cli -- serve # http://127.0.0.1:7070 Open `http://localhost:1420` in your browser. Vite proxies `/api/*` requests to the backend at `:7070`. -### Desktop App Development (macOS only) +### Desktop App Development (macOS / Windows) ```bash cargo tauri dev ``` -Tauri automatically runs `npm run dev` as a before-dev command and launches the native window. +Tauri automatically runs `npm run dev` as a before-dev command and launches the native window. On Windows, the Visual Studio Build Tools ("Desktop development with C++") that rustup asks for are the only extra prerequisite. `cargo tauri build` packages NSIS and MSI installers under `target/release/bundle/`; because `createUpdaterArtifacts` is on, it also needs `TAURI_SIGNING_PRIVATE_KEY` in the environment, or pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'` for a local build without updater artifacts. ## Building Releases @@ -89,6 +89,8 @@ npm test # frontend tests (vitest) cargo test --workspace # Rust tests ``` +Tests that need an isolated home directory set `HARNESSKIT_HOME`, which `hk_core::home_dir()` honors ahead of the real home. It exists for test isolation (on Windows the home directory cannot be redirected through `HOME`); it is not a supported way to relocate HarnessKit's data. + ## Troubleshooting ### `Found version mismatched Tauri packages` From fcbb2d67251d7dfb1fd71f291da79494e258d88f Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 13:55:37 +0800 Subject: [PATCH 12/15] test: serialize the remaining tests that read the home directory twice `kits_dir_is_under_home` and the two `~` expansion tests in sanitize.rs read the home twice per run while the kit tests, on other threads, swap it out and back. They already raced on `HOME` on Unix; with `HARNESSKIT_HOME` in play on every platform, take the same `kit_env` lock. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/kits/tests/paths.rs | 4 ++++ crates/hk-core/src/sanitize.rs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/crates/hk-core/src/kits/tests/paths.rs b/crates/hk-core/src/kits/tests/paths.rs index c42ea0b1..393143e9 100644 --- a/crates/hk-core/src/kits/tests/paths.rs +++ b/crates/hk-core/src/kits/tests/paths.rs @@ -1,4 +1,5 @@ use crate::kits::paths::{kits_dir, zip_path_for}; +use serial_test::serial; #[test] fn zip_path_for_uses_hk_kit_zip_suffix() { @@ -6,7 +7,10 @@ fn zip_path_for_uses_hk_kit_zip_suffix() { assert!(p.to_string_lossy().ends_with("abc-123.hk-kit.zip")); } +/// Reads the home twice (`kits_dir` and the assertion); the kit tests +/// rewrite it, so take the same lock. #[test] +#[serial(kit_env)] fn kits_dir_is_under_home() { let dir = kits_dir().unwrap(); let home = crate::home_dir(); diff --git a/crates/hk-core/src/sanitize.rs b/crates/hk-core/src/sanitize.rs index 28b78d05..af1bd007 100644 --- a/crates/hk-core/src/sanitize.rs +++ b/crates/hk-core/src/sanitize.rs @@ -160,6 +160,7 @@ pub fn resolve_and_validate_config_path(path: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use tempfile::TempDir; #[test] @@ -305,7 +306,11 @@ mod tests { /// Bare "~" should expand to home and be rejected as "home itself" — not /// treated as a literal "~" path which then fails the existence check. + // The two home-reading tests below take the kit tests' env lock: those + // tests rewrite the home, and `resolve_and_validate_config_path` reads it + // twice (expansion, then the home-itself comparison). #[test] + #[serial(kit_env)] fn config_path_bare_tilde_expands_to_home_then_rejected() { let result = resolve_and_validate_config_path("~"); match result { @@ -320,6 +325,7 @@ mod tests { /// Home with trailing slash (or any surface variant pointing at home) /// should be caught by the canonicalize-based comparison. #[test] + #[serial(kit_env)] fn config_path_rejects_home_with_trailing_slash() { // (Test) `crate::home_dir()` always returns a PathBuf; an empty one // only happens in environments without any discoverable home. From c90a20456ae6324b79c028c5eebbca3c74a94503 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 16:17:01 +0800 Subject: [PATCH 13/15] fix(ui): paint the canvas and stop root overscroll on the Windows desktop The page keeps html/body transparent and lays a 25% sidebar tint over it, which only looks right when the OS paints vibrancy behind the window. The Windows window is opaque (`tauri.windows.conf.json`), so in dark mode the tint sat on a white canvas and the sidebar came out washed out. The web build already paints the canvas via a `data-web` attribute; generalize it to `data-no-vibrancy`, set whenever the build is not the macOS desktop (`isDesktop() && isMac()`, with `isMac()` new in transport.ts). Also set `overscroll-behavior: none` on the root: the page never scrolls as a whole, and WebView2 otherwise rubber-bands the entire window on a horizontal trackpad swipe. Co-Authored-By: Claude Fable 5.1 --- src/App.tsx | 8 +++++--- src/index.css | 41 +++++++++++++++++++++++------------------ src/lib/transport.ts | 5 +++++ 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7dc45e04..c32e38e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import { Confetti } from "./components/onboarding/confetti"; import { Onboarding, useOnboarding } from "./components/onboarding/onboarding"; import { ErrorBoundary } from "./components/shared/error-boundary"; import { api } from "./lib/invoke"; -import { isDesktop } from "./lib/transport"; +import { isDesktop, isMac } from "./lib/transport"; import AgentsPage from "./pages/agents"; import AuditPage from "./pages/audit"; import ExtensionsPage from "./pages/extensions"; @@ -148,8 +148,10 @@ export default function App() { } else { root.classList.remove("dark"); } - if (!isDesktop()) { - root.setAttribute("data-web", "true"); + // Only the macOS desktop build has OS vibrancy behind the page; the web + // build and the Windows desktop sit on an opaque canvas the CSS must paint. + if (!(isDesktop() && isMac())) { + root.setAttribute("data-no-vibrancy", "true"); } // Force macOS vibrancy to match — "light" | "dark" | null (system) if (isDesktop()) { diff --git a/src/index.css b/src/index.css index 24098c4a..cb646665 100644 --- a/src/index.css +++ b/src/index.css @@ -650,10 +650,10 @@ --toast-warning-text: oklch(0.82 0.12 75); } -/* Tiesen dark on web: sidebar↔background gap is only 0.025 in lightness, - which desktop masks via vibrancy lift but web cannot. Brighten +/* Tiesen dark without vibrancy (web, Windows desktop): sidebar↔background + gap is only 0.025 in lightness, which macOS masks via vibrancy lift. Brighten --background to restore visible separation against sidebar. */ -html.dark[data-theme="tiesen"][data-web="true"] { +html.dark[data-theme="tiesen"][data-no-vibrancy="true"] { --background: oklch(0.175 0.012 267); } @@ -774,25 +774,30 @@ html.dark[data-theme="tiesen"][data-web="true"] { * { @apply border-border outline-ring/50; } - /* Transparent html/body so OS vibrancy (macOS) shows through */ + /* Transparent html/body so OS vibrancy (macOS) shows through. No + overscroll on the root: the page never scrolls as a whole, and WebView2 + on Windows otherwise rubber-bands the entire window on a horizontal + trackpad swipe. */ html, body, #root { background: transparent; - } - /* Web mode: lock the browser's canvas color to our theme so the - translucent bg-sidebar/25 layer has a stable backdrop regardless of - OS prefers-color-scheme or Chrome's Force Dark Mode. `only` defeats - Force Dark. Scoped to [data-web] so Tauri vibrancy stays intact. */ - html[data-web="true"] { color-scheme: only light; } - html.dark[data-web="true"] { color-scheme: only dark; } - - /* Web dark: UA's default dark canvas is a generic gray — paint our - actual sidebar color so the frosted layer composites correctly. - (Light mode intentionally bleeds to the white UA canvas — cleaner.) */ - html.dark[data-web="true"], - html.dark[data-web="true"] body, - html.dark[data-web="true"] #root { + overscroll-behavior: none; + } + /* No OS vibrancy (web mode, Windows desktop): lock the canvas color to + our theme so the translucent bg-sidebar/25 layer has a stable backdrop + regardless of OS prefers-color-scheme or Chrome's Force Dark Mode. + `only` defeats Force Dark. Scoped so macOS vibrancy stays intact. */ + html[data-no-vibrancy="true"] { color-scheme: only light; } + html.dark[data-no-vibrancy="true"] { color-scheme: only dark; } + + /* No-vibrancy dark: the default dark canvas is a generic gray (web) or + plain white (Windows WebView2) — paint our actual sidebar color so the + frosted layer composites correctly. (Light mode intentionally bleeds + to the white canvas — cleaner.) */ + html.dark[data-no-vibrancy="true"], + html.dark[data-no-vibrancy="true"] body, + html.dark[data-no-vibrancy="true"] #root { background: var(--sidebar); } body { diff --git a/src/lib/transport.ts b/src/lib/transport.ts index c0ba68f4..accb1fc6 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -103,6 +103,11 @@ async function httpInvoke( } /** Whether we're running in Tauri desktop or web browser */ +/** Running on macOS (desktop build or browser). */ +export function isMac(): boolean { + return /Mac/.test(navigator.platform); +} + export function isDesktop(): boolean { return isTauri; } From c1c6b06b7057e1e1e8ba1db26142ba2350f4511e Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 16:17:01 +0800 Subject: [PATCH 14/15] fix(ui): show the app icon picker only on the macOS desktop `set_app_icon` swaps the Dock icon through AppKit; on every other platform the command is a no-op that still reports success, so the Windows desktop showed a picker that did nothing. Windows has no runtime equivalent (the Start menu and shortcut icons come from the executable), so hide the setting, and the startup restore, outside the macOS desktop instead of pretending. Co-Authored-By: Claude Fable 5.1 --- src/App.tsx | 4 ++-- src/pages/settings.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c32e38e5..c6d1f59e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -163,9 +163,9 @@ export default function App() { } }, [themeName, mode, resolved, showOnboarding]); - // Restore app icon from saved preference — desktop only + // Restore app icon from saved preference — macOS desktop only (Dock icon) useEffect(() => { - if (isDesktop()) { + if (isDesktop() && isMac()) { api .setAppIcon(appIcon) .catch((e) => console.error("Failed to set app icon:", e)); diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 8953720c..016485a1 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -22,7 +22,7 @@ import { type LanguagePreference, } from "@/lib/i18n"; import { api } from "@/lib/invoke"; -import { isDesktop } from "@/lib/transport"; +import { isDesktop, isMac } from "@/lib/transport"; import { agentDisplayName, type DiscoveredProject } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useProjectStore } from "@/stores/project-store"; @@ -802,11 +802,12 @@ export default function SettingsPage() { - {isDesktop() && ( + {isDesktop() && isMac() && ( <>
- {/* App Icon — desktop only */} + {/* App Icon — macOS only: the command swaps the Dock icon, + which has no Windows/Linux equivalent at runtime */}
{t("appearance.appIcon")}
From 12c6ddfd8926ed0488563a47f38e88a3b3950d15 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 24 Oct 2026 16:23:08 +0800 Subject: [PATCH 15/15] feat(desktop): give the Windows title bar the sidebar's colors With "Transparency effects" on, Windows 11 paints every window's caption with Mica, a tint derived from the desktop wallpaper, which clashes with the app's palette (a blue bar over a warm theme). An explicit caption color (`DWMWA_CAPTION_COLOR` / `DWMWA_TEXT_COLOR`, Windows 11 build 22000+) opts the window out of that tint whatever the user's transparency setting. New `set_title_bar_color` command (no-op outside Windows); the frontend calls it from the theme effect with the sidebar color as it actually renders (`bg-sidebar/25` over the canvas) and the sidebar foreground, so the title bar reads as part of the sidebar and follows theme changes. Windows 10 rejects the attributes and has no Mica either; the result is ignored. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + crates/hk-desktop/Cargo.toml | 3 ++ crates/hk-desktop/src/main.rs | 2 + crates/hk-desktop/src/title_bar.rs | 79 +++++++++++++++++++++++++++++ src/App.tsx | 22 ++++++++ src/lib/__tests__/css-color.test.ts | 29 +++++++++++ src/lib/css-color.ts | 34 +++++++++++++ src/lib/invoke.ts | 5 ++ 8 files changed, 175 insertions(+) create mode 100644 crates/hk-desktop/src/title_bar.rs create mode 100644 src/lib/__tests__/css-color.test.ts create mode 100644 src/lib/css-color.ts diff --git a/Cargo.lock b/Cargo.lock index 6f127451..5e9d3277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1993,6 +1993,7 @@ dependencies = [ "tauri-plugin-updater", "tempfile", "uuid", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/hk-desktop/Cargo.toml b/crates/hk-desktop/Cargo.toml index 5da47ca5..16b8fb11 100644 --- a/crates/hk-desktop/Cargo.toml +++ b/crates/hk-desktop/Cargo.toml @@ -22,6 +22,9 @@ uuid.workspace = true tempfile = "3" parking_lot = "0.12" +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Dwm"] } + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] } diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 9193936d..fb8c5c92 100644 --- a/crates/hk-desktop/src/main.rs +++ b/crates/hk-desktop/src/main.rs @@ -2,6 +2,7 @@ mod commands; mod icon; +mod title_bar; use commands::AppState; use hk_core::{adapter, store::Store}; @@ -97,6 +98,7 @@ fn main() { commands::import_kit, commands::list_project_install_records, icon::set_app_icon, + title_bar::set_title_bar_color, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/crates/hk-desktop/src/title_bar.rs b/crates/hk-desktop/src/title_bar.rs new file mode 100644 index 00000000..2bb29b84 --- /dev/null +++ b/crates/hk-desktop/src/title_bar.rs @@ -0,0 +1,79 @@ +//! Native title bar colors on Windows. +//! +//! With "Transparency effects" on, Windows 11 paints every window's caption +//! with Mica, a tint derived from the desktop wallpaper, which clashes with +//! the app's own palette. An explicit caption color opts the window out of +//! that tint (DWM applies it regardless of the user's transparency setting). +//! The frontend sends the sidebar colors whenever the theme changes so the +//! title bar reads as part of the sidebar below it. + +use tauri::command; + +/// Parse `#rrggbb` into a Win32 `COLORREF` (`0x00BBGGRR`). +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn colorref(hex: &str) -> Result { + let digits = hex.strip_prefix('#').unwrap_or(hex); + if digits.len() != 6 { + return Err(format!("expected #rrggbb, got {hex:?}")); + } + let rgb = u32::from_str_radix(digits, 16).map_err(|e| format!("bad color {hex:?}: {e}"))?; + let (r, g, b) = ((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff); + Ok((b << 16) | (g << 8) | r) +} + +#[cfg(target_os = "windows")] +#[command] +pub fn set_title_bar_color( + window: tauri::WebviewWindow, + background: String, + text: String, +) -> Result<(), String> { + use windows_sys::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_CAPTION_COLOR, DWMWA_TEXT_COLOR, + }; + + let hwnd = window.hwnd().map_err(|e| e.to_string())?; + let hwnd = hwnd.0 as windows_sys::Win32::Foundation::HWND; + for (attribute, hex) in [(DWMWA_CAPTION_COLOR, &background), (DWMWA_TEXT_COLOR, &text)] { + let color = colorref(hex)?; + // SAFETY: `hwnd` is this window's live handle, and the attribute takes + // a COLORREF, which `color` is, passed with its size. + let _ = unsafe { + DwmSetWindowAttribute( + hwnd, + attribute as u32, + &color as *const u32 as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ) + }; + // The result is deliberately ignored: Windows 10 rejects these + // attributes (it has no Mica to opt out of either), and a failure + // here is cosmetic. + } + Ok(()) +} + +/// Only Windows paints a native title bar whose color the app can set. +#[cfg(not(target_os = "windows"))] +#[command] +pub fn set_title_bar_color(_background: String, _text: String) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::colorref; + + #[test] + fn colorref_swaps_red_and_blue_bytes() { + assert_eq!(colorref("#112233").unwrap(), 0x0033_2211); + assert_eq!(colorref("ffffff").unwrap(), 0x00ff_ffff); + } + + #[test] + fn colorref_rejects_anything_but_six_hex_digits() { + assert!(colorref("#fff").is_err()); + assert!(colorref("#gg0000").is_err()); + assert!(colorref("rgb(1, 2, 3)").is_err()); + } +} diff --git a/src/App.tsx b/src/App.tsx index c6d1f59e..24ccc6aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { WebUpdateDialog } from "./components/layout/web-update-dialog"; import { Confetti } from "./components/onboarding/confetti"; import { Onboarding, useOnboarding } from "./components/onboarding/onboarding"; import { ErrorBoundary } from "./components/shared/error-boundary"; +import { blendOver, cssColorToRgb, type Rgb, rgbToHex } from "./lib/css-color"; import { api } from "./lib/invoke"; import { isDesktop, isMac } from "./lib/transport"; import AgentsPage from "./pages/agents"; @@ -161,6 +162,27 @@ export default function App() { ) .catch((e) => console.error("Failed to set window theme:", e)); } + // Windows tints the native title bar with the wallpaper (Mica) when + // transparency effects are on; give it the sidebar's colors instead so it + // reads as part of the sidebar. No-op on other platforms. + if (isDesktop() && !isMac()) { + const styles = getComputedStyle(root); + const sidebar = cssColorToRgb(styles.getPropertyValue("--sidebar")); + const text = cssColorToRgb( + styles.getPropertyValue("--sidebar-foreground"), + ); + if (sidebar && text) { + // The visible sidebar is `bg-sidebar/25` (app-shell.tsx) over the + // canvas: the sidebar color itself in dark mode, white in light mode. + const canvas: Rgb = activeDark ? sidebar : [255, 255, 255]; + api + .setTitleBarColor( + rgbToHex(blendOver(sidebar, canvas, 0.25)), + rgbToHex(text), + ) + .catch((e) => console.error("Failed to set title bar color:", e)); + } + } }, [themeName, mode, resolved, showOnboarding]); // Restore app icon from saved preference — macOS desktop only (Dock icon) diff --git a/src/lib/__tests__/css-color.test.ts b/src/lib/__tests__/css-color.test.ts new file mode 100644 index 00000000..2cf5c895 --- /dev/null +++ b/src/lib/__tests__/css-color.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { blendOver, cssColorToRgb, rgbToHex } from "../css-color"; + +describe("blendOver", () => { + it("returns the top color at alpha 1 and the bottom color at alpha 0", () => { + expect(blendOver([10, 20, 30], [200, 210, 220], 1)).toEqual([10, 20, 30]); + expect(blendOver([10, 20, 30], [200, 210, 220], 0)).toEqual([ + 200, 210, 220, + ]); + }); + + it("mixes per channel and rounds", () => { + expect(blendOver([0, 0, 0], [255, 255, 255], 0.25)).toEqual([ + 191, 191, 191, + ]); + }); +}); + +describe("rgbToHex", () => { + it("zero-pads each channel", () => { + expect(rgbToHex([0, 15, 255])).toBe("#000fff"); + }); +}); + +describe("cssColorToRgb", () => { + it("returns null without a canvas context (jsdom)", () => { + expect(cssColorToRgb("#ff0000")).toBeNull(); + }); +}); diff --git a/src/lib/css-color.ts b/src/lib/css-color.ts new file mode 100644 index 00000000..d82f7016 --- /dev/null +++ b/src/lib/css-color.ts @@ -0,0 +1,34 @@ +/** + * Color helpers for handing CSS theme colors to native code. + */ + +export type Rgb = readonly [number, number, number]; + +/** + * Resolve any CSS color (the theme uses oklch) to sRGB bytes by painting it + * on a 1×1 canvas. Returns null when the color does not parse or no canvas + * is available (jsdom). + */ +export function cssColorToRgb(color: string): Rgb | null { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return null; + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + return a === 0 ? null : [r, g, b]; +} + +/** `top` composited over `bottom` at `alpha` (0–1), per channel. */ +export function blendOver(top: Rgb, bottom: Rgb, alpha: number): Rgb { + const mix = (t: number, b: number) => Math.round(t * alpha + b * (1 - alpha)); + return [ + mix(top[0], bottom[0]), + mix(top[1], bottom[1]), + mix(top[2], bottom[2]), + ]; +} + +export function rgbToHex([r, g, b]: Rgb): string { + const hex = (n: number) => n.toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index f6bc6295..50d41cf4 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -362,6 +362,11 @@ export const api = { return transport("set_app_icon", { name }); }, + /** Windows only: colors for the native title bar (`#rrggbb`). */ + setTitleBarColor(background: string, text: string): Promise { + return transport("set_title_bar_color", { background, text }); + }, + listKits(): Promise { return transport("list_kits"); },