diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs index 9e9915a..d1434d8 100644 --- a/src-tauri/src/antigravity.rs +++ b/src-tauri/src/antigravity.rs @@ -1,3 +1,4 @@ +use crate::home::home_or_tmp; use std::fs; use std::io::{Read, Write}; use std::path::PathBuf; @@ -17,16 +18,12 @@ const HOOK_NAME: &str = "logic-loop"; /// on a Post* event can only delay, never block or deny. const ANTIGRAVITY_HOOK_EVENTS: [&str; 3] = ["PostToolUse", "PostInvocation", "Stop"]; -fn home() -> String { - std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) -} - /// Global discovery location per the installed CLI's own docs (`~/.gemini/ /// config/` — "Global Configuration (Machine-Local)"), not the per-project /// `.agents/hooks.json` (would need one register/strip per project) and not /// the legacy `~/.gemini/settings.json`. fn settings_path() -> PathBuf { - PathBuf::from(home()).join(".gemini").join("config").join("hooks.json") + PathBuf::from(home_or_tmp()).join(".gemini").join("config").join("hooks.json") } fn read_settings() -> Result { @@ -115,7 +112,7 @@ pub fn antigravity_detect() -> bool { } ["homebrew/bin", ".local/bin"] .iter() - .any(|rel| is_executable(&PathBuf::from(home()).join(rel).join("agy"))) + .any(|rel| is_executable(&PathBuf::from(home_or_tmp()).join(rel).join("agy"))) || is_executable(&PathBuf::from("/opt/homebrew/bin/agy")) || is_executable(&PathBuf::from("/usr/local/bin/agy")) } diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index f75f339..65c0fef 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -20,7 +20,7 @@ pub fn clipboard_text() -> String { /// ~/.context-terminal/pastes/ and return the path. None otherwise. #[tauri::command] pub fn clipboard_image_path() -> Option { - let home = std::env::var("HOME").ok()?; + let home = crate::home::home()?; let dir = std::path::Path::new(&home).join(".context-terminal/pastes"); std::fs::create_dir_all(&dir).ok()?; let ts = std::time::SystemTime::now() diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index cdc7356..8dbdffe 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -1,3 +1,4 @@ +use crate::home::home_or_tmp; use std::fs; use std::path::PathBuf; @@ -7,16 +8,12 @@ use std::path::PathBuf; const CODEX_HOOK_EVENTS: [&str; 5] = ["SessionStart", "Stop", "PostToolUse", "UserPromptSubmit", "PermissionRequest"]; -fn home() -> String { - std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) -} - /// Standalone hooks.json, not inline `[hooks]` in config.toml — Codex /// auto-discovers this file with zero config.toml edits, and avoids touching /// tables (`[marketplaces]`, `[plugins]`, `[projects]`, `[tui]`, `[notice]`) /// a naive TOML rewrite could mangle. fn settings_path() -> PathBuf { - PathBuf::from(home()).join(".codex").join("hooks.json") + PathBuf::from(home_or_tmp()).join(".codex").join("hooks.json") } /// `command` is the exact string `crate::ingest::hook_command()` produces @@ -109,7 +106,7 @@ pub fn codex_detect() -> bool { } ["homebrew/bin", ".local/bin"] .iter() - .any(|rel| is_executable(&PathBuf::from(home()).join(rel).join("codex"))) + .any(|rel| is_executable(&PathBuf::from(home_or_tmp()).join(rel).join("codex"))) || is_executable(&PathBuf::from("/opt/homebrew/bin/codex")) || is_executable(&PathBuf::from("/usr/local/bin/codex")) } diff --git a/src-tauri/src/extractor.rs b/src-tauri/src/extractor.rs index 5ca0e13..3662657 100644 --- a/src-tauri/src/extractor.rs +++ b/src-tauri/src/extractor.rs @@ -4,7 +4,7 @@ use std::process::{Command, Stdio}; fn claude_bin() -> String { let local = format!( "{}/.local/bin/claude", - std::env::var("HOME").unwrap_or_default() + crate::home::home().unwrap_or_default() ); if std::path::Path::new(&local).exists() { local diff --git a/src-tauri/src/home.rs b/src-tauri/src/home.rs new file mode 100644 index 0000000..4277b35 --- /dev/null +++ b/src-tauri/src/home.rs @@ -0,0 +1,58 @@ +// Single source for the user's home directory. `HOME` does not exist on +// Windows — the equivalent is `USERPROFILE` — so reading `HOME` alone made +// every adapter fall through to its own fallback there and register its hooks +// into a path Windows has no notion of, silently and with no error surfaced. + +/// The first of `HOME`, `USERPROFILE` that is set. `None` when neither is, so +/// each call site keeps the fallback it already had rather than one being +/// imposed here. +pub fn home() -> Option { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok() +} + +/// The fallback the four agent adapters share, factored out so the literal has +/// one definition instead of one per adapter. `std::env::temp_dir()` is used +/// over a hardcoded `/tmp` so this resolves on Windows too, where `/tmp` +/// doesn't exist. +pub fn home_or_tmp() -> String { + home().unwrap_or_else(|| std::env::temp_dir().to_string_lossy().into_owned()) +} + +// Tests across this file and pty.rs read/write HOME and USERPROFILE; without +// a shared lock, cargo test's parallel threads can race and panic each other +// (one test unsets HOME mid-`unwrap()` in another). Every test touching +// either var must hold this first. +#[cfg(test)] +pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn home_falls_back_to_userprofile_when_home_unset() { + let _guard = ENV_LOCK.lock().unwrap(); + let orig_home = std::env::var("HOME").ok(); + let orig_profile = std::env::var("USERPROFILE").ok(); + + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", "/tmp/fake-userprofile"); + assert_eq!(home().as_deref(), Some("/tmp/fake-userprofile")); + + std::env::remove_var("HOME"); + std::env::remove_var("USERPROFILE"); + assert_eq!(home(), None); + assert_eq!(home_or_tmp(), std::env::temp_dir().to_string_lossy()); + + match orig_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match orig_profile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } +} diff --git a/src-tauri/src/ingest.rs b/src-tauri/src/ingest.rs index f15e8d1..aaf3107 100644 --- a/src-tauri/src/ingest.rs +++ b/src-tauri/src/ingest.rs @@ -1,3 +1,4 @@ +use crate::home::home_or_tmp; use std::collections::HashSet; use std::fs; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; @@ -14,16 +15,12 @@ pub(crate) const MARKER: &str = "context-terminal/ingest.env"; /// extractor again — a self-amplifying loop. Dropped at the door. pub const EXTRACTOR_TETHER: &str = "__logic_loop_extractor__"; -fn home() -> String { - std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) -} - fn config_dir() -> PathBuf { - PathBuf::from(home()).join(".context-terminal") + PathBuf::from(home_or_tmp()).join(".context-terminal") } fn settings_path() -> PathBuf { - PathBuf::from(home()).join(".claude/settings.json") + PathBuf::from(home_or_tmp()).join(".claude/settings.json") } /// Sessions with an active transcript tailer. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e73048..3a81005 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod clipboard; mod extractor; mod codex; +mod home; mod ingest; mod opencode; mod pty; diff --git a/src-tauri/src/opencode.rs b/src-tauri/src/opencode.rs index b710137..18c5e81 100644 --- a/src-tauri/src/opencode.rs +++ b/src-tauri/src/opencode.rs @@ -1,3 +1,4 @@ +use crate::home::home_or_tmp; use std::fs; use std::path::PathBuf; @@ -10,17 +11,13 @@ const MARKER: &str = "logic-loop-opencode-plugin"; /// must know about, same role as `ingest.rs`'s `HOOK_VERSION`. const OPENCODE_PLUGIN_VERSION: u32 = 1; -fn home() -> String { - std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) -} - /// OpenCode resolves its global config dir from `$XDG_CONFIG_HOME` or /// `~/.config` on every platform, including Windows — no per-OS branch. /// Matched here so we write to the same file OpenCode itself reads. fn config_dir() -> PathBuf { match std::env::var("XDG_CONFIG_HOME") { Ok(v) if !v.is_empty() => PathBuf::from(v).join("opencode"), - _ => PathBuf::from(home()).join(".config").join("opencode"), + _ => PathBuf::from(home_or_tmp()).join(".config").join("opencode"), } } @@ -29,7 +26,7 @@ fn settings_path() -> PathBuf { } fn plugin_path() -> PathBuf { - PathBuf::from(home()) + PathBuf::from(home_or_tmp()) .join(".context-terminal") .join(format!("{MARKER}.mjs")) } @@ -236,12 +233,12 @@ pub fn opencode_detect() -> bool { } [".opencode/bin", ".local/bin"] .iter() - .any(|rel| is_executable(&PathBuf::from(home()).join(rel).join("opencode"))) + .any(|rel| is_executable(&PathBuf::from(home_or_tmp()).join(rel).join("opencode"))) } #[tauri::command] pub fn opencode_hooks_setup() -> Result<(), String> { - let dir = PathBuf::from(home()).join(".context-terminal"); + let dir = PathBuf::from(home_or_tmp()).join(".context-terminal"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::write(plugin_path(), plugin_source()).map_err(|e| e.to_string())?; let mut settings = read_settings()?; diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 4bdd8df..20668eb 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -63,8 +63,8 @@ impl PtyManager { /// Falls back to the expanded string when the path doesn't exist (bookmarks may /// point at folders that are gone). pub fn canon(p: &str) -> String { - let expanded = match (p.strip_prefix("~"), std::env::var("HOME")) { - (Some(rest), Ok(home)) => format!("{home}{rest}"), + let expanded = match (p.strip_prefix("~"), crate::home::home()) { + (Some(rest), Some(home)) => format!("{home}{rest}"), _ => p.to_string(), }; std::fs::canonicalize(&expanded) @@ -86,7 +86,7 @@ pub fn canonicalize_cwd(path: String) -> String { /// non-repo directory collapse into one giant "project" — silent and total. pub fn project_key(cwd: &str) -> String { let resolved = canon(cwd); - let home = std::env::var("HOME").map(|h| canon(&h)).unwrap_or_default(); + let home = crate::home::home().map(|h| canon(&h)).unwrap_or_default(); let mut dir = std::path::Path::new(&resolved); loop { if !home.is_empty() && dir.as_os_str() == home.as_str() { @@ -599,9 +599,13 @@ mod tests { } #[test] - #[cfg(unix)] // $HOME-dependent; Phase 13 re-enables against a home() helper + // Still gated after the home() helper: the case-fold assertion needs + // `~/Library` to exist on a case-insensitive filesystem, and where it does + // not both spellings fall through canon unchanged and compare unequal. + #[cfg(unix)] fn canon_resolves_case_and_tilde_to_one_key() { - let home = std::env::var("HOME").unwrap(); + let _guard = crate::home::ENV_LOCK.lock().unwrap(); + let home = crate::home::home().unwrap(); // `~` expands, and a case-variant spelling of an existing dir resolves to // the same string — that equality is what keeps a project from splitting // into several SQL keys. @@ -636,9 +640,15 @@ mod tests { } #[test] - #[cfg(unix)] // $HOME-dependent; Phase 13 re-enables against a home() helper + // Still gated after the home() helper: on Windows `canonicalize` returns a + // `\\?\` verbatim path for a home that exists but leaves the nonexistent + // `~/...` case unprefixed, so the $HOME boundary this asserts is never + // reached and the walk runs to the drive root instead. That is the path + // half of the Windows port, not the env-var half. + #[cfg(unix)] fn project_key_outside_a_repo_is_the_dir_itself() { - let home = std::env::var("HOME").unwrap(); + let _guard = crate::home::ENV_LOCK.lock().unwrap(); + let home = crate::home::home().unwrap(); // No `.git` anywhere up to `/` → the dir is its own project, no panic // and no walk off the end of the tree. let key = project_key("/tmp");