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 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` 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-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/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..a05143a1 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)] @@ -520,7 +520,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..9abb7651 100644 --- a/crates/hk-core/src/auditor/rules/cli.rs +++ b/crates/hk-core/src/auditor/rules/cli.rs @@ -21,10 +21,8 @@ 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()) - .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/kits/paths.rs b/crates/hk-core/src/kits/paths.rs index 94a1e401..0bad7daf 100644 --- a/crates/hk-core/src/kits/paths.rs +++ b/crates/hk-core/src/kits/paths.rs @@ -2,10 +2,11 @@ use crate::HkError; use std::path::PathBuf; /// Canonical directory holding all Kit zip files: `~/.harnesskit/kits/`. +/// +/// 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 { - 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/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/paths.rs b/crates/hk-core/src/kits/tests/paths.rs index 27f0a44d..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,10 +7,13 @@ 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 = 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-core/src/kits/tests/service.rs b/crates/hk-core/src/kits/tests/service.rs index d840ae03..41b7e0cb 100644 --- a/crates/hk-core/src/kits/tests/service.rs +++ b/crates/hk-core/src/kits/tests/service.rs @@ -11,27 +11,53 @@ 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"), - } + 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 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); + 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. @@ -66,11 +92,9 @@ 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(); - 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 +102,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(); @@ -93,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -126,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -163,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -198,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); 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(); @@ -213,7 +235,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 +268,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 +297,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"); @@ -314,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + 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"); @@ -381,7 +402,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 +447,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 +464,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 +485,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 +522,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 +600,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 +630,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 +744,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 +822,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 +875,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 +963,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 +1038,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(); @@ -1154,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); // Real .md skill on disk under claude's global skill dir. let skill_path = dir @@ -1201,10 +1221,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 @@ -1233,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"); - unsafe { std::env::set_var("HOME", dir.path()); } - let _restore = RestoreHome(orig_home); + let _restore = RestoreHome::new(); + set_test_home(dir.path()); let store = Mutex::new(Store::open(&dir.path().join("hk.db")).unwrap()); let adapters = all_adapters(); @@ -1362,7 +1378,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 +1557,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 +1618,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 +1697,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 03b6d028..da9536c4 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,16 @@ pub mod skills_cli; 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() +} diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index e5aa796b..d906dfd7 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 @@ -593,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" { @@ -896,7 +894,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 +939,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 +1329,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 +1750,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 +1765,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1778,11 +1776,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 +1794,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1807,7 +1805,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 +1817,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 +1828,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 +1846,7 @@ mod tests { ]) .output() .unwrap(); - Command::new("git") + crate::command("git") .args([ "-C", &work.path().to_string_lossy(), @@ -1859,7 +1857,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..c22032a4 --- /dev/null +++ b/crates/hk-core/src/process.rs @@ -0,0 +1,38 @@ +//! Subprocess helpers. +//! +//! 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}; + +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); + } + cmd +} diff --git a/crates/hk-core/src/sanitize.rs b/crates/hk-core/src/sanitize.rs index 7e04eca3..af1bd007 100644 --- a/crates/hk-core/src/sanitize.rs +++ b/crates/hk-core/src/sanitize.rs @@ -124,13 +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 == "~" { - dirs::home_dir() - .map(|h| h.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("~/") { - dirs::home_dir() - .map(|h| h.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() }; @@ -148,7 +144,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), @@ -164,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] @@ -309,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 { @@ -324,11 +325,14 @@ 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() { - 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 c2ad6cf5..88955d22 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() @@ -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"), @@ -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()?; @@ -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")] @@ -3507,12 +3507,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 +3544,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 +3557,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 +3569,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/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..e5ff2f88 100644 --- a/crates/hk-core/src/skills_cli.rs +++ b/crates/hk-core/src/skills_cli.rs @@ -16,8 +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 +63,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), @@ -114,7 +112,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()); } @@ -122,7 +126,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-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/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", diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 34f78ac1..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}; @@ -10,13 +11,13 @@ 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"); + // 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"]}`) @@ -97,12 +98,22 @@ 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 { - // 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/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/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/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/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/handlers/mod.rs b/crates/hk-web/src/handlers/mod.rs index e7346c87..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,9 +47,9 @@ pub(crate) fn is_path_allowed(path: &Path, store: &Store) -> bool { .unwrap_or(false) }; - if let Some(home) = dirs::home_dir() - && 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() { @@ -63,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 { @@ -91,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"); @@ -101,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. @@ -136,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 e9ef307e..a66ae230 100644 --- a/crates/hk-web/src/lib.rs +++ b/crates/hk-web/src/lib.rs @@ -47,9 +47,8 @@ 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 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"))?; @@ -61,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 6149f4be..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,8 +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. + // 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(); diff --git a/src/App.tsx b/src/App.tsx index 7dc45e04..24ccc6aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,8 +8,9 @@ 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 } 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 +149,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()) { @@ -159,11 +162,32 @@ 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 — 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/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/__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"); }, 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; } 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")}