From 6a3875670a5fd1961edd5598ba21ba9e981a9845 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 08:01:04 +0700 Subject: [PATCH] feat(spaceshd): configurable default shell Add a config.rs module that loads optional ~/.spacesh/config.toml and a default_shell() resolver: 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 absent. All plain-panel spawn sites in server.rs now use the resolver. Adds toml + libc deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 83 ++++++++++++++++++++++++++++ Cargo.toml | 2 + crates/spaceshd/Cargo.toml | 2 + crates/spaceshd/src/config.rs | 101 ++++++++++++++++++++++++++++++++++ crates/spaceshd/src/main.rs | 1 + crates/spaceshd/src/server.rs | 6 +- 6 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 crates/spaceshd/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 21360d4..99ddc86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,6 +246,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -382,6 +388,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -403,6 +415,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ioctl-rs" version = "0.1.6" @@ -736,6 +758,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial" version = "0.4.0" @@ -889,6 +920,7 @@ dependencies = [ "dirs", "fs2", "futures", + "libc", "serde", "serde_json", "spacesh-core", @@ -897,6 +929,7 @@ dependencies = [ "thiserror", "tokio", "tokio-util", + "toml", ] [[package]] @@ -986,6 +1019,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1200,6 +1274,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 849d892..f22dd74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,5 +26,7 @@ portable-pty = "0.8" alacritty_terminal = "0.25" fs2 = "0.4" dirs = "5" +toml = "0.8" +libc = "0.2" clap = { version = "4", features = ["derive"] } clap_complete = "4" diff --git a/crates/spaceshd/Cargo.toml b/crates/spaceshd/Cargo.toml index b156157..47b6fdc 100644 --- a/crates/spaceshd/Cargo.toml +++ b/crates/spaceshd/Cargo.toml @@ -22,3 +22,5 @@ thiserror.workspace = true futures.workspace = true fs2.workspace = true dirs.workspace = true +toml.workspace = true +libc.workspace = true diff --git a/crates/spaceshd/src/config.rs b/crates/spaceshd/src/config.rs new file mode 100644 index 0000000..3197e5f --- /dev/null +++ b/crates/spaceshd/src/config.rs @@ -0,0 +1,101 @@ +//! 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; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + /// Shell launched for plain (no-command) panels. When unset, the daemon + /// auto-detects the user's login shell. + #[serde(default)] + pub default_shell: Option, +} + +impl Config { + /// 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(), + } + } +} + +/// 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). +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 current user's login shell from the passwd database (`getpwuid`). +#[cfg(unix)] +fn login_shell() -> Option { + 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 { 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"); + } +} diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index 054364c..e693b90 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -1,3 +1,4 @@ +mod config; mod event_log; mod event_store; mod hooks; diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 8cced31..e5a9c2b 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -298,7 +298,7 @@ async fn handle_request( let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; let sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let shell = command.clone().unwrap_or_else(crate::config::default_shell); let spec = SurfaceSpec { command: shell, args: args.clone(), cwd: ws.path.clone(), agent_label: command, cols, rows, autostart: false, @@ -333,7 +333,7 @@ async fn handle_request( }; let ws = reg.workspace(&ws_id).cloned().unwrap(); let new_sid = reg.new_surface_id(); - let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let shell = command.clone().unwrap_or_else(crate::config::default_shell); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec); match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) { @@ -406,7 +406,7 @@ async fn handle_request( let slot = slots.get(i); let new_sid = reg.new_surface_id(); let command = slot.and_then(|s| s.command.clone()); - let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let shell = command.clone().unwrap_or_else(crate::config::default_shell); let args = slot.map(|s| s.args.clone()).unwrap_or_default(); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; let (env, hooks_active) = spawn_env(&new_sid, &spec);