Files
spaceshell/crates/spaceshd/src/config.rs
T

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);
}
}