4419f5660e
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
302 lines
11 KiB
Rust
302 lines
11 KiB
Rust
//! Daemon configuration loaded from `~/.spacesh/config.toml`.
|
|
//!
|
|
//! The file is optional; every field has a sane fallback so a missing or
|
|
//! partial config never breaks startup.
|
|
|
|
use std::path::Path;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
pub struct TerminalConfig {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub font_family: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub font_size: Option<u16>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
pub struct AppearanceConfig {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub theme: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub accent: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
pub struct Config {
|
|
/// Shell launched for plain (no-command) panels. When unset, the daemon
|
|
/// auto-detects the user's login shell.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub default_shell: Option<String>,
|
|
#[serde(default)]
|
|
pub terminal: TerminalConfig,
|
|
#[serde(default)]
|
|
pub appearance: AppearanceConfig,
|
|
}
|
|
|
|
impl Config {
|
|
/// Resolve to a client-facing view, applying defaults and the shell resolver.
|
|
pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView {
|
|
spacesh_proto::config_view::ConfigView {
|
|
default_shell: self.resolved_shell(),
|
|
font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()),
|
|
font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
|
|
theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
|
|
accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
|
|
}
|
|
}
|
|
|
|
/// Shell for a plain panel using THIS in-memory config
|
|
/// (env -> config -> passwd -> $SHELL -> /bin/sh).
|
|
pub fn resolved_shell(&self) -> String {
|
|
if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } }
|
|
if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } }
|
|
if let Some(s) = login_shell() { return s; }
|
|
if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } }
|
|
"/bin/sh".into()
|
|
}
|
|
|
|
/// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults.
|
|
pub fn load() -> Self {
|
|
let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() };
|
|
Self::from_path(&dir.join("config.toml"))
|
|
}
|
|
|
|
pub fn from_path(path: &Path) -> Self {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(s) => toml::from_str(&s).unwrap_or_default(),
|
|
Err(_) => Self::default(),
|
|
}
|
|
}
|
|
|
|
/// Persist to `~/.spacesh/config.toml`.
|
|
pub fn save(&self) -> std::io::Result<()> {
|
|
let dir = crate::lifecycle::spacesh_dir()
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
self.save_to(&dir.join("config.toml"))
|
|
}
|
|
|
|
/// Persist to an arbitrary path. Creates the parent directory if needed.
|
|
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let s = toml::to_string_pretty(self)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
std::fs::write(path, s)
|
|
}
|
|
}
|
|
|
|
/// Resolve the shell to spawn for a plain panel.
|
|
///
|
|
/// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell
|
|
/// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under
|
|
/// launchd, where `$SHELL` is typically absent (so a bash fallback would win).
|
|
// retained for the env-override test and potential startup use
|
|
#[allow(dead_code)]
|
|
pub fn default_shell() -> String {
|
|
if let Ok(s) = std::env::var("SPACESH_SHELL") {
|
|
if !s.is_empty() { return s; }
|
|
}
|
|
if let Some(s) = Config::load().default_shell {
|
|
if !s.is_empty() { return s; }
|
|
}
|
|
if let Some(s) = login_shell() {
|
|
return s;
|
|
}
|
|
if let Ok(s) = std::env::var("SHELL") {
|
|
if !s.is_empty() { return s; }
|
|
}
|
|
"/bin/sh".into()
|
|
}
|
|
|
|
/// The user's full PATH for spawning panels: their login-shell PATH (which sources
|
|
/// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install
|
|
/// dirs. Cached for the daemon's lifetime.
|
|
///
|
|
/// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is
|
|
/// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed
|
|
/// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits
|
|
/// immediately with "Process exited". A bare `/bin/zsh` still works, which is why
|
|
/// shells launched fine but agents didn't.
|
|
pub fn enriched_path() -> String {
|
|
use std::collections::HashSet;
|
|
use std::sync::OnceLock;
|
|
static CACHE: OnceLock<String> = OnceLock::new();
|
|
CACHE
|
|
.get_or_init(|| {
|
|
let mut dirs: Vec<String> = Vec::new();
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut merge = |src: &str| {
|
|
for d in src.split(':') {
|
|
if !d.is_empty() && seen.insert(d.to_string()) {
|
|
dirs.push(d.to_string());
|
|
}
|
|
}
|
|
};
|
|
if let Some(p) = login_shell_path() {
|
|
merge(&p);
|
|
}
|
|
if let Ok(p) = std::env::var("PATH") {
|
|
merge(&p);
|
|
}
|
|
merge(&fallback_path_dirs());
|
|
dirs.join(":")
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
/// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path
|
|
/// if it contains a slash). Used to only offer agents the user actually has installed.
|
|
pub fn is_installed(cmd: &str) -> bool {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if cmd.is_empty() {
|
|
return false;
|
|
}
|
|
let is_exec = |p: &std::path::Path| {
|
|
p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
|
|
};
|
|
if cmd.contains('/') {
|
|
return is_exec(std::path::Path::new(cmd));
|
|
}
|
|
enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd)))
|
|
}
|
|
|
|
/// Common locations user-installed CLIs land in, as a colon-joined fallback.
|
|
fn fallback_path_dirs() -> String {
|
|
let mut v = vec![
|
|
"/opt/homebrew/bin".to_string(),
|
|
"/usr/local/bin".to_string(),
|
|
"/usr/bin".to_string(),
|
|
"/bin".to_string(),
|
|
"/usr/sbin".to_string(),
|
|
"/sbin".to_string(),
|
|
];
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
if !home.is_empty() {
|
|
for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] {
|
|
v.push(format!("{home}/{d}"));
|
|
}
|
|
}
|
|
}
|
|
v.join(":")
|
|
}
|
|
|
|
/// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply.
|
|
/// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this
|
|
/// works for fish too, where `$PATH` would otherwise print space-separated).
|
|
#[cfg(unix)]
|
|
fn login_shell_path() -> Option<String> {
|
|
let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?;
|
|
let out = std::process::Command::new(&shell)
|
|
.args(["-lic", "env"])
|
|
.output()
|
|
.ok()?;
|
|
String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.find_map(|l| l.strip_prefix("PATH="))
|
|
.map(|p| p.trim().to_string())
|
|
.filter(|p| !p.is_empty())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn login_shell_path() -> Option<String> { None }
|
|
|
|
/// The current user's login shell from the passwd database (`getpwuid`).
|
|
#[cfg(unix)]
|
|
fn login_shell() -> Option<String> {
|
|
use std::ffi::CStr;
|
|
// SAFETY: getpwuid returns a pointer into a static buffer valid until the
|
|
// next libc passwd call; we copy the string out immediately on this thread.
|
|
unsafe {
|
|
let pw = libc::getpwuid(libc::getuid());
|
|
if pw.is_null() { return None; }
|
|
let shell = (*pw).pw_shell;
|
|
if shell.is_null() { return None; }
|
|
let s = CStr::from_ptr(shell).to_str().ok()?;
|
|
if s.is_empty() { None } else { Some(s.to_string()) }
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn login_shell() -> Option<String> { None }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn missing_file_is_default() {
|
|
let c = Config::from_path(Path::new("/no/such/spacesh/config.toml"));
|
|
assert!(c.default_shell.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_default_shell() {
|
|
let dir = std::env::temp_dir().join("spacesh-cfg-test");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("config.toml");
|
|
std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap();
|
|
let c = Config::from_path(&path);
|
|
assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh"));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn env_override_wins() {
|
|
let _serial = crate::test_support::serial();
|
|
std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell");
|
|
let s = default_shell();
|
|
std::env::remove_var("SPACESH_SHELL");
|
|
assert_eq!(s, "/tmp/fake-shell");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_terminal_and_appearance() {
|
|
let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("config.toml");
|
|
std::fs::write(&path,
|
|
"default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap();
|
|
let c = Config::from_path(&path);
|
|
assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo"));
|
|
assert_eq!(c.terminal.font_size, Some(15));
|
|
assert_eq!(c.appearance.theme.as_deref(), Some("light"));
|
|
assert_eq!(c.appearance.accent.as_deref(), Some("teal"));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_sections_default() {
|
|
let c = Config::from_path(Path::new("/no/such/cfg.toml"));
|
|
assert!(c.terminal.font_family.is_none());
|
|
assert!(c.appearance.theme.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn to_view_applies_defaults_and_clamp() {
|
|
let mut c = Config::default();
|
|
c.terminal.font_size = Some(99);
|
|
let v = c.to_view();
|
|
assert_eq!(v.font_size, 20);
|
|
assert_eq!(v.theme, "dark");
|
|
assert_eq!(v.accent, "blue");
|
|
assert!(!v.font_family.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn save_then_reload_round_trips() {
|
|
let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("config.toml");
|
|
let mut c = Config::default();
|
|
c.terminal.font_size = Some(14);
|
|
c.appearance.accent = Some("purple".into());
|
|
c.save_to(&path).unwrap();
|
|
let back = Config::from_path(&path);
|
|
assert_eq!(back.terminal.font_size, Some(14));
|
|
assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|