Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src-tauri/src/antigravity.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::home::home_or_tmp;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
Expand All @@ -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<serde_json::Value, String> {
Expand Down Expand Up @@ -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"))
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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()
Expand Down
9 changes: 3 additions & 6 deletions src-tauri/src/codex.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::home::home_or_tmp;
use std::fs;
use std::path::PathBuf;

Expand All @@ -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
Expand Down Expand Up @@ -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"))
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions src-tauri/src/home.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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"),
}
}
}
9 changes: 3 additions & 6 deletions src-tauri/src/ingest.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod clipboard;
mod extractor;
mod codex;
mod home;
mod ingest;
mod opencode;
mod pty;
Expand Down
13 changes: 5 additions & 8 deletions src-tauri/src/opencode.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::home::home_or_tmp;
use std::fs;
use std::path::PathBuf;

Expand All @@ -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"),
}
}

Expand All @@ -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"))
}
Expand Down Expand Up @@ -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()?;
Expand Down
24 changes: 17 additions & 7 deletions src-tauri/src/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Loading