diff --git a/crates/ghostty-os/Cargo.toml b/crates/ghostty-os/Cargo.toml index ef24850..25836eb 100644 --- a/crates/ghostty-os/Cargo.toml +++ b/crates/ghostty-os/Cargo.toml @@ -5,7 +5,16 @@ edition.workspace = true license.workspace = true [dependencies] +gettext-rs = { version = "0.8.0", features = ["gettext-system"] } +log = "0.4.34" +nix = { version = "0.31.3", features = ["user"] } +rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand"] } +thiserror = "2.0.21" +zbus = "5.19.0" [lints] workspace = true + +[dev-dependencies] +temp-env = "0.3.6" diff --git a/crates/ghostty-os/src/args.rs b/crates/ghostty-os/src/args.rs new file mode 100644 index 0000000..2cf10ae --- /dev/null +++ b/crates/ghostty-os/src/args.rs @@ -0,0 +1,21 @@ +//! Command line argument iteration. +//! +//! The macOS NSProcessInfo-based iterator is skipped; on Linux this is the +//! standard process argument iterator (as `os_str`s so that non-UTF-8 args +//! don't panic). + +/// Returns an iterator over the command line arguments. +pub fn iterator() -> std::env::ArgsOs { + std::env::args_os() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn args() { + let mut iter = iterator(); + assert!(!iter.next().unwrap().is_empty()); + } +} diff --git a/crates/ghostty-os/src/cgroup.rs b/crates/ghostty-os/src/cgroup.rs new file mode 100644 index 0000000..dc8a214 --- /dev/null +++ b/crates/ghostty-os/src/cgroup.rs @@ -0,0 +1,40 @@ +use std::io::Read; + +/// Maximum number of bytes read from the cgroup file (the Zig version reads +/// into a caller-provided buffer; callers there use path-sized buffers). +const READ_LIMIT: u64 = 4096; + +/// Returns the path to the cgroup for the given pid. +pub fn current(pid: u32) -> Option { + // Read our cgroup by opening /proc//cgroup and reading the first + // line. The first line will look something like this: + // 0::/user.slice/user-1000.slice/session-1.scope + // The cgroup path is the third field. + let file = std::fs::File::open(format!("/proc/{pid}/cgroup")).ok()?; + let mut contents = Vec::new(); + file.take(READ_LIMIT).read_to_end(&mut contents).ok()?; + + // Find the last ':' + let idx = contents.iter().rposition(|&b| b == b':')?; + let rest = &contents[idx + 1..]; + let end = rest + .iter() + .rposition(|b| !matches!(b, b' ' | b'\r' | b'\n')) + .map_or(0, |i| i + 1); + Some(String::from_utf8_lossy(&rest[..end]).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_cgroup_of_self() { + // Not part of the Zig tests; sanity check that parsing works when + // /proc is available. + if let Some(p) = current(std::process::id()) { + assert!(!p.ends_with('\n')); + } + assert!(current(u32::MAX).is_none()); + } +} diff --git a/crates/ghostty-os/src/dbus.rs b/crates/ghostty-os/src/dbus.rs new file mode 100644 index 0000000..131f65b --- /dev/null +++ b/crates/ghostty-os/src/dbus.rs @@ -0,0 +1,36 @@ +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +pub fn launched_by_dbus_activation() -> bool { + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + std::env::var_os("DBUS_STARTER_ADDRESS").is_some() + && std::env::var_os("DBUS_STARTER_BUS_TYPE").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::with_env_lock; + + #[test] + fn dbus_activation_env() { + temp_env::with_vars( + [ + ("DBUS_STARTER_ADDRESS", Some("x")), + ("DBUS_STARTER_BUS_TYPE", Some("session")), + ], + || assert!(launched_by_dbus_activation()), + ); + temp_env::with_vars( + [ + ("DBUS_STARTER_ADDRESS", Some("x")), + ("DBUS_STARTER_BUS_TYPE", None), + ], + || assert!(!launched_by_dbus_activation()), + ); + with_env_lock(|| {}); + } +} diff --git a/crates/ghostty-os/src/desktop.rs b/crates/ghostty-os/src/desktop.rs new file mode 100644 index 0000000..21e1ed1 --- /dev/null +++ b/crates/ghostty-os/src/desktop.rs @@ -0,0 +1,112 @@ +/// Returns true if the program was launched from a desktop environment. +/// +/// On Linux GTK, this returns true if the program was launched using the +/// desktop file. This also includes when `gtk-launch` is used because I +/// can't find a way to distinguish the two scenarios. +pub fn launched_from_desktop() -> bool { + // On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and + // GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if + // we match the PID and assume that if we do, we were launched from + // the desktop file. Pid comparing catches the scenario where + // another terminal was launched from a desktop file and then launches + // Ghostty and Ghostty inherits the env. + let Some(gio_pid_str) = std::env::var_os("GIO_LAUNCHED_DESKTOP_FILE_PID") else { + return false; + }; + + let pid = rustix::process::getpid().as_raw_nonzero().get(); + let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::() else { + return false; + }; + + gio_pid == pid +} + +/// The list of desktop environments that we detect. New Linux desktop +/// environments should only be added to this list if there's a specific reason +/// to differentiate between `gnome` and `other`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DesktopEnvironment { + Gnome, + Macos, + Other, + Windows, +} + +/// Detect what desktop environment we are running under. This is mainly used +/// on Linux and BSD to enable or disable certain features but there may be more uses in +/// the future. +pub fn desktop_environment() -> DesktopEnvironment { + // Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux + // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= + if let Some(sd) = std::env::var_os("XDG_SESSION_DESKTOP") { + let sd = sd.to_string_lossy(); + if sd.eq_ignore_ascii_case("gnome") { + return DesktopEnvironment::Gnome; + } + if sd.eq_ignore_ascii_case("gnome-xorg") { + return DesktopEnvironment::Gnome; + } + } + + // If $XDG_SESSION_DESKTOP is not set, or doesn't match any known + // DE, check $XDG_CURRENT_DESKTOP. $XDG_CURRENT_DESKTOP is a + // colon-separated list of up to three desktop names, although we + // only look at the first. + // https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html + if let Some(cd) = std::env::var_os("XDG_CURRENT_DESKTOP") { + let cd = cd.to_string_lossy(); + let first = cd.split(':').next().unwrap_or(""); + if first.eq_ignore_ascii_case("gnome") { + return DesktopEnvironment::Gnome; + } + } + + DesktopEnvironment::Other +} + +#[cfg(test)] +mod tests { + use super::*; + + fn de(session: Option<&str>, current: Option<&str>) -> DesktopEnvironment { + temp_env::with_vars( + [ + ("XDG_SESSION_DESKTOP", session), + ("XDG_CURRENT_DESKTOP", current), + ], + desktop_environment, + ) + } + + #[test] + fn desktop_environment_detection() { + use DesktopEnvironment::{Gnome, Other}; + assert_eq!(de(Some("gnome"), None), Gnome); + assert_eq!(de(Some("gnome-xorg"), None), Gnome); + assert_eq!(de(Some("foobar"), None), Other); + assert_eq!(de(None, None), Other); + assert_eq!(de(None, Some("GNOME")), Gnome); + assert_eq!(de(None, Some("FOOBAR")), Other); + assert_eq!(de(None, Some("GNOME:Unity")), Gnome); + assert_eq!(de(None, Some("Unity:GNOME")), Other); + assert_eq!(de(Some("foobar"), Some("GNOME")), Gnome); + } + + #[test] + fn launched_from_desktop_pid_match() { + let pid = std::process::id().to_string(); + temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some(pid), || { + assert!(launched_from_desktop()); + }); + temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some("1"), || { + assert!(!launched_from_desktop()); + }); + temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", Some("abc"), || { + assert!(!launched_from_desktop()); + }); + temp_env::with_var("GIO_LAUNCHED_DESKTOP_FILE_PID", None::<&str>, || { + assert!(!launched_from_desktop()); + }); + } +} diff --git a/crates/ghostty-os/src/env.rs b/crates/ghostty-os/src/env.rs new file mode 100644 index 0000000..937929d --- /dev/null +++ b/crates/ghostty-os/src/env.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +use crate::flatpak::is_flatpak; + +/// PATH-style delimiter (`std.fs.path.delimiter` on POSIX). +const DELIMITER: char = ':'; + +/// Get the environment map. Inside Flatpak this is empty (the sandbox +/// environment is not meaningful for host commands). +pub fn get_env_map() -> HashMap { + if is_flatpak() { + return HashMap::new(); + } + std::env::vars_os() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.to_string_lossy().into_owned(), + ) + }) + .collect() +} + +/// Append a value to an environment variable such as PATH. +pub fn append_env(current: &str, value: &str) -> String { + // If there is no prior value, we return it as-is + if current.is_empty() { + return value.to_owned(); + } + + // Otherwise we must prefix. + append_env_always(current, value) +} + +/// Always append value to environment, even when it is empty. +/// This is useful because some env vars (like MANPATH) want there +/// to be an empty prefix to preserve existing values. +pub fn append_env_always(current: &str, value: &str) -> String { + format!("{current}{DELIMITER}{value}") +} + +/// Prepend a value to an environment variable such as PATH. +pub fn prepend_env(current: &str, value: &str) -> String { + // If there is no prior value, we return it as-is + if current.is_empty() { + return value.to_owned(); + } + + format!("{value}{DELIMITER}{current}") +} + +/// Gets the value of an environment variable, or None if not found +/// (or not valid unicode). +pub fn getenv(key: &str) -> Option { + std::env::var(key).ok() +} + +/// Gets the value of an environment variable. Returns None if not found or +/// the value is empty. +pub fn getenv_not_empty(key: &str) -> Option { + getenv(key).filter(|v| !v.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_env_empty() { + assert_eq!(append_env("", "foo"), "foo"); + } + + #[test] + fn append_env_existing() { + assert_eq!(append_env("a:b", "foo"), "a:b:foo"); + } + + #[test] + fn prepend_env_empty() { + assert_eq!(prepend_env("", "foo"), "foo"); + } + + #[test] + fn prepend_env_existing() { + assert_eq!(prepend_env("a:b", "foo"), "foo:a:b"); + } + + #[test] + fn append_env_always_empty_value() { + assert_eq!(append_env_always("a", ""), "a:"); + assert_eq!(append_env_always("", "x"), ":x"); + } + + #[test] + fn getenv_not_empty_filters() { + temp_env::with_vars( + [("GHOSTTY_RS_T1", Some("")), ("GHOSTTY_RS_T2", Some("v"))], + || { + assert_eq!(getenv("GHOSTTY_RS_T1").as_deref(), Some("")); + assert_eq!(getenv_not_empty("GHOSTTY_RS_T1"), None); + assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").as_deref(), Some("v")); + assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None); + }, + ); + } +} diff --git a/crates/ghostty-os/src/file.rs b/crates/ghostty-os/src/file.rs new file mode 100644 index 0000000..40e854b --- /dev/null +++ b/crates/ghostty-os/src/file.rs @@ -0,0 +1,101 @@ +use rustix::process::{Resource, Rlimit as RustixRlimit, getrlimit, setrlimit}; + +/// Resource limit (`rlimit` in Zig). `None` means infinity. +pub type Rlimit = RustixRlimit; + +/// rlim_t value for infinity in the Zig implementation. +const INFINITY: u64 = u64::MAX; + +fn cur(l: &Rlimit) -> u64 { + l.current.unwrap_or(INFINITY) +} +fn max(l: &Rlimit) -> u64 { + l.maximum.unwrap_or(INFINITY) +} +fn mk(cur: u64, max: Option) -> Rlimit { + Rlimit { + current: if cur == INFINITY { None } else { Some(cur) }, + maximum: max, + } +} + +/// This maximizes the number of file descriptors we can have open. We +/// need to do this because each window consumes at least a handful of fds. +/// This is extracted from the Zig compiler source code. +pub fn fix_max_files() -> Option { + let old = getrlimit(Resource::Nofile); + + // If we're already at the max, we're done. + if cur(&old) >= max(&old) { + log::debug!("file handle limit already maximized value={}", cur(&old)); + return Some(old); + } + + // Do a binary search for the limit. + let mut lim = old; + let mut min_v: u64 = cur(&lim); + let mut max_v: u64 = 1 << 20; + // But if there's a defined upper bound, don't search, just set it. + if max(&lim) != INFINITY { + min_v = max(&lim); + max_v = max(&lim); + } + + loop { + let c = min_v + (max_v - min_v) / 2; + lim = mk(c, lim.maximum); + if setrlimit(Resource::Nofile, lim).is_ok() { + min_v = c; + } else { + max_v = c; + } + if min_v + 1 >= max_v { + break; + } + } + + log::debug!("file handle limit raised value={}", cur(&lim)); + Some(old) +} + +pub fn restore_max_files(lim: Rlimit) { + let _ = setrlimit(Resource::Nofile, lim); +} + +/// Return the recommended path for temporary files. +pub fn alloc_tmp_dir() -> Option { + if let Some(v) = std::env::var_os("TMPDIR") { + return Some(v.to_string_lossy().into_owned()); + } + if let Some(v) = std::env::var_os("TMP") { + return Some(v.to_string_lossy().into_owned()); + } + Some("/tmp".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tmp_dir_env_precedence() { + temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || { + assert_eq!(alloc_tmp_dir().as_deref(), Some("/a")); + }); + temp_env::with_vars([("TMPDIR", None), ("TMP", Some("/b"))], || { + assert_eq!(alloc_tmp_dir().as_deref(), Some("/b")); + }); + temp_env::with_vars([("TMPDIR", None::<&str>), ("TMP", None)], || { + assert_eq!(alloc_tmp_dir().as_deref(), Some("/tmp")); + }); + } + + #[test] + fn fix_and_restore_max_files() { + let old = fix_max_files().unwrap(); + let now = getrlimit(Resource::Nofile); + assert!(cur(&now) >= cur(&old)); + restore_max_files(old); + assert_eq!(getrlimit(Resource::Nofile), old); + } +} diff --git a/crates/ghostty-os/src/file_type.rs b/crates/ghostty-os/src/file_type.rs new file mode 100644 index 0000000..db4816c --- /dev/null +++ b/crates/ghostty-os/src/file_type.rs @@ -0,0 +1,194 @@ +//! Port of `src/file_type.zig`. + +struct TypeDetails { + typ: FileType, + sigs: &'static [&'static [Option]], + exts: &'static [&'static str], +} + +#[allow(clippy::unnecessary_wraps)] +const fn b(v: u8) -> Option { + Some(v) +} + +const TYPE_DETAILS: &[TypeDetails] = &[ + TypeDetails { + typ: FileType::Jpeg, + sigs: &[ + &[b(0xFF), b(0xD8), b(0xFF), b(0xDB)], + &[ + b(0xFF), + b(0xD8), + b(0xFF), + b(0xE0), + b(0x00), + b(0x10), + b(0x4A), + b(0x46), + b(0x49), + b(0x46), + b(0x00), + b(0x01), + ], + &[b(0xFF), b(0xD8), b(0xFF), b(0xEE)], + &[ + b(0xFF), + b(0xD8), + b(0xFF), + b(0xE1), + None, + None, + b(0x45), + b(0x78), + b(0x69), + b(0x66), + b(0x00), + b(0x00), + ], + &[b(0xFF), b(0xD8), b(0xFF), b(0xE0)], + ], + exts: &[".jpg", ".jpeg", ".jfif"], + }, + TypeDetails { + typ: FileType::Png, + sigs: &[&[ + b(0x89), + b(0x50), + b(0x4E), + b(0x47), + b(0x0D), + b(0x0A), + b(0x1A), + b(0x0A), + ]], + exts: &[".png"], + }, + TypeDetails { + typ: FileType::Gif, + sigs: &[ + &[b(b'G'), b(b'I'), b(b'F'), b(b'8'), b(b'7'), b(b'a')], + &[b(b'G'), b(b'I'), b(b'F'), b(b'8'), b(b'9'), b(b'a')], + ], + exts: &[".gif"], + }, + TypeDetails { + typ: FileType::Bmp, + sigs: &[&[b(b'B'), b(b'M')]], + exts: &[".bmp"], + }, + TypeDetails { + typ: FileType::Qoi, + sigs: &[&[b(b'q'), b(b'o'), b(b'i'), b(b'f')]], + exts: &[".qoi"], + }, + TypeDetails { + typ: FileType::Webp, + sigs: &[&[ + b(0x52), + b(0x49), + b(0x46), + b(0x46), + None, + None, + None, + None, + b(0x57), + b(0x45), + b(0x42), + b(0x50), + ]], + exts: &[".webp"], + }, +]; + +/// This is a helper for detecting file types based on magic bytes. +/// +/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileType { + /// JPEG image file. + Jpeg, + /// PNG image file. + Png, + /// GIF image file. + Gif, + /// BMP image file. + Bmp, + /// QOI image file. + Qoi, + /// WebP image file. + Webp, + /// Unknown file format. + Unknown, +} + +impl FileType { + /// Detect file type based on the magic bytes + /// at the start of the provided file contents. + pub fn detect(contents: &[u8]) -> FileType { + for typ in TYPE_DETAILS { + for signature in typ.sigs { + if contents.len() >= signature.len() + && contents + .iter() + .zip(signature.iter()) + .all(|(f, sig)| sig.is_none_or(|s| *f == s)) + { + return typ.typ; + } + } + } + FileType::Unknown + } + + /// Guess file type from its extension. + pub fn guess_from_extension(extension: &str) -> FileType { + for typ in TYPE_DETAILS { + for ext in typ.exts { + if extension.eq_ignore_ascii_case(ext) { + return typ.typ; + } + } + } + FileType::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The Zig file has no tests; these cover the detection logic. + #[test] + fn detect_magic_bytes() { + assert_eq!( + FileType::detect(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1]), + FileType::Png + ); + assert_eq!(FileType::detect(b"GIF89a...."), FileType::Gif); + assert_eq!(FileType::detect(b"BM"), FileType::Bmp); + assert_eq!(FileType::detect(b"qoif"), FileType::Qoi); + assert_eq!( + FileType::detect(b"RIFF\x01\x02\x03\x04WEBP"), + FileType::Webp + ); + assert_eq!( + FileType::detect(b"RIFF\x01\x02\x03\x04WEBX"), + FileType::Unknown + ); + assert_eq!( + FileType::detect(&[0xFF, 0xD8, 0xFF, 0xE1, 9, 9, 0x45, 0x78, 0x69, 0x66, 0, 0]), + FileType::Jpeg + ); + assert_eq!(FileType::detect(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg); + assert_eq!(FileType::detect(b""), FileType::Unknown); + } + + #[test] + fn guess_from_extension_case_insensitive() { + assert_eq!(FileType::guess_from_extension(".JPG"), FileType::Jpeg); + assert_eq!(FileType::guess_from_extension(".jfif"), FileType::Jpeg); + assert_eq!(FileType::guess_from_extension(".webp"), FileType::Webp); + assert_eq!(FileType::guess_from_extension(".txt"), FileType::Unknown); + } +} diff --git a/crates/ghostty-os/src/flatpak.rs b/crates/ghostty-os/src/flatpak.rs new file mode 100644 index 0000000..aab066c --- /dev/null +++ b/crates/ghostty-os/src/flatpak.rs @@ -0,0 +1,400 @@ +use std::collections::HashMap; +use std::os::fd::{AsFd, OwnedFd}; +use std::sync::{Arc, Condvar, Mutex}; + +use zbus::blocking::{Connection, MessageIterator}; +use zbus::zvariant::Fd; +use zbus::{MatchRule, message::Type as MessageType}; + +const BUS_NAME: &str = "org.freedesktop.Flatpak"; +const OBJECT_PATH: &str = "/org/freedesktop/Flatpak/Development"; +const INTERFACE: &str = "org.freedesktop.Flatpak.Development"; + +/// Returns true if we're running in a Flatpak environment. +pub fn is_flatpak() -> bool { + std::path::Path::new("/.flatpak-info").exists() +} + +/// Errors that are possible from us. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("flatpak host command must be started")] + FlatpakMustBeStarted, + #[error("flatpak spawn failed")] + FlatpakSpawnFail, + #[error("flatpak setup failed")] + FlatpakSetupFail, + #[error("flatpak rpc failed")] + FlatpakRPCFail, +} + +/// State the process is in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Initial state + Init, + /// Error starting. The error message is only available via logs. + Err, + /// Process started with the given pid on the host. + Started { pid: u32 }, + /// Process exited + Exited { pid: u32, status: u8 }, +} + +/// State shared with the dedicated thread, protected by the lock and +/// condition variable. +struct Shared { + state: Mutex, + cv: Condvar, +} + +impl Shared { + fn update(&self, state: State) { + let mut guard = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = state; + self.cv.notify_all(); + } +} + +/// A struct to help execute commands on the host via the +/// org.freedesktop.Flatpak.Development DBus module. This uses `zbus` +/// (blocking API) rather than GIO/GLib. +/// +/// This always spawns its own thread to wait for the exit signal. This makes +/// it easy for the command to behave synchronously similar to +/// `std::process::Child`. +/// +/// There are lots of chances for low-hanging improvements here (automatic +/// pipes, /dev/null, etc.) but this was purpose built for a specific need so +/// it doesn't have all of those. +/// +/// NOTE: this can't be exercised in the test environment (needs a Flatpak +/// session bus with the Development interface); it is a best-effort port. +pub struct FlatpakHostCommand { + /// Argv are the arguments to call on the host with argv[0] being + /// the command to execute. + pub argv: Vec, + + /// The cwd for the new process. If this is not set then it will use + /// the current cwd of the calling process. + pub cwd: Option, + + /// Environment variables for the child process. If this is None, this + /// does not send any environment variables. + pub env: Option>, + + /// File descriptors to send to the child process. It is up to the + /// caller to create the file descriptors and set them up. They are + /// duplicated when spawning so the caller keeps ownership. + pub stdin: OwnedFd, + pub stdout: OwnedFd, + pub stderr: OwnedFd, + + /// Connection kept for sending signals. + shared: Arc, +} + +impl FlatpakHostCommand { + pub fn new(argv: Vec, stdin: OwnedFd, stdout: OwnedFd, stderr: OwnedFd) -> Self { + Self { + argv, + cwd: None, + env: None, + stdin, + stdout, + stderr, + shared: Arc::new(Shared { + state: Mutex::new(State::Init), + cv: Condvar::new(), + }), + } + } + + fn state(&self) -> State { + *self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Spawn the command. This will start the host command. On return, + /// the pid will be available. This must only be called with the + /// state in "init". + pub fn spawn(&mut self) -> Result { + let request = Request { + argv: self.argv.clone(), + cwd: self.cwd.clone(), + env: self.env.clone().unwrap_or_default(), + fds: [ + self.stdin + .try_clone() + .map_err(|_| Error::FlatpakSetupFail)?, + self.stdout + .try_clone() + .map_err(|_| Error::FlatpakSetupFail)?, + self.stderr + .try_clone() + .map_err(|_| Error::FlatpakSetupFail)?, + ], + }; + let shared = Arc::clone(&self.shared); + + // We don't track this thread, it will terminate on its own on + // command exit. + std::thread::Builder::new() + .name("flatpak-host-command".into()) + .spawn(move || thread_main(&shared, request)) + .map_err(|_| Error::FlatpakSpawnFail)?; + + // Wait for the process to start or error. + let mut guard = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while *guard == State::Init { + guard = self + .shared + .cv + .wait(guard) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + + match *guard { + State::Init => unreachable!(), + State::Err => Err(Error::FlatpakSpawnFail), + State::Started { pid } | State::Exited { pid, .. } => Ok(pid), + } + } + + /// Wait for the process to end and return the exit status. This + /// can only be called ONCE. Once this returns, the state is reset. + pub fn wait(&mut self) -> Result { + let mut guard = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + match *guard { + State::Init => return Err(Error::FlatpakMustBeStarted), + State::Err => return Err(Error::FlatpakSpawnFail), + State::Started { .. } => {} + State::Exited { status, .. } => { + *guard = State::Init; + self.shared.cv.notify_all(); + return Ok(status); + } + } + + guard = self + .shared + .cv + .wait(guard) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + + /// Wait for the process to end asynchronously. The callback is invoked + /// from a helper thread with the result. This can only be called ONCE. + /// + /// This replaces `waitXev` (libxev completion) of the Zig version. + pub fn wait_callback( + mut self, + callback: impl FnOnce(Result) + Send + 'static, + ) -> std::io::Result<()> { + std::thread::Builder::new() + .name("flatpak-host-wait".into()) + .spawn(move || callback(self.wait()))?; + Ok(()) + } + + /// Send a signal to the started command. This does nothing if the + /// command is not in the started state. + pub fn signal(&self, sig: u8, pg: bool) -> Result<(), Error> { + let State::Started { pid } = self.state() else { + return Ok(()); + }; + + // Get our bus connection. + let bus = Connection::session().map_err(|e| { + log::warn!("signal error getting bus: {e}"); + Error::FlatpakSetupFail + })?; + + if let Err(e) = bus.call_method( + Some(BUS_NAME), + OBJECT_PATH, + Some(INTERFACE), + "HostCommandSignal", + &(pid, u32::from(sig), pg), + ) { + log::warn!("signal send error: {e}"); + } + Ok(()) + } +} + +struct Request { + argv: Vec, + cwd: Option, + env: HashMap, + fds: [OwnedFd; 3], +} + +fn thread_main(shared: &Shared, request: Request) { + // Get our bus connection. This has to remain active until we exit + // the thread otherwise our signals won't be called. + let bus = match Connection::session() { + Ok(b) => b, + Err(e) => { + log::warn!("spawn error getting bus: {e}"); + shared.update(State::Err); + return; + } + }; + + // Spawn the command first. This will setup all our IO. + match start(shared, &bus, request) { + Ok(iter_and_pid) => { + let (iter, pid) = iter_and_pid; + wait_for_exit(shared, iter, pid); + } + Err(err) => { + log::warn!("error starting host command: {err}"); + shared.update(State::Err); + } + } +} + +/// Start the command. This will start the host command and set the +/// pid on success. This will not wait for completion. +fn start( + shared: &Shared, + bus: &Connection, + request: Request, +) -> Result<(MessageIterator, u32), Error> { + // Build our file descriptor map: 0 -> stdin, 1 -> stdout, 2 -> stderr. + let mut fds: HashMap> = HashMap::new(); + for (i, fd) in request.fds.iter().enumerate() { + fds.insert(i as u32, Fd::from(fd.as_fd())); + } + + // Build our args (arrays of NUL-terminated byte strings). + let args: Vec> = request + .argv + .iter() + .map(|a| { + let mut v = a.as_bytes().to_vec(); + v.push(0); + v + }) + .collect(); + + // Get the cwd in case we don't have ours set. + let cwd = match request.cwd { + Some(c) => c, + None => std::env::current_dir() + .map_err(|_| Error::FlatpakSetupFail)? + .to_string_lossy() + .into_owned(), + }; + let mut cwd = cwd.into_bytes(); + cwd.push(0); + + // Subscribe to exit notifications (before the call so we can't miss it). + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .sender(BUS_NAME) + .and_then(|b| b.interface(INTERFACE)) + .and_then(|b| b.member("HostCommandExited")) + .and_then(|b| b.path(OBJECT_PATH)) + .map(zbus::match_rule::Builder::build) + .map_err(|e| { + log::warn!("error building match rule: {e}"); + Error::FlatpakSetupFail + })?; + let iter = MessageIterator::for_match_rule(rule, bus, None).map_err(|e| { + log::warn!("error subscribing to HostCommandExited: {e}"); + Error::FlatpakSetupFail + })?; + + // Go! + let reply = bus + .call_method( + Some(BUS_NAME), + OBJECT_PATH, + Some(INTERFACE), + "HostCommand", + &(cwd, args, fds, request.env, 0u32), + ) + .map_err(|e| { + log::warn!("Flatpak.HostCommand failed: {e}"); + Error::FlatpakRPCFail + })?; + + let pid: u32 = reply.body().deserialize().map_err(|e| { + log::warn!("Flatpak.HostCommand bad reply: {e}"); + Error::FlatpakRPCFail + })?; + log::debug!("HostCommand started pid={pid}"); + + shared.update(State::Started { pid }); + Ok((iter, pid)) +} + +/// Block on the exit signal for our pid and publish the exit status. +fn wait_for_exit(shared: &Shared, iter: MessageIterator, pid: u32) { + for msg in iter { + let Ok(msg) = msg else { continue }; + let Ok((exit_pid, exit_status_raw)) = msg.body().deserialize::<(u32, u32)>() else { + continue; + }; + if exit_pid != pid { + continue; + } + + // posix.W.EXITSTATUS + let status = ((exit_status_raw >> 8) & 0xff) as u8; + shared.update(State::Exited { pid, status }); + log::debug!("HostCommand exited pid={pid} status={status}"); + return; + } + + // The connection closed without an exit signal. + log::warn!("host command signal stream ended without exit pid={pid}"); + shared.update(State::Err); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_flatpak_matches_flatpak_info() { + assert_eq!( + is_flatpak(), + std::path::Path::new("/.flatpak-info").exists() + ); + } + + #[test] + fn wait_before_spawn_errors() { + let (r, w) = crate::pipe::pipe().unwrap(); + let w2 = w.try_clone().unwrap(); + let mut cmd = FlatpakHostCommand::new(vec!["true".into()], r, w, w2); + assert_eq!(cmd.wait(), Err(Error::FlatpakMustBeStarted)); + // Signalling an unstarted command is a no-op. + assert_eq!(cmd.signal(15, false), Ok(())); + } + + #[test] + fn exit_status_extraction() { + // Mirrors posix.W.EXITSTATUS on a raw wait status. + assert_eq!(((3u32 << 8) >> 8) & 0xff, 3); + } +} diff --git a/crates/ghostty-os/src/homedir.rs b/crates/ghostty-os/src/homedir.rs new file mode 100644 index 0000000..a0024b6 --- /dev/null +++ b/crates/ghostty-os/src/homedir.rs @@ -0,0 +1,152 @@ +use std::process::{Command, Stdio}; + +use crate::passwd; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + /// The buffer used for output is not large enough to store the value. + #[error("buffer too small")] + BufferTooSmall, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ExpandError { + #[error("home detection failed")] + HomeDetectionFailed, + #[error("buffer too small")] + BufferTooSmall, +} + +/// Equivalent of `std.fs.max_path_bytes` on Linux. +const MAX_PATH_BYTES: usize = 4096; + +/// Determine the home directory for the currently executing user. This +/// is generally an expensive process so the value should be cached. +pub fn home() -> Result, Error> { + home_with_capacity(MAX_PATH_BYTES) +} + +/// Like [`home`] but emulates the fixed output buffer of the Zig version: +/// errors with `BufferTooSmall` if the result is longer than `cap` bytes. +pub fn home_with_capacity(cap: usize) -> Result, Error> { + let check = |s: String| { + if cap < s.len() { + Err(Error::BufferTooSmall) + } else { + Ok(Some(s)) + } + }; + + // First: if we have a HOME env var, then we use that. + if let Some(result) = std::env::var_os("HOME") { + return check(result.to_string_lossy().into_owned()); + } + + // We try passwd. This doesn't work on multi-user mac but we try it anyways. + if let Ok(pw) = passwd::get() + && let Some(result) = pw.home + { + return check(result); + } + + // If all else fails, have the shell tell us... + let run = Command::new("/bin/sh") + .args(["-c", "cd && pwd"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output(); + if let Ok(run) = run + && run.status.success() + { + let out = String::from_utf8_lossy(&run.stdout); + return check(trim_space(&out).to_owned()); + } + + Ok(None) +} + +fn trim_space(input: &str) -> &str { + input.trim_matches([' ', '\n', '\t']) +} + +/// Expands a path that starts with a tilde (~) to the home directory of +/// the current user. +/// +/// Errors if `home` fails or if the size of the expanded path is larger +/// than the maximum path length. +pub fn expand_home(path: &str) -> Result { + expand_home_with_capacity(path, MAX_PATH_BYTES) +} + +/// Like [`expand_home`] with an explicit output capacity (emulating the +/// caller-provided buffer of the Zig version). +pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result { + if !path.starts_with("~/") { + return Ok(path.to_owned()); + } + let Ok(Some(home_dir)) = home_with_capacity(cap) else { + return Err(ExpandError::HomeDetectionFailed); + }; + let rest = &path[1..]; // Skip the ~ + let expanded_len = home_dir.len() + rest.len(); + + if expanded_len > cap { + return Err(ExpandError::BufferTooSmall); + } + + Ok(format!("{home_dir}{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::with_env_lock; + + #[test] + fn expand_home_unix() { + with_env_lock(|| { + let cap = MAX_PATH_BYTES; + let home_dir = expand_home_with_capacity("~/", cap).unwrap(); + // Joining the home directory `~` with the path `/` + // the result should end with a separator here. (e.g. `/home/user/`) + assert!(home_dir.ends_with('/')); + + let downloads = expand_home_with_capacity("~/Downloads/shader.glsl", cap).unwrap(); + let expected_downloads = format!("{home_dir}Downloads/shader.glsl"); + assert_eq!(expected_downloads, downloads); + + assert_eq!("~", expand_home_with_capacity("~", cap).unwrap()); + assert_eq!("~abc/", expand_home_with_capacity("~abc/", cap).unwrap()); + assert_eq!( + "/home/user", + expand_home_with_capacity("/home/user", cap).unwrap() + ); + assert_eq!("", expand_home_with_capacity("", cap).unwrap()); + + // Expect an error if the buffer is large enough to hold the home directory, + // but not the expanded path + assert_eq!( + Err(ExpandError::BufferTooSmall), + expand_home_with_capacity("~/Downloads", home_dir.len()) + ); + }); + } + + #[test] + fn home_is_detected() { + with_env_lock(|| { + let result = home().unwrap(); + assert!(result.is_some()); + assert!(!result.unwrap().is_empty()); + }); + } + + #[test] + fn home_prefers_env() { + temp_env::with_var("HOME", Some("/some/home"), || { + assert_eq!(home().unwrap().as_deref(), Some("/some/home")); + assert_eq!(expand_home("~/x").unwrap(), "/some/home/x"); + assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall)); + }); + } +} diff --git a/crates/ghostty-os/src/hostname.rs b/crates/ghostty-os/src/hostname.rs new file mode 100644 index 0000000..9a7a37b --- /dev/null +++ b/crates/ghostty-os/src/hostname.rs @@ -0,0 +1,158 @@ +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum LocalHostnameValidationError { + #[error("permission denied")] + PermissionDenied, + #[error("unexpected error")] + Unexpected, +} + +/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123) +/// +/// A permissive validator would consider strings like ".example.com", +/// "exa..mple.com", and "-example.com" to be valid hostnames, which +/// is incorrect. +pub fn is_valid(hostname: &str) -> bool { + let hostname = hostname.as_bytes(); + if hostname.is_empty() { + return false; + } + if hostname[0] == b'.' { + return false; + } + + // Ignore trailing dot (FQDN). It doesn't count toward our length. + let end = if hostname[hostname.len() - 1] == b'.' { + if hostname.len() == 1 { + return false; + } + hostname.len() - 1 + } else { + hostname.len() + }; + + if end > 253 { + return false; + } + + // Hostnames are divided into dot-separated "labels", which: + // + // - Start with a letter or digit + // - Can contain letters, digits, or hyphens + // - Must end with a letter or digit + // - Have a minimum of 1 character and a maximum of 63 + let mut label_start = 0usize; + let mut label_len = 0usize; + for (i, &c) in hostname[..end].iter().enumerate() { + match c { + b'.' => { + if label_len == 0 || label_len > 63 { + return false; + } + if !hostname[label_start].is_ascii_alphanumeric() { + return false; + } + if !hostname[i - 1].is_ascii_alphanumeric() { + return false; + } + + label_start = i + 1; + label_len = 0; + } + b'-' => { + label_len += 1; + } + _ => { + if !c.is_ascii_alphanumeric() { + return false; + } + label_len += 1; + } + } + } + + // Validate the final label + if label_len == 0 || label_len > 63 { + return false; + } + if !hostname[label_start].is_ascii_alphanumeric() { + return false; + } + if !hostname[end - 1].is_ascii_alphanumeric() { + return false; + } + + true +} + +/// The current machine's hostname (`gethostname`). +fn our_hostname() -> String { + rustix::system::uname() + .nodename() + .to_string_lossy() + .into_owned() +} + +/// Checks if a hostname is local to the current machine. This matches +/// both "localhost" and the current hostname of the machine (as returned +/// by `gethostname`). +pub fn is_local(hostname: &str) -> Result { + // A 'localhost' hostname is always considered local. + if hostname == "localhost" { + return Ok(true); + } + + // If hostname is not "localhost" it must match our hostname. + Ok(hostname == our_hostname()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_valid_hostnames() { + // Valid hostnames + assert!(is_valid("example")); + assert!(is_valid("example.com")); + assert!(is_valid("www.example.com")); + assert!(is_valid("sub.domain.example.com")); + assert!(is_valid("example.com.")); + assert!(is_valid("host-name.example.com.")); + assert!(is_valid("123.example.com.")); + assert!(is_valid("a-b.com")); + assert!(is_valid("a.b.c.d.e.f.g")); + assert!(is_valid("127.0.0.1")); // Also a valid hostname + assert!(is_valid(&format!("{}.com", "a".repeat(63)))); // Label exactly 63 chars (valid) + assert!(is_valid(&format!("{}a", "a.".repeat(126)))); // Total length 253 (valid) + + // Invalid hostnames + assert!(!is_valid("")); + assert!(!is_valid(".example.com")); + assert!(!is_valid("example.com..")); + assert!(!is_valid("host..domain")); + assert!(!is_valid("-hostname")); + assert!(!is_valid("hostname-")); + assert!(!is_valid("a.-.b")); + assert!(!is_valid("host_name.com")); + assert!(!is_valid(".")); + assert!(!is_valid("..")); + assert!(!is_valid(&format!("{}.com", "a".repeat(64)))); // Label length 64 (too long) + assert!(!is_valid(&format!("{}ab", "a.".repeat(126)))); // Total length 254 (too long) + } + + #[test] + fn is_local_returns_true_when_provided_hostname_is_localhost() { + assert!(is_local("localhost").unwrap()); + } + + #[test] + fn is_local_returns_true_when_hostname_is_local() { + let local = our_hostname(); + assert!(is_local(&local).unwrap()); + } + + #[test] + fn is_local_returns_false_when_hostname_is_not_local() { + assert!(!is_local("not-the-local-hostname").unwrap()); + } +} diff --git a/crates/ghostty-os/src/i18n.rs b/crates/ghostty-os/src/i18n.rs new file mode 100644 index 0000000..5e499bd --- /dev/null +++ b/crates/ghostty-os/src/i18n.rs @@ -0,0 +1,194 @@ +use std::path::Path; + +use crate::i18n_locales::LOCALES; + +/// Application bundle ID, used as the gettext domain +/// (`build_config.bundle_id`). +// TODO(integration): take from a shared build-config crate. +pub const BUNDLE_ID: &str = "com.mitchellh.ghostty"; + +/// Membership test for supported locales (Zig: `locales_map`). +pub fn is_supported_locale(locale: &str) -> bool { + LOCALES.contains(&locale) +} + +#[derive(Debug, thiserror::Error)] +pub enum InitError { + #[error("invalid resources dir")] + InvalidResourcesDir, + #[error("out of memory")] + OutOfMemory, +} + +/// Initialize i18n support for the application. This should be +/// called automatically by the global state initialization. +/// +/// This calls `bindtextdomain` for gettext with the proper directory +/// of translations. This does NOT call `textdomain` as we don't +/// want to set the domain for the entire application since this is also +/// used by libghostty. +pub fn init(resources_dir: &str) -> Result<(), InitError> { + // Our resources dir is always nested below the share dir that + // is standard for translations. + // (`std.fs.path.dirname` returns null for a bare relative name, where + // `Path::parent` returns an empty path.) + let share_dir = Path::new(resources_dir) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .ok_or(InitError::InvalidResourcesDir)?; + + // Build our locale path + let path = format!("{}/locale", share_dir.display()); + + // Bind our bundle ID to the given locale path + log::debug!("binding domain={BUNDLE_ID} path={path}"); + gettextrs::bindtextdomain(BUNDLE_ID, path).map_err(|_| InitError::OutOfMemory)?; + Ok(()) +} + +/// Set the global gettext domain to our bundle ID, allowing unqualified +/// `gettext` calls to look up translations for our application. +/// +/// This should only be called for apprts that are fully owning the +/// Ghostty application. This should not be called for libghostty users. +pub fn init_global_domain() -> Result<(), InitError> { + gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?; + Ok(()) +} + +/// Translate a message for the Ghostty domain (Zig: `_`). +pub fn tr(msgid: &str) -> String { + gettextrs::dgettext(BUNDLE_ID, msgid) +} + +/// Canonicalize a locale name from a platform-specific value to +/// a POSIX-compliant value. +/// +/// The Zig version is a thin layer over gnulib's +/// `_libintl_locale_name_canonicalize` (only linked when building libintl +/// from source, i.e. macOS). That function isn't available here, so this is +/// a pure-Rust approximation of the cases that matter: BCP-47 style tags +/// have `-` replaced with `_` and the Chinese script tags are mapped to +/// their POSIX region equivalents. Encodings are NOT parsed out (like the +/// original, `en_US.UTF-8` becomes `en_US.UTF_8`). +pub fn canonicalize_locale(locale: &str) -> String { + // Fix zh locales + if let Some(fixed) = fix_zh_locale(locale) { + return fixed.to_owned(); + } + + // Emulate gnulib's mapping of bare zh script tags. + match locale { + "zh-Hans" => return "zh_CN".to_owned(), + "zh-Hant" => return "zh_TW".to_owned(), + _ => {} + } + + locale.replace('-', "_") +} + +/// Handles some zh locales canonicalization because internal libintl +/// canonicalization function doesn't handle correctly in these cases. +fn fix_zh_locale(locale: &str) -> Option<&'static str> { + let mut it = locale.split('-'); + let name = it.next()?; + if name != "zh" { + return None; + } + + let script = it.next()?; + let region = it.next()?; + + if script == "Hans" { + if region == "SG" { + return Some("zh_SG"); + } + return Some("zh_CN"); + } + + if script == "Hant" { + if region == "MO" { + return Some("zh_MO"); + } + if region == "HK" { + return Some("zh_HK"); + } + return Some("zh_TW"); + } + + None +} + +/// This can be called at any point a compile-time-known locale is +/// available. In a `const` context this verifies the locale is supported +/// (panics at compile time otherwise). +pub const fn static_locale(v: &'static str) -> &'static str { + let vb = v.as_bytes(); + let mut i = 0; + while i < LOCALES.len() { + let lb = LOCALES[i].as_bytes(); + if lb.len() == vb.len() { + let mut j = 0; + let mut eq = true; + while j < lb.len() { + if lb[j] != vb[j] { + eq = false; + break; + } + j += 1; + } + if eq { + return LOCALES[i]; + } + } + i += 1; + } + panic!("unsupported locale"); +} + +#[cfg(test)] +mod tests { + use super::*; + + // The Zig test is darwin-only; the canonicalization here is portable. + #[test] + fn canonicalize_locale_darwin() { + assert_eq!("en_US", canonicalize_locale("en_US")); + assert_eq!("zh_CN", canonicalize_locale("zh-Hans")); + assert_eq!("zh_TW", canonicalize_locale("zh-Hant")); + + assert_eq!("zh_CN", canonicalize_locale("zh-Hans-CN")); + assert_eq!("zh_SG", canonicalize_locale("zh-Hans-SG")); + assert_eq!("zh_TW", canonicalize_locale("zh-Hant-TW")); + assert_eq!("zh_HK", canonicalize_locale("zh-Hant-HK")); + assert_eq!("zh_MO", canonicalize_locale("zh-Hant-MO")); + + // This is just an edge case I want to make sure we're aware of: + // canonicalizeLocale does not handle encodings and will turn them into + // underscores. We should parse them out before calling this function. + assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8")); + } + + #[test] + fn locales_map_and_static_locale() { + assert!(is_supported_locale("zh_CN")); + assert!(is_supported_locale("kk")); + assert!(!is_supported_locale("xx")); + const L: &str = static_locale("de"); + assert_eq!(L, "de"); + } + + #[test] + fn init_rejects_dirless_resources_dir() { + assert!(matches!( + init("ghostty"), + Err(InitError::InvalidResourcesDir) + )); + assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir))); + } + + #[test] + fn tr_falls_back_to_msgid() { + assert_eq!(tr("some untranslated message"), "some untranslated message"); + } +} diff --git a/crates/ghostty-os/src/i18n_locales.rs b/crates/ghostty-os/src/i18n_locales.rs new file mode 100644 index 0000000..7e24902 --- /dev/null +++ b/crates/ghostty-os/src/i18n_locales.rs @@ -0,0 +1,34 @@ +// NOTE: This is in a separate file because our build depends on it and +// we want to minimize the transitive dependencies of the build binary +// itself. + +/// Supported locales for the application. This must be kept up to date +/// with the translations available in the `po/` directory; this is used +/// by our build process as well runtime libghostty APIs. +/// +/// The order also matters. For incomplete locale information (i.e. only +/// a language code available), the first match is used. For example, if +/// we know the user requested `zh` but has no script code, then we'd pick +/// the first locale that matches `zh`. +/// +/// For ordering, we prefer: +/// +/// 1. The most common locales first, since there are places in the code +/// where we do linear searches for a locale and we want to minimize +/// the number of iterations for the common case. +/// +/// 2. Alphabetical for otherwise equally common locales. +/// +/// 3. Most preferred locale for a language without a country code. +/// +/// Note for "most common" locales, this is subjective and based on +/// the perceived userbase of Ghostty, which may not be representative +/// of general populations or global language distribution. Also note +/// that ordering may be weird when we first merge a new locale since +/// we don't have a good way to determine this. We can always reorder +/// with some data. +pub const LOCALES: [&str; 29] = [ + "zh_CN", "de", "fr", "ja", "nl", "nb", "ru", "uk", "pl", "ko_KR", "mk", "tr", "id", "es_BO", + "es_AR", "es_ES", "pt_BR", "ca", "it", "bg", "ga", "hu", "he", "zh_TW", "hr", "lt", "lv", "vi", + "kk", +]; diff --git a/crates/ghostty-os/src/kernel_info.rs b/crates/ghostty-os/src/kernel_info.rs new file mode 100644 index 0000000..a907831 --- /dev/null +++ b/crates/ghostty-os/src/kernel_info.rs @@ -0,0 +1,29 @@ +use std::io::Read; + +/// Returns the kernel release string (`/proc/sys/kernel/osrelease`). +pub fn get_kernel_info() -> Option { + let file = std::fs::File::open("/proc/sys/kernel/osrelease").ok()?; + + // 128 bytes should be enough to hold the kernel information. Zig's + // readToEndAlloc fails if the file is larger than the limit. + let mut buf = Vec::new(); + file.take(129).read_to_end(&mut buf).ok()?; + if buf.len() > 128 { + return None; + } + let s = String::from_utf8_lossy(&buf); + Some(s.trim_matches(|c: char| c.is_ascii_whitespace()).to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_proc_sys_kernel_osrelease() { + let kernel_info = get_kernel_info().unwrap(); + // Since we can't hardcode the info in tests, just check + // if something was read from the file + assert!(!kernel_info.is_empty()); + } +} diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index bcb1e4e..895c48d 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -1 +1,101 @@ -//! ghostty-os: see docs/rewrite/ARCHITECTURE.md for the module map. +//! The "os" package contains utilities for interfacing with the operating +//! system. These aren't restricted to syscalls or low-level operations, but +//! also OS-specific features and conventions. +//! +//! Port of `src/os/*.zig` (Linux only) plus `src/file_type.zig`. See +//! `docs/rewrite/notes/ghostty-os.md` for deviations and skipped files. + +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::assert_is_empty, + clippy::items_after_statements + ) +)] + +pub mod args; +pub mod cgroup; +pub mod dbus; +pub mod desktop; +pub mod env; +pub mod file; +pub mod file_type; +pub mod flatpak; +pub mod homedir; +pub mod hostname; +pub mod i18n; +pub mod i18n_locales; +pub mod kernel_info; +pub mod locale; +pub mod mouse; +pub mod open; +pub mod passwd; +pub mod path; +pub mod pipe; +pub mod resourcesdir; +pub mod shell; +pub mod string_encoding; +pub mod systemd; +pub mod temp_dir; +pub mod uri; +pub mod xdg; + +#[cfg(test)] +pub(crate) mod testutil; + +// Functions and types (mirrors the flat exports of `os/main.zig`). +pub use dbus::launched_by_dbus_activation; +pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop}; +pub use env::{append_env, append_env_always, get_env_map, getenv, getenv_not_empty, prepend_env}; +pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files}; +pub use flatpak::{FlatpakHostCommand, is_flatpak}; +pub use homedir::{expand_home, home}; +pub use kernel_info::get_kernel_info; +pub use locale::ensure_locale; +pub use mouse::click_interval; +pub use open::open; +pub use pipe::pipe; +pub use resourcesdir::{ResourcesDir, resources_dir}; +pub use shell::ShellEscapeWriter; +pub use systemd::launched_by_systemd; +pub use temp_dir::TempDir; + +/// Join path components like `std.fs.path.join` (POSIX): empty components +/// are skipped and exactly one separator is placed between components. +pub(crate) fn path_join(parts: &[&str]) -> String { + let mut out = String::new(); + for p in parts { + if p.is_empty() { + continue; + } + if out.is_empty() { + out.push_str(p); + continue; + } + let prev_sep = out.ends_with('/'); + let this_sep = p.starts_with('/'); + match (prev_sep, this_sep) { + (true, true) => out.push_str(&p[1..]), + (false, false) => { + out.push('/'); + out.push_str(p); + } + _ => out.push_str(p), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::path_join; + + #[test] + fn path_join_basics() { + assert_eq!(path_join(&["/a", ".cache", ""]), "/a/.cache"); + assert_eq!(path_join(&["/a/", "/b"]), "/a/b"); + assert_eq!(path_join(&["", "b"]), "b"); + assert_eq!(path_join(&["/a", "b", "c"]), "/a/b/c"); + } +} diff --git a/crates/ghostty-os/src/locale.rs b/crates/ghostty-os/src/locale.rs new file mode 100644 index 0000000..0324108 --- /dev/null +++ b/crates/ghostty-os/src/locale.rs @@ -0,0 +1,44 @@ +//! Locale setup (`os/locale.zig`). +//! +//! DEFERRED: the Zig `ensureLocale` is built from `setlocale(LC_ALL, "")` +//! plus `setenv`/`unsetenv` of `LANG`. All of these are `unsafe` in Rust +//! (`libc::setlocale`, `gettextrs::setlocale` and Rust 2024's +//! `std::env::set_var`) and this crate forbids `unsafe`. There is no safe +//! crate that wraps `setlocale`. +//! +//! Resolution options for integration: +//! * GTK (`gtk_init`, called by `gtk4::init()`) already calls +//! `setlocale(LC_ALL, "")` on Linux, so for the GTK apprt nothing else is +//! needed for gettext/Pango to pick up the locale. +//! * If an explicit call is needed (e.g. CLI actions without GTK), an +//! `unsafe`-permitted crate (apprt-gtk / the root binary) must perform the +//! libc call; [`FALLBACK_LOCALE`] documents the Zig fallback behaviour +//! (try `setlocale(LC_ALL, "")`, then with `LANG` unset, then +//! `en_US.UTF-8` and export `LANG=en_US.UTF-8`). +//! +//! The macOS-specific `LANG` population from Cocoa is not applicable. + +/// The locale Ghostty falls back to (and exports as `LANG`) when the +/// environment's locale can't be set. +pub const FALLBACK_LOCALE: &str = "en_US.UTF-8"; + +/// Ensure that the locale is set. +/// +/// DEFERRED (see module docs): this is currently a no-op that only logs. +/// Returns `Some(value)` when `LANG` should be set to `value` for child +/// processes; always `None` for now. +pub fn ensure_locale() -> Option { + log::debug!("ensure_locale: setlocale is not available from safe Rust; relying on the toolkit"); + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_locale_is_noop() { + assert_eq!(ensure_locale(), None); + assert_eq!(FALLBACK_LOCALE, "en_US.UTF-8"); + } +} diff --git a/crates/ghostty-os/src/mouse.rs b/crates/ghostty-os/src/mouse.rs new file mode 100644 index 0000000..43d01e0 --- /dev/null +++ b/crates/ghostty-os/src/mouse.rs @@ -0,0 +1,6 @@ +/// The system-configured double-click interval if its available. +/// +/// Only macOS can report this; on Linux this is always `None`. +pub fn click_interval() -> Option { + None +} diff --git a/crates/ghostty-os/src/open.rs b/crates/ghostty-os/src/open.rs new file mode 100644 index 0000000..34d349a --- /dev/null +++ b/crates/ghostty-os/src/open.rs @@ -0,0 +1,92 @@ +use std::io; +use std::process::{Command, Stdio}; + +/// Kind of URL being opened (`apprt.action.OpenUrl.Kind`). On Linux the +/// kind is ignored by [`open`] (it only matters for macOS `open -t`). +// TODO(integration): replace with the apprt action's OpenUrl kind once +// ghostty-core defines it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OpenUrlKind { + Text, + Html, + #[default] + Unknown, +} + +/// Open a URL in the default handling application (via `xdg-open`). +/// +/// Any output on stderr is logged as a warning in the application logs. +/// Output on stdout is ignored. +/// +/// This function is purposely simple for the sake of providing +/// some portable way to open URLs. If you are implementing an +/// apprt for Ghostty, you should consider doing something special-cased +/// for your platform. +pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> { + let _ = kind; + let mut cmd = Command::new("xdg-open"); + cmd.arg(url); + + // Pipe stdout/stderr so we can collect output from the command. + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at + // the snap's bundled libraries. Leaking this into child process can + // can be problematic, so let's drop it from the env. + // TODO(integration): Zig does this only for `build_config.snap` builds; + // we do it when running inside a snap (`SNAP` is set). + if std::env::var_os("SNAP").is_some() { + cmd.env_remove("LD_LIBRARY_PATH"); + } + + // Spawn the process on our same thread so we can detect failure + // quickly. + let child = cmd.spawn()?; + + // Create a thread that handles collecting output and reaping + // the process. This is done in a separate thread because SOME + // open implementations block and some do not. It's easier to just + // spawn a thread to handle this so that we never block. + std::thread::Builder::new() + .name("open-url".into()) + .spawn(move || open_thread(child))?; + Ok(()) +} + +fn open_thread(child: std::process::Child) { + // 50 KiB is the default value used by std.process.Child.run and should + // be enough to get the output we care about. + const OUTPUT_MAX_SIZE: usize = 50 * 1024; + + let output = match child.wait_with_output() { + Ok(o) => o, + Err(e) => { + log::warn!("error waiting for open command err={e}"); + return; + } + }; + + // If we have any stderr output we log it. This makes it easier for + // users to debug why some open commands may not work as expected. + let stderr = &output.stderr[..output.stderr.len().min(OUTPUT_MAX_SIZE)]; + if !stderr.is_empty() { + log::warn!("wait stderr={}", String::from_utf8_lossy(stderr)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_reports_spawn_failure_quickly() { + // With an empty PATH, xdg-open can't be found; Zig detects this + // synchronously and so do we. + temp_env::with_var("PATH", Some("/nonexistent-dir"), || { + let err = open(OpenUrlKind::Unknown, "https://example.com").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + }); + } +} diff --git a/crates/ghostty-os/src/passwd.rs b/crates/ghostty-os/src/passwd.rs new file mode 100644 index 0000000..21fb6d5 --- /dev/null +++ b/crates/ghostty-os/src/passwd.rs @@ -0,0 +1,58 @@ +use crate::flatpak::is_flatpak; + +/// Entry that is retrieved from the passwd API. This only contains the fields +/// we care about. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Entry { + pub shell: Option, + pub home: Option, + pub name: Option, +} + +/// Get the passwd entry for the currently executing user. +pub fn get() -> std::io::Result { + let uid = nix::unistd::getuid(); + let user = match nix::unistd::User::from_uid(uid) { + Ok(Some(user)) => user, + Ok(None) => { + // Future: let's check if a better shell is available like zsh + log::warn!("no pw entry to detect default shell, will default to 'sh'"); + return Ok(Entry::default()); + } + Err(e) => { + log::warn!("error retrieving pw entry code={e}"); + return Ok(Entry::default()); + } + }; + + // TODO(integration): Zig shells out to the host via + // FlatpakHostCommand + a pty (`getent passwd `) when running in + // Flatpak, because the sandbox passwd entry only has a correct username. + // That needs the `ghostty-termio` Pty; until then we use the sandbox + // entry and warn. + if is_flatpak() { + log::warn!( + "flatpak detected, but host passwd lookup is not implemented; \ + using sandbox passwd entry" + ); + } + + Ok(Entry { + shell: Some(user.shell.to_string_lossy().into_owned()), + home: Some(user.dir.to_string_lossy().into_owned()), + name: Some(user.name), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_entry() { + // We should be able to get an entry + let entry = get().unwrap(); + assert!(entry.shell.is_some()); + assert!(entry.home.is_some()); + } +} diff --git a/crates/ghostty-os/src/path.rs b/crates/ghostty-os/src/path.rs new file mode 100644 index 0000000..51c1826 --- /dev/null +++ b/crates/ghostty-os/src/path.rs @@ -0,0 +1,107 @@ +use std::io; +use std::os::unix::fs::PermissionsExt; + +/// Equivalent of `std.fs.max_path_bytes` on Linux. +const MAX_PATH_BYTES: usize = 4096; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("path too long")] + PathTooLong, + #[error("access denied")] + AccessDenied, + #[error(transparent)] + Io(#[from] io::Error), +} + +/// Search for "cmd" in the PATH and return the absolute path. +pub fn expand(cmd: &str) -> Result, Error> { + // If the command already contains a slash, then we return it as-is + // because it is assumed to be absolute or relative. + if cmd.contains('/') { + return Ok(Some(cmd.to_owned())); + } + + let Some(path_var) = std::env::var_os("PATH") else { + return Ok(None); + }; + let path_var = path_var.to_string_lossy(); + + let mut seen_eacces = false; + for search_path in path_var.split(':').filter(|s| !s.is_empty()) { + // We need enough space in our path buffer to store this + let path_len = search_path.len() + cmd.len() + 1; + if MAX_PATH_BYTES < path_len { + return Err(Error::PathTooLong); + } + + let full_path = format!("{search_path}/{cmd}"); + + // Stat it + let f = match std::fs::File::open(&full_path) { + Ok(f) => f, + Err(e) => match e.kind() { + io::ErrorKind::NotFound => continue, + io::ErrorKind::PermissionDenied => { + // Accumulate this and return it later so we can try other + // paths that we have access to. + seen_eacces = true; + continue; + } + _ => return Err(e.into()), + }, + }; + let meta = f.metadata()?; + if !meta.is_dir() && is_executable(meta.permissions().mode()) { + return Ok(Some(full_path)); + } + } + + if seen_eacces { + return Err(Error::AccessDenied); + } + + Ok(None) +} + +fn is_executable(mode: u32) -> bool { + mode & 0o0111 != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + // `uname -n` is the *nix equivalent of `hostname.exe` on Windows + #[test] + fn expand_hostname() { + let path = crate::testutil::with_env_lock(|| expand("uname").unwrap().unwrap()); + assert!(path.len() > "uname".len()); + } + + #[test] + fn expand_does_not_exist() { + let path = + crate::testutil::with_env_lock(|| expand("thisreallyprobablydoesntexist123").unwrap()); + assert!(path.is_none()); + } + + #[test] + fn expand_slash() { + let path = expand("foo/env").unwrap().unwrap(); + assert_eq!(path.len(), 7); + } + + #[test] + fn expand_skips_non_executable_and_dirs() { + let dir = crate::TempDir::init().unwrap(); + std::fs::write(dir.path().join("plain"), b"x").unwrap(); + std::fs::create_dir(dir.path().join("adir")).unwrap(); + let p = dir.path(); + let p = p.to_str().unwrap(); + temp_env::with_var("PATH", Some(p), || { + assert!(expand("plain").unwrap().is_none()); + assert!(expand("adir").unwrap().is_none()); + }); + } +} diff --git a/crates/ghostty-os/src/pipe.rs b/crates/ghostty-os/src/pipe.rs new file mode 100644 index 0000000..885220b --- /dev/null +++ b/crates/ghostty-os/src/pipe.rs @@ -0,0 +1,28 @@ +use std::io; +use std::os::fd::OwnedFd; + +/// pipe() for POSIX. Sets CLOEXEC on the file descriptors. +/// Returns `(read, write)`. +pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> { + rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).map_err(io::Error::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + #[test] + fn pipe_roundtrip_cloexec() { + let (r, w) = pipe().unwrap(); + let flags = rustix::io::fcntl_getfd(&r).unwrap(); + assert!(flags.contains(rustix::io::FdFlags::CLOEXEC)); + let mut w = std::fs::File::from(w); + let mut r = std::fs::File::from(r); + w.write_all(b"hi").unwrap(); + drop(w); + let mut s = String::new(); + r.read_to_string(&mut s).unwrap(); + assert_eq!(s, "hi"); + } +} diff --git a/crates/ghostty-os/src/resourcesdir.rs b/crates/ghostty-os/src/resourcesdir.rs new file mode 100644 index 0000000..cc0ae00 --- /dev/null +++ b/crates/ghostty-os/src/resourcesdir.rs @@ -0,0 +1,181 @@ +use std::io; +use std::path::Path; + +use crate::path_join; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResourcesDir { + /// Avoid accessing these directly, use the app() and host() methods instead. + pub app_path: Option, + pub host_path: Option, +} + +impl ResourcesDir { + /// Get the directory to the bundled resources directory accessible + /// by the application. + pub fn app(&self) -> Option<&str> { + self.app_path.as_deref() + } + + /// Get the directory to the bundled resources directory accessible + /// by the host environment (i.e. for sandboxed applications). The + /// returned directory might not be accessible from the application + /// itself. + /// + /// In non-sandboxed environment, this should be the same as app(). + pub fn host(&self) -> Option<&str> { + self.host_path.as_deref().or(self.app_path.as_deref()) + } +} + +/// Sentinels we look for in the path to know we've found the resources +/// directory (Linux). +const SENTINELS: [&str; 2] = ["terminfo/g/ghostty", "terminfo/x/xterm-ghostty"]; + +/// Gets the directory to the bundled resources directory, if it +/// exists (not all platforms or packages have it). +/// +/// Zig's `builtin.mode == .Debug` is mapped to `cfg!(debug_assertions)`. +pub fn resources_dir() -> io::Result { + let exe = std::env::current_exe().ok(); + let env = std::env::var("GHOSTTY_RESOURCES_DIR").ok(); + Ok(resources_dir_impl( + cfg!(debug_assertions), + exe.as_deref(), + env, + )) +} + +fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> ResourcesDir { + // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. + // + // In debug builds we try using terminfo detection first instead, since + // if debug Ghostty is launched by an older version of Ghostty, it + // would inherit the old, stale resources of older Ghostty instead of the + // freshly built ones under zig-out/share/ghostty. + if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) { + return ResourcesDir { + app_path: Some(dir.clone()), + host_path: None, + }; + } + + // Get the path to our running binary + let Some(mut exe) = exe else { + return ResourcesDir::default(); + }; + + // We have an exe path! Climb the tree looking for the terminfo + // bundle as we expect it. + while let Some(dir) = exe.parent() { + exe = dir; + let dir_str = dir.to_string_lossy(); + + // We look for a /usr/share style path. + for sentinel in SENTINELS { + if let Some(v) = maybe_dir(&dir_str, "share", sentinel) { + return ResourcesDir { + app_path: Some(path_join(&[&v, "ghostty"])), + host_path: None, + }; + } + } + } + + // If terminfo detection failed in debug builds (somehow), + // fallback and use the provided resources dir. + if debug && let Some(dir) = env.filter(|d| !d.is_empty()) { + return ResourcesDir { + app_path: Some(dir), + host_path: None, + }; + } + + ResourcesDir::default() +} + +/// Little helper to check if the "base/sub/suffix" directory exists and +/// if so return "base/sub". The "suffix" is just used as a way to verify a +/// directory seems roughly right. +pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option { + let path = format!("{base}/{sub}/{suffix}"); + + // Folder doesn't exist. If a different error happens its okay + // we just ignore it and move on. + if Path::new(&path).exists() { + let len = path.len() - suffix.len() - 1; + return Some(path[..len].to_owned()); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resources_dir_accessors() { + let mut r = ResourcesDir::default(); + assert_eq!(r.app(), None); + assert_eq!(r.host(), None); + r.app_path = Some("/a".into()); + assert_eq!(r.host(), Some("/a")); + r.host_path = Some("/h".into()); + assert_eq!(r.app(), Some("/a")); + assert_eq!(r.host(), Some("/h")); + } + + fn make_tree() -> crate::TempDir { + let td = crate::TempDir::init().unwrap(); + std::fs::create_dir_all(td.path().join("share/terminfo/x")).unwrap(); + std::fs::write(td.path().join("share/terminfo/x/xterm-ghostty"), b"").unwrap(); + std::fs::create_dir_all(td.path().join("bin")).unwrap(); + td + } + + #[test] + fn detects_share_dir_from_exe() { + let td = make_tree(); + let exe = td.path().join("bin/ghostty"); + let r = resources_dir_impl(true, Some(&exe), None); + let expected = format!("{}/share/ghostty", td.path().display()); + assert_eq!(r.app(), Some(expected.as_str())); + } + + #[test] + fn env_in_release_wins_and_in_debug_is_fallback() { + let td = make_tree(); + let exe = td.path().join("bin/ghostty"); + let env = Some("/env/res".to_owned()); + assert_eq!( + resources_dir_impl(false, Some(&exe), env.clone()).app(), + Some("/env/res") + ); + assert_ne!( + resources_dir_impl(true, Some(&exe), env.clone()).app(), + Some("/env/res") + ); + // Debug fallback when detection fails. + assert_eq!( + resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env).app(), + Some("/env/res") + ); + // Empty env is ignored. + assert_eq!( + resources_dir_impl(false, None, Some(String::new())).app(), + None + ); + } + + #[test] + fn maybe_dir_checks_suffix() { + let td = make_tree(); + let base = td.path().to_string_lossy().into_owned(); + assert_eq!( + maybe_dir(&base, "share", "terminfo/x/xterm-ghostty"), + Some(format!("{base}/share")) + ); + assert_eq!(maybe_dir(&base, "share", "terminfo/g/ghostty"), None); + } +} diff --git a/crates/ghostty-os/src/shell.rs b/crates/ghostty-os/src/shell.rs new file mode 100644 index 0000000..67ba462 --- /dev/null +++ b/crates/ghostty-os/src/shell.rs @@ -0,0 +1,155 @@ +use std::ffi::{CString, NulError}; +use std::io::{self, Write}; + +/// Builder for constructing space-separated shell command strings. +#[derive(Debug, Default, Clone)] +pub struct ShellCommandBuilder { + buffer: String, +} + +impl ShellCommandBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Append an argument to the command with automatic space separation. + /// Empty arguments are ignored. + pub fn append_arg(&mut self, arg: &str) { + if arg.is_empty() { + return; + } + if !self.buffer.is_empty() { + self.buffer.push(' '); + } + self.buffer.push_str(arg); + } + + /// The command written so far. + pub fn written(&self) -> &str { + &self.buffer + } + + /// Get the final null-terminated command string. + pub fn to_owned_slice(self) -> Result { + CString::new(self.buffer) + } +} + +/// Writer that escapes characters that shells treat specially to reduce the +/// risk of injection attacks or other such weirdness. Specifically excludes +/// linefeeds so that they can be used to delineate lists of file paths. +pub struct ShellEscapeWriter { + child: W, +} + +impl ShellEscapeWriter { + pub fn new(child: W) -> Self { + Self { child } + } + + pub fn into_inner(self) -> W { + self.child + } +} + +impl Write for ShellEscapeWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + for &byte in data { + match byte { + b'\\' | b'"' | b'\'' | b'$' | b'`' | b'*' | b'?' | b' ' | b'|' | b'(' | b')' => { + self.child.write_all(&[b'\\', byte])?; + } + _ => self.child.write_all(&[byte])?, + } + } + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.child.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_command_builder() { + // Empty command + let cmd = ShellCommandBuilder::new(); + assert_eq!("", cmd.written()); + + // Single arg + let mut cmd = ShellCommandBuilder::new(); + cmd.append_arg("bash"); + assert_eq!("bash", cmd.written()); + + // Multiple args + let mut cmd = ShellCommandBuilder::new(); + cmd.append_arg("bash"); + cmd.append_arg("--posix"); + cmd.append_arg("-l"); + assert_eq!("bash --posix -l", cmd.written()); + + // Empty arg + let mut cmd = ShellCommandBuilder::new(); + cmd.append_arg("bash"); + cmd.append_arg(""); + assert_eq!("bash", cmd.written()); + + // toOwnedSlice + let mut cmd = ShellCommandBuilder::new(); + cmd.append_arg("bash"); + cmd.append_arg("--posix"); + let result = cmd.to_owned_slice().unwrap(); + assert_eq!(result.to_str().unwrap(), "bash --posix"); + assert_eq!(result.as_bytes_with_nul().last(), Some(&0)); + } + + fn escape(input: &str) -> String { + let mut shell = ShellEscapeWriter::new(Vec::new()); + shell.write_all(input.as_bytes()).unwrap(); + String::from_utf8(shell.into_inner()).unwrap() + } + + #[test] + fn shell_escape_1() { + assert_eq!(escape("abc"), "abc"); + } + + #[test] + fn shell_escape_2() { + assert_eq!(escape("a c"), "a\\ c"); + } + + #[test] + fn shell_escape_3() { + assert_eq!(escape("a?c"), "a\\?c"); + } + + #[test] + fn shell_escape_4() { + assert_eq!(escape("a\\c"), "a\\\\c"); + } + + #[test] + fn shell_escape_5() { + assert_eq!(escape("a|c"), "a\\|c"); + } + + #[test] + fn shell_escape_6() { + assert_eq!(escape("a\"c"), "a\\\"c"); + } + + #[test] + fn shell_escape_7() { + assert_eq!(escape("a(1)"), "a\\(1\\)"); + } + + #[test] + fn shell_escape_keeps_newlines() { + assert_eq!(escape("a\nb"), "a\nb"); + } +} diff --git a/crates/ghostty-os/src/string_encoding.rs b/crates/ghostty-os/src/string_encoding.rs new file mode 100644 index 0000000..144218b --- /dev/null +++ b/crates/ghostty-os/src/string_encoding.rs @@ -0,0 +1,245 @@ +use std::io::{self, Write}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("decode error")] + DecodeError, + #[error(transparent)] + Io(#[from] io::Error), +} + +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + matches!((self, other), (Error::DecodeError, Error::DecodeError)) + } +} + +/// Decode date from the buffer that has been encoded in the same way that +/// `bash`'s `printf %q` encodes a string and write it to the writer. If an +/// error is returned garbage may have been written to the buffer. +pub fn printf_q_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { + let data: &[u8] = if buf.starts_with(b"$'") { + // Strip off `$''` quoting. + if buf.len() < 3 || !buf.ends_with(b"'") { + return Err(Error::DecodeError); + } + &buf[2..buf.len() - 1] + } else if buf.starts_with(b"'") { + // Strip off `''` quoting. + if buf.len() < 2 || !buf.ends_with(b"'") { + return Err(Error::DecodeError); + } + &buf[1..buf.len() - 1] + } else { + buf + }; + + let mut src = 0; + while src < data.len() { + match data[src] { + b'\\' => { + if src + 1 >= data.len() { + return Err(Error::DecodeError); + } + let out = match data[src + 1] { + c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c, + b'e' => 0x1b, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'v' => 0x0b, + _ => return Err(Error::DecodeError), + }; + writer.write_all(&[out])?; + src += 2; + } + c => { + writer.write_all(&[c])?; + src += 1; + } + } + } + Ok(()) +} + +/// Decode data from the buffer that has been URL percent encoded and write +/// it to the given buffer. If an error is returned the garbage may have been +/// written to the writer. +pub fn url_percent_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { + let mut src = 0; + while src < buf.len() { + match buf[src] { + b'%' => { + if src + 2 >= buf.len() { + return Err(Error::DecodeError); + } + let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else { + return Err(Error::DecodeError); + }; + writer.write_all(&[(hi << 4) | lo])?; + src += 3; + } + c => { + writer.write_all(&[c])?; + src += 1; + } + } + } + Ok(()) +} + +fn hex(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Is the given character valid in URI percent encoding? +fn is_valid_char(c: u8) -> bool { + match c { + b' ' | b';' | b'=' => false, + _ => (0x20..=0x7e).contains(&c), + } +} + +/// Write data to the writer after URI percent encoding. +pub fn url_percent_encode(writer: &mut W, data: &[u8]) -> io::Result<()> { + for &c in data { + if is_valid_char(c) { + writer.write_all(&[c])?; + } else { + write!(writer, "%{c:02X}")?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pq(s: &[u8]) -> Result, Error> { + let mut w = Vec::new(); + printf_q_decode(&mut w, s).map(|()| w) + } + + fn pd(s: &[u8]) -> Result, Error> { + let mut w = Vec::new(); + url_percent_decode(&mut w, s).map(|()| w) + } + + #[test] + fn printf_q_1() { + assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_2() { + assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa"); + } + + #[test] + fn printf_q_3() { + assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_4() { + assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_5() { + assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_6() { + assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_7() { + assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_8() { + assert_eq!(pq(b"$'"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_9() { + assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_10() { + assert_eq!(pq(b"'"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_escapes() { + assert_eq!( + pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(), + b"\x1b\r\t\x0b\\\"'$" + ); + } + + #[test] + fn singles_percent() { + for c in 0..255u32 { + let lower = format!("%{c:02x}"); + let decoded = pd(lower.as_bytes()).unwrap(); + assert_eq!(decoded, [c as u8]); + let upper = format!("%{c:02X}"); + let decoded = pd(upper.as_bytes()).unwrap(); + assert_eq!(decoded, [c as u8]); + } + } + + #[test] + fn percent_1() { + assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa"); + } + + #[test] + fn percent_2() { + assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_3() { + assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_4() { + assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_5() { + assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa "); + } + + #[test] + fn percent_6() { + assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError)); + } + + #[test] + fn percent_7() { + assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError)); + } + + #[test] + fn percent_encode() { + let mut w = Vec::new(); + url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap(); + assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~"); + } +} diff --git a/crates/ghostty-os/src/systemd.rs b/crates/ghostty-os/src/systemd.rs new file mode 100644 index 0000000..8459ce9 --- /dev/null +++ b/crates/ghostty-os/src/systemd.rs @@ -0,0 +1,216 @@ +use std::io::Read; + +/// Kernel `TASK_COMM_LEN`. +const TASK_COMM_LEN: u64 = 16; + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +pub fn launched_by_systemd() -> bool { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) environment variables. If these + // environment variables are not present we were not launched by + // systemd. + if std::env::var_os("INVOCATION_ID").is_none() { + return false; + } + if std::env::var_os("JOURNAL_STREAM").is_none() { + return false; + } + + // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure + // that our parent process is actually `systemd`, not some other terminal + // emulator that doesn't clean up those environment variables. + let ppid = rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid); + if ppid == 1 { + return true; + } + + // If the parent PID is not 1 we need to check to see if we were launched by + // a user systemd daemon. Do that by checking the `/proc//comm` + // to see if it ends with `systemd`. + let comm_path = format!("/proc/{ppid}/comm"); + let Ok(comm_file) = std::fs::File::open(&comm_path) else { + log::error!("unable to open '{comm_path}' for reading"); + return false; + }; + + // The maximum length of the command name is defined by + // `TASK_COMM_LEN` in the Linux kernel. This is usually 16 + // bytes at the time of writing (Jun 2025) so its set to that. + // Also, since we only care to compare to "systemd", anything + // longer can be assumed to not be systemd. + let mut comm_data = Vec::new(); + if comm_file + .take(TASK_COMM_LEN) + .read_to_end(&mut comm_data) + .is_err() + { + log::error!("problems reading from '{comm_path}'"); + return false; + } + + let end = comm_data + .iter() + .rposition(|&b| b != b'\n') + .map_or(0, |i| i + 1); + &comm_data[..end] == b"systemd" +} + +/// systemd notifications. Used by Ghostty to inform systemd of the state of the +/// process. Currently only used to notify systemd that we are ready and that +/// configuration reloading has started. +/// +/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +/// +/// These functions are re-implemented (rather than using `libsystemd`) to +/// avoid another external dependency. Linux systems that do not use +/// `systemd` should never start Ghostty with the `NOTIFY_SOCKET` environment +/// variable set and these functions essentially become a no-op. +pub mod notify { + use rustix::net::{ + AddressFamily, SendFlags, SocketAddrUnix, SocketFlags, SocketType, connect, send, + socket_with, + }; + + /// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET + /// environment variable. If there NOTIFY_SOCKET environment variable does + /// not exist then no message is sent. + pub(super) fn send_message(message: &[u8]) { + // Get the socket address that should receive notifications. + let Some(socket_path) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let socket_path = socket_path.as_encoded_bytes(); + + // If the socket address is an empty string return. + if socket_path.is_empty() { + return; + } + + // The socket address must be a path or an abstract socket. + if socket_path[0] != b'/' && socket_path[0] != b'@' { + log::warn!( + "only AF_UNIX sockets with path or abstract namespace addresses are supported!" + ); + return; + } + + // sockaddr_un.path is 108 bytes. + if 108 < socket_path.len() { + log::warn!("NOTIFY_SOCKET path is too long!"); + return; + } + + // NOTE: the Zig version copies the path verbatim (including a + // leading '@'); the sd_notify protocol says a leading '@' denotes + // an abstract socket, which is what we do here. + let addr = if socket_path[0] == b'@' { + SocketAddrUnix::new_abstract_name(&socket_path[1..]) + } else { + SocketAddrUnix::new(socket_path) + }; + let addr = match addr { + Ok(addr) => addr, + Err(e) => { + log::warn!("invalid NOTIFY_SOCKET address: {e}"); + return; + } + }; + + let sock = match socket_with( + AddressFamily::UNIX, + SocketType::DGRAM, + SocketFlags::CLOEXEC, + None, + ) { + Ok(s) => s, + Err(e) => { + log::warn!("creating socket failed: {e}"); + return; + } + }; + + if let Err(e) = connect(&sock, &addr) { + log::warn!("unable to connect to notify socket: {e}"); + return; + } + + match send(&sock, message, SendFlags::empty()) { + Ok(written) if written < message.len() => { + log::warn!( + "short write to notify socket: {written} < {}", + message.len() + ); + } + Ok(_) => {} + Err(e) => log::warn!("unable to write to notify socket: {e}"), + } + } + + /// Tell systemd that we are ready or that we are finished reloading. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1 + pub fn ready() { + send_message(b"READY=1"); + } + + /// Tell systemd that we have started reloading our configuration. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1 + /// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6 + pub fn reloading() { + let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic); + let now = ts.tv_sec * 1_000_000 + ts.tv_nsec / 1000; + let message = format!("RELOADING=1\nMONOTONIC_USEC={now}"); + send_message(message.as_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixDatagram; + + #[test] + fn not_launched_without_env() { + temp_env::with_vars( + [("INVOCATION_ID", None::<&str>), ("JOURNAL_STREAM", None)], + || assert!(!launched_by_systemd()), + ); + temp_env::with_vars( + [("INVOCATION_ID", Some("x")), ("JOURNAL_STREAM", None)], + || assert!(!launched_by_systemd()), + ); + } + + fn recv_with_socket(f: impl FnOnce()) -> String { + let dir = crate::TempDir::init().unwrap(); + let path = dir.path().join("notify.sock"); + let sock = UnixDatagram::bind(&path).unwrap(); + sock.set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + temp_env::with_var("NOTIFY_SOCKET", Some(path.to_str().unwrap()), f); + let mut buf = [0u8; 128]; + let n = sock.recv(&mut buf).unwrap(); + String::from_utf8(buf[..n].to_vec()).unwrap() + } + + #[test] + fn notify_ready_sends_message() { + assert_eq!(recv_with_socket(notify::ready), "READY=1"); + } + + #[test] + fn notify_reloading_sends_message() { + let msg = recv_with_socket(notify::reloading); + let rest = msg.strip_prefix("RELOADING=1\nMONOTONIC_USEC=").unwrap(); + assert!(rest.parse::().unwrap() > 0); + } + + #[test] + fn notify_without_socket_is_noop() { + temp_env::with_var("NOTIFY_SOCKET", None::<&str>, notify::ready); + temp_env::with_var("NOTIFY_SOCKET", Some(""), notify::ready); + temp_env::with_var("NOTIFY_SOCKET", Some("relative"), notify::ready); + } +} diff --git a/crates/ghostty-os/src/temp_dir.rs b/crates/ghostty-os/src/temp_dir.rs new file mode 100644 index 0000000..2072ba3 --- /dev/null +++ b/crates/ghostty-os/src/temp_dir.rs @@ -0,0 +1,131 @@ +//! Creates a temporary directory at runtime that can be safely used to +//! store temporary data and is destroyed on drop. + +use std::io; +use std::os::unix::fs::DirBuilderExt; +use std::path::{Path, PathBuf}; + +use crate::file::alloc_tmp_dir; + +// The amount of random bytes to get to determine our filename. +const RANDOM_BYTES: usize = 16; + +// Base64 alphabet, replacing the standard `+/` with `-_` so that it can +// be used in a file name on any filesystem. +const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/// Unpadded base64 using [`B64_ALPHABET`]. +fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity((data.len() * 4).div_ceil(3)); + for chunk in data.chunks(3) { + let n = match chunk.len() { + 3 => u32::from(chunk[0]) << 16 | u32::from(chunk[1]) << 8 | u32::from(chunk[2]), + 2 => u32::from(chunk[0]) << 16 | u32::from(chunk[1]) << 8, + _ => u32::from(chunk[0]) << 16, + }; + let chars = chunk.len() + 1; + for i in 0..chars { + let idx = (n >> (18 - 6 * i)) & 0x3f; + out.push(B64_ALPHABET[idx as usize] as char); + } + } + out +} + +#[derive(Debug)] +pub struct TempDir { + /// Parent directory + parent: PathBuf, + + /// Name of the directory (basename only). + name: String, +} + +impl TempDir { + /// Create the temporary directory. + pub fn init() -> io::Result { + let parent = match alloc_tmp_dir() { + Some(p) => PathBuf::from(p), + None => std::env::current_dir()?, + }; + // Verify the parent can be opened (Zig opens it as a Dir). + std::fs::read_dir(&parent)?; + + // We now loop forever until we can find a directory that we can create. + loop { + let mut rand_buf = [0u8; RANDOM_BYTES]; + rustix::rand::getrandom(&mut rand_buf, rustix::rand::GetRandomFlags::empty())?; + let name = b64_encode(&rand_buf); + + match std::fs::DirBuilder::new() + .mode(0o755) + .create(parent.join(&name)) + { + Ok(()) => return Ok(TempDir { parent, name }), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + } + } + + /// Name returns the name of the directory. This is just the basename + /// and is not the full absolute path. + pub fn name(&self) -> &str { + &self.name + } + + /// The parent directory containing the temporary directory. + pub fn parent(&self) -> &Path { + &self.parent + } + + /// Full path to the temporary directory. + pub fn path(&self) -> PathBuf { + self.parent.join(&self.name) + } + + /// Finish with the temporary directory. This deletes all contents in the + /// directory. + pub fn deinit(self) {} +} + +impl Drop for TempDir { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_dir_all(self.path()) { + log::error!("error deleting temp dir err={err}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_dir_lifecycle() { + let td = TempDir::init().unwrap(); + let name = td.name().to_owned(); + assert!(!name.is_empty()); + assert_eq!(name.len(), 22); + + // Can open a new handle to it proves it exists. + let parent = td.parent().to_owned(); + assert!(std::fs::read_dir(parent.join(&name)).is_ok()); + std::fs::write(td.path().join("file"), b"x").unwrap(); + + // Should be deleted after we deinit + td.deinit(); + let err = std::fs::read_dir(parent.join(&name)).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn b64_encoder() { + assert_eq!(b64_encode(b""), ""); + assert_eq!(b64_encode(b"f"), "Zg"); + assert_eq!(b64_encode(b"fo"), "Zm8"); + assert_eq!(b64_encode(b"foo"), "Zm9v"); + assert_eq!(b64_encode(&[0xfb, 0xff]), "-_8"); + assert_eq!(b64_encode(&[0u8; 16]).len(), 22); + } +} diff --git a/crates/ghostty-os/src/testutil.rs b/crates/ghostty-os/src/testutil.rs new file mode 100644 index 0000000..33619ea --- /dev/null +++ b/crates/ghostty-os/src/testutil.rs @@ -0,0 +1,9 @@ +//! Test helpers. Process environment mutation is `unsafe` in Rust 2024, so +//! tests go through `temp_env`, which serializes env access with a global +//! lock. Tests that merely *read* env vars that other tests mutate should +//! take the same lock via [`with_env_lock`]. + +/// Run `f` while holding the global env lock (no variables changed). +pub fn with_env_lock(f: impl FnOnce() -> R) -> R { + temp_env::with_vars(Vec::<(&str, Option<&str>)>::new(), f) +} diff --git a/crates/ghostty-os/src/uri.rs b/crates/ghostty-os/src/uri.rs new file mode 100644 index 0000000..b3daf4a --- /dev/null +++ b/crates/ghostty-os/src/uri.rs @@ -0,0 +1,506 @@ +//! URI parsing. Extends a faithful port of Zig's `std.Uri.parse` with the +//! additional [`ParseOptions`] from `os/uri.zig`. + +use std::borrow::Cow; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ParseOptions { + /// Parse MAC addresses in the host component. + /// + /// This is useful when the "Private Wi-Fi address" is enabled on macOS, + /// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef). + pub mac_address: bool, + + /// Return the full, raw, unencoded path string. Any query and fragment + /// values will be return as part of the path instead of as distinct + /// fields. + pub raw_path: bool, +} + +/// Errors of `std.Uri.ParseError` plus `InvalidMacAddress`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ParseError { + #[error("unexpected character")] + UnexpectedCharacter, + #[error("invalid format")] + InvalidFormat, + #[error("invalid port")] + InvalidPort, + #[error("invalid mac address")] + InvalidMacAddress, +} + +/// `std.Uri.Component`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Component<'a> { + /// Unencoded text. + Raw(Cow<'a, str>), + /// Percent-encoded text. + PercentEncoded(&'a str), +} + +impl Component<'_> { + pub fn is_empty(&self) -> bool { + match self { + Component::Raw(s) => s.is_empty(), + Component::PercentEncoded(s) => s.is_empty(), + } + } + + /// The raw (percent-decoded) value (`toRaw`). Invalid escapes are left + /// as-is, matching `std.Uri.percentDecodeInPlace`. + pub fn to_raw(&self) -> Cow<'_, str> { + match self { + Component::Raw(s) => Cow::Borrowed(s.as_ref()), + Component::PercentEncoded(s) => percent_decode(s), + } + } + + /// The percent-encoded value as it appears in the source, or the raw + /// text for `Raw` components. + pub fn as_str(&self) -> &str { + match self { + Component::Raw(s) => s, + Component::PercentEncoded(s) => s, + } + } +} + +/// Lenient percent decoding like `std.Uri.percentDecodeBackwards`/InPlace: +/// valid `%XX` sequences are decoded, everything else passes through. +fn percent_decode(s: &str) -> Cow<'_, str> { + if !s.contains('%') { + return Cow::Borrowed(s); + } + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && let (Some(h), Some(l)) = (hexval(b[i + 1]), hexval(b[i + 2])) + { + out.push((h << 4) | l); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + +fn hexval(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// `std.Uri`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Uri<'a> { + pub scheme: &'a str, + pub user: Option>, + pub password: Option>, + pub host: Option>, + pub port: Option, + pub path: Component<'a>, + pub query: Option>, + pub fragment: Option>, +} + +/// Port of `std.Uri.parse`. +fn std_parse(text: &str) -> Result, ParseError> { + let Some(colon) = text.find(':') else { + return Err(ParseError::UnexpectedCharacter); + }; + let scheme = &text[..colon]; + parse_after_scheme(scheme, &text[colon + 1..]) +} + +/// Port of `std.Uri.parseAfterScheme`. +fn parse_after_scheme<'a>(scheme: &'a str, text: &'a str) -> Result, ParseError> { + let mut uri = Uri { + scheme, + user: None, + password: None, + host: None, + port: None, + path: Component::PercentEncoded(""), + query: None, + fragment: None, + }; + let mut rest = text; + + if let Some(after) = rest.strip_prefix("//") { + // authority part + let end = after.find(['/', '?', '#']).unwrap_or(after.len()); + let authority = &after[..end]; + rest = &after[end..]; + + if authority.is_empty() { + if !rest.starts_with('/') { + return Err(ParseError::InvalidFormat); + } + } else { + parse_authority(authority, &mut uri)?; + } + } + + let path_end = rest.find(['?', '#']).unwrap_or(rest.len()); + uri.path = Component::PercentEncoded(&rest[..path_end]); + rest = &rest[path_end..]; + if let Some(after) = rest.strip_prefix('?') { + // query part + let end = after.find('#').unwrap_or(after.len()); + uri.query = Some(Component::PercentEncoded(&after[..end])); + rest = &after[end..]; + } + if let Some(after) = rest.strip_prefix('#') { + uri.fragment = Some(Component::PercentEncoded(after)); + } + Ok(uri) +} + +fn parse_authority<'a>(authority: &'a str, uri: &mut Uri<'a>) -> Result<(), ParseError> { + let mut start_of_host = 0; + if let Some(index) = authority.find('@') { + start_of_host = index + 1; + let user_info = &authority[..index]; + + if let Some(idx) = user_info.find(':') { + uri.user = Some(Component::PercentEncoded(&user_info[..idx])); + if idx + 1 < user_info.len() { + // empty password is also "no password" + uri.password = Some(Component::PercentEncoded(&user_info[idx + 1..])); + } + } else { + uri.user = Some(Component::PercentEncoded(user_info)); + uri.password = None; + } + } + + // only possible if uri consists of only `userinfo@` + if start_of_host >= authority.len() { + return Ok(()); + } + + let mut end_of_host = authority.len(); + let ab = authority.as_bytes(); + + // if we see `]` first without `@` + if ab[start_of_host] == b']' { + return Err(ParseError::InvalidFormat); + } + + if ab[start_of_host] == b'[' { + // IPv6 + end_of_host = authority.rfind(']').ok_or(ParseError::InvalidFormat)? + 1; + + if let Some(index) = authority.rfind(':') + && index >= end_of_host + { + // if not part of the V6 address field + end_of_host = end_of_host.min(index); + uri.port = Some( + authority[index + 1..] + .parse::() + .map_err(|_| ParseError::InvalidPort)?, + ); + } + } else if let Some(index) = authority.rfind(':') + && index >= start_of_host + { + // if not part of the userinfo field + end_of_host = end_of_host.min(index); + uri.port = Some( + authority[index + 1..] + .parse::() + .map_err(|_| ParseError::InvalidPort)?, + ); + } + + uri.host = Some(Component::PercentEncoded( + &authority[start_of_host..end_of_host], + )); + Ok(()) +} + +/// Byte offset of `inner` within `outer` (both must be sub-slices of the +/// same allocation; here `inner` always comes from parsing `outer`). +fn offset_in(outer: &str, inner: &str) -> usize { + inner.as_ptr() as usize - outer.as_ptr() as usize +} + +/// Parses a URI from the given string. +/// +/// This extends the standard URI parse with some additional ParseOptions. +pub fn parse(text: &str, options: ParseOptions) -> Result, ParseError> { + let mut uri = match std_parse(text) { + Ok(uri) => uri, + Err(err) => { + // We can attempt to re-parse the text as a URI that has a MAC address + // in its host field (which tripped up the standard port parsing): + // + // file://12:34:56:78:90:aa/path/to/file + // ^^ InvalidPort + // + if err != ParseError::InvalidPort || !options.mac_address { + return Err(err); + } + + // We can assume that the initial parse already validated the + // scheme, so we only need to find its bounds within the string. + let scheme_end = text.find("://").ok_or(ParseError::InvalidFormat)?; + let scheme = &text[..scheme_end]; + + // We similarly find the bounds of the host component by looking + // for the first slash (/) after the scheme. This is all we need + // for this case because the resulting slice can be unambiguously + // determined to be a MAC address (or not). + let host_start = scheme_end + "://".len(); + let host_end = text[host_start..] + .find('/') + .map_or(text.len(), |i| host_start + i); + let mac_address = &text[host_start..host_end]; + if !is_valid_mac_address(mac_address) { + return Err(ParseError::InvalidMacAddress); + } + + // Parse the rest of the text (starting with the path component) as a + // partial URI and then add our MAC address as its host component. + let mut uri = parse_after_scheme(scheme, &text[host_end..])?; + uri.host = Some(Component::PercentEncoded(mac_address)); + uri + } + }; + + // When MAC address parsing is enabled, we need to handle the case where + // the standard parse parsed the address's last octet as a numeric port number. + // We use a few heuristics to identify this case (14 characters, 4 colons) + // and then "repair" the result by reassign the .host component to the full + // MAC address and clearing the .port component. + // + // 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99 + // (original host) (parsed host + port) (restored host) + // + if options.mac_address + && let Some(Component::PercentEncoded(host)) = uri.host.clone() + && host.len() == 14 + && host.matches(':').count() == 4 + && let Some(port) = uri.port + && port <= 99 + { + // The parse returns slices pointing into the original text string. + let host_start = offset_in(text, host); + let path_start = offset_in(text, uri.path.as_str()); + let mac_address = &text[host_start..path_start]; + if !is_valid_mac_address(mac_address) { + return Err(ParseError::InvalidMacAddress); + } + + uri.host = Some(Component::PercentEncoded(mac_address)); + uri.port = None; + } + + // When the raw_path option is active, return everything after the authority + // (host) in the .path component, including any query and fragment values. + if options.raw_path { + let path_start = offset_in(text, uri.path.as_str()); + uri.path = Component::Raw(Cow::Borrowed(&text[path_start..])); + uri.query = None; + uri.fragment = None; + } + + Ok(uri) +} + +/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef. +fn is_valid_mac_address(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() != 17 { + return false; + } + + for (i, &c) in b.iter().enumerate() { + if i % 3 == 2 { + if c != b':' { + return false; + } + } else if !c.is_ascii_hexdigit() { + return false; + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC: ParseOptions = ParseOptions { + mac_address: true, + raw_path: false, + }; + + fn host<'a>(u: &'a Uri<'_>) -> &'a str { + match u.host.as_ref().unwrap() { + Component::PercentEncoded(s) => s, + Component::Raw(_) => panic!("raw host"), + } + } + + #[test] + fn parse_mac_address() { + // Numeric MAC address without a port + let uri1 = parse("file://00:12:34:56:78:90/path", MAC).unwrap(); + assert_eq!("file", uri1.scheme); + assert_eq!("00:12:34:56:78:90", host(&uri1)); + assert_eq!("/path", uri1.path.as_str()); + assert_eq!(None, uri1.port); + + // Numeric MAC address with a port + let uri2 = parse("file://00:12:34:56:78:90:999/path", MAC).unwrap(); + assert_eq!("file", uri2.scheme); + assert_eq!("00:12:34:56:78:90", host(&uri2)); + assert_eq!("/path", uri2.path.as_str()); + assert_eq!(Some(999), uri2.port); + + // Alphabetic MAC address without a port + let uri3 = parse("file://ab:cd:ef:ab:cd:ef/path", MAC).unwrap(); + assert_eq!("file", uri3.scheme); + assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri3)); + assert_eq!("/path", uri3.path.as_str()); + assert_eq!(None, uri3.port); + + // Alphabetic MAC address with a port + let uri4 = parse("file://ab:cd:ef:ab:cd:ef:999/path", MAC).unwrap(); + assert_eq!("file", uri4.scheme); + assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri4)); + assert_eq!("/path", uri4.path.as_str()); + assert_eq!(Some(999), uri4.port); + + // Numeric MAC address without a path component + let uri5 = parse("file://00:12:34:56:78:90", MAC).unwrap(); + assert_eq!("file", uri5.scheme); + assert_eq!("00:12:34:56:78:90", host(&uri5)); + assert!(uri5.path.is_empty()); + + // Alphabetic MAC address without a path component + let uri6 = parse("file://ab:cd:ef:ab:cd:ef", MAC).unwrap(); + assert_eq!("file", uri6.scheme); + assert_eq!("ab:cd:ef:ab:cd:ef", host(&uri6)); + assert!(uri6.path.is_empty()); + + // Invalid MAC addresses + assert_eq!( + Err(ParseError::InvalidMacAddress), + parse("file://zz:zz:zz:zz:zz:00/path", MAC) + ); + assert_eq!( + Err(ParseError::InvalidMacAddress), + parse("file://zz:zz:zz:zz:zz:zz/path", MAC) + ); + } + + #[test] + fn parse_raw_path() { + let text = "file://localhost/path??#fragment"; + + let uri1 = parse( + text, + ParseOptions { + raw_path: false, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("file", uri1.scheme); + assert_eq!("localhost", host(&uri1)); + assert_eq!("/path", uri1.path.to_raw()); + assert_eq!("?", uri1.query.as_ref().unwrap().as_str()); + assert_eq!("fragment", uri1.fragment.as_ref().unwrap().as_str()); + + let uri2 = parse( + text, + ParseOptions { + raw_path: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("file", uri2.scheme); + assert_eq!("localhost", host(&uri2)); + assert_eq!("/path??#fragment", uri2.path.to_raw()); + assert_eq!(None, uri2.query); + assert_eq!(None, uri2.fragment); + + let uri3 = parse( + "file://localhost", + ParseOptions { + raw_path: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("file", uri3.scheme); + assert_eq!("localhost", host(&uri3)); + assert!(uri3.path.is_empty()); + assert_eq!(None, uri3.query); + assert_eq!(None, uri3.fragment); + } + + #[test] + fn is_valid_mac_address_cases() { + assert!(is_valid_mac_address("01:23:45:67:89:Aa")); + assert!(is_valid_mac_address("Aa:Bb:Cc:Dd:Ee:Ff")); + + assert!(!is_valid_mac_address("")); + assert!(!is_valid_mac_address("00:23:45")); + assert!(!is_valid_mac_address("00:23:45:Xx:Yy:Zz")); + assert!(!is_valid_mac_address("01-23-45-67-89-Aa")); + assert!(!is_valid_mac_address("01:23:45:67:89:Aa:Bb")); + } + + #[test] + fn std_parse_basics() { + let u = parse( + "https://user:pw@example.com:8080/a%20b?q=1#frag", + ParseOptions::default(), + ) + .unwrap(); + assert_eq!(u.scheme, "https"); + assert_eq!(u.user.as_ref().unwrap().as_str(), "user"); + assert_eq!(u.password.as_ref().unwrap().as_str(), "pw"); + assert_eq!(host(&u), "example.com"); + assert_eq!(u.port, Some(8080)); + assert_eq!(u.path.to_raw(), "/a b"); + assert_eq!(u.query.as_ref().unwrap().as_str(), "q=1"); + assert_eq!(u.fragment.as_ref().unwrap().as_str(), "frag"); + + let u = parse("file:///tmp/x", ParseOptions::default()).unwrap(); + assert_eq!(u.host, None); + assert_eq!(u.path.as_str(), "/tmp/x"); + + let u = parse("http://[::1]:80/", ParseOptions::default()).unwrap(); + assert_eq!(host(&u), "[::1]"); + assert_eq!(u.port, Some(80)); + + assert_eq!( + parse("nocolon", ParseOptions::default()), + Err(ParseError::UnexpectedCharacter) + ); + assert_eq!( + parse("file://a:b/", ParseOptions::default()), + Err(ParseError::InvalidPort) + ); + assert_eq!( + parse("file://?x", ParseOptions::default()), + Err(ParseError::InvalidFormat) + ); + } +} diff --git a/crates/ghostty-os/src/xdg.rs b/crates/ghostty-os/src/xdg.rs new file mode 100644 index 0000000..51fae5b --- /dev/null +++ b/crates/ghostty-os/src/xdg.rs @@ -0,0 +1,201 @@ +//! Implementation of the XDG Base Directory specification +//! (https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) + +use std::ffi::CStr; + +use crate::env::getenv_not_empty; +use crate::{homedir, path_join}; + +#[derive(Debug, Clone, Default)] +pub struct Options<'a> { + /// Subdirectories to join to the base. This avoids extra allocations + /// when building up the directory. This is commonly the application. + pub subdir: Option<&'a str>, + + /// The home directory for the user. If this is not set, we will attempt + /// to look it up which is an expensive process. By setting this, you can + /// avoid lookups. + pub home: Option<&'a str>, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("no home directory could be determined")] + NoHomeDir, +} + +/// Get the XDG user config directory. +pub fn config(opts: &Options<'_>) -> Result { + dir(opts, "XDG_CONFIG_HOME", ".config") +} + +/// Get the XDG cache directory. +pub fn cache(opts: &Options<'_>) -> Result { + dir(opts, "XDG_CACHE_HOME", ".cache") +} + +/// Get the XDG state directory. +pub fn state(opts: &Options<'_>) -> Result { + dir(opts, "XDG_STATE_HOME", ".local/state") +} + +/// Unified helper to get XDG directories that follow a common pattern. +fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result { + // If we have a cached home dir, use that. + if let Some(home) = opts.home { + return Ok(path_join(&[ + home, + default_subdir, + opts.subdir.unwrap_or(""), + ])); + } + + // First check the env var. + if let Some(env) = getenv_not_empty(env) { + // If we have a subdir, then we join it. + if let Some(subdir) = opts.subdir { + return Ok(path_join(&[&env, subdir])); + } + + return Ok(env); + } + + // Get our home dir + if let Ok(Some(home)) = homedir::home() { + return Ok(path_join(&[ + &home, + default_subdir, + opts.subdir.unwrap_or(""), + ])); + } + + Err(Error::NoHomeDir) +} + +/// Parses the xdg-terminal-exec specification. This expects argv[0] to +/// be "xdg-terminal-exec". +pub fn parse_terminal_exec<'a>(argv: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> { + let first = argv.first()?.to_bytes(); + let base = first.rsplit(|&b| b == b'/').next().unwrap_or(first); + if base != b"xdg-terminal-exec" { + return None; + } + + // We expect at least one argument + if argv.len() < 2 { + return Some(&[]); + } + + // If the first argument is "-e" we skip it. + let start = if argv[1].to_bytes() == b"-e" { 2 } else { 1 }; + Some(&argv[start..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_default() { + let value = crate::testutil::with_env_lock(|| config(&Options::default()).unwrap()); + assert!(!value.is_empty()); + } + + #[test] + fn cache_directory_paths() { + let mock_home = "/Users/test"; + + // Test base path + let p = cache(&Options { + home: Some(mock_home), + ..Default::default() + }) + .unwrap(); + assert_eq!(p, "/Users/test/.cache"); + + // Test with subdir + let p = cache(&Options { + home: Some(mock_home), + subdir: Some("ghostty"), + }) + .unwrap(); + assert_eq!(p, "/Users/test/.cache/ghostty"); + } + + type DirFn = fn(&Options<'_>) -> Result; + const CASES: [(&str, DirFn, &str); 3] = [ + ("XDG_CONFIG_HOME", config, ".config"), + ("XDG_CACHE_HOME", cache, ".cache"), + ("XDG_STATE_HOME", state, ".local/state"), + ]; + + #[test] + fn fallback_when_xdg_env_empty() { + let temp_home = "/tmp/ghostty-test-home"; + for (name, func, default_subdir) in CASES { + temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || { + let expected = path_join(&[temp_home, default_subdir]); + let actual = func(&Options::default()).unwrap(); + assert_eq!(expected, actual); + }); + } + } + + #[test] + fn fallback_when_xdg_env_empty_and_subdir() { + let temp_home = "/tmp/ghostty-test-home"; + for (name, func, default_subdir) in CASES { + temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || { + let expected = path_join(&[temp_home, default_subdir, "ghostty"]); + let actual = func(&Options { + subdir: Some("ghostty"), + ..Default::default() + }) + .unwrap(); + assert_eq!(expected, actual); + }); + } + } + + #[test] + fn env_var_used_when_set() { + temp_env::with_vars([("XDG_CONFIG_HOME", Some("/x/cfg"))], || { + assert_eq!(config(&Options::default()).unwrap(), "/x/cfg"); + let o = Options { + subdir: Some("ghostty"), + ..Default::default() + }; + assert_eq!(config(&o).unwrap(), "/x/cfg/ghostty"); + }); + } + + fn c(s: &str) -> std::ffi::CString { + std::ffi::CString::new(s).unwrap() + } + + #[test] + fn parse_terminal_exec_cases() { + fn run(args: &[&str]) -> Option> { + let owned: Vec = args.iter().map(|s| c(s)).collect(); + let refs: Vec<&CStr> = owned.iter().map(std::ffi::CString::as_c_str).collect(); + parse_terminal_exec(&refs) + .map(|s| s.iter().map(|x| x.to_str().unwrap().to_owned()).collect()) + } + + assert_eq!(run(&["a", "b", "c"]), None); + assert_eq!(run(&["xdg-terminal-exec"]), Some(vec![])); + assert_eq!( + run(&["xdg-terminal-exec", "a", "b", "c"]), + Some(vec!["a".into(), "b".into(), "c".into()]) + ); + assert_eq!( + run(&["xdg-terminal-exec", "-e", "a", "b", "c"]), + Some(vec!["a".into(), "b".into(), "c".into()]) + ); + assert_eq!( + run(&["xdg-terminal-exec", "a", "-e", "b", "c"]), + Some(vec!["a".into(), "-e".into(), "b".into(), "c".into()]) + ); + assert_eq!(run(&["/usr/bin/xdg-terminal-exec"]), Some(vec![])); + } +} diff --git a/docs/rewrite/notes/ghostty-os.md b/docs/rewrite/notes/ghostty-os.md new file mode 100644 index 0000000..3acadc1 --- /dev/null +++ b/docs/rewrite/notes/ghostty-os.md @@ -0,0 +1,70 @@ +# ghostty-os notes + +Port of `ZIG/src/os/*` (Linux only) and `ZIG/src/file_type.zig`. `unsafe` is +forbidden; deps: rustix, nix (user), zbus, gettext-rs (`gettextrs`), thiserror, +log; dev: temp-env. + +## File -> module +xdg, homedir, hostname, env, file, flatpak, i18n, i18n_locales, locale, open, +passwd, path, pipe, resourcesdir, shell, string_encoding, systemd, +TempDir (`temp_dir`), uri, desktop, kernel_info, cgroup, dbus, mouse, args, +main (re-exports in `lib.rs`), `file_type` (from `src/file_type.zig`). + +## Skipped files +macos.zig, mach.zig, cf_release_thread.zig, windows.zig, wasm.zig, wasm/* +(macOS/Windows/WASM are out of scope). Windows/macOS branches inside ported +files are dropped. `main.zig` exports `OpenType` (stale, nonexistent in +open.zig) - not ported. + +## API shape deviations (buffers/allocators -> owned values) +- Zig allocator/fixed-buffer APIs return `String`/`Option`. Fixed + buffer overflow semantics kept only where tested: `homedir::home_with_capacity` + / `expand_home_with_capacity` (BufferTooSmall). +- `env::getenv` returns `Option` (no `GetEnvResult`); `setenv`/`unsetenv` + are NOT provided (unsafe in Rust 2024). Tests use `temp_env` (dev-dep). +- `ShellEscapeWriter`, `ShellCommandBuilder::to_owned_slice` -> `CString`. +- `string_encoding` take `impl io::Write`; error enum `{DecodeError, Io}`. +- `uri`: own `Uri`/`Component` types replicating `std.Uri.parse` (Zig std not + available in Rust); `parse(text, ParseOptions)` as in Zig. +- `cgroup::current(pid)` reads at most 4096 bytes (caller buffer in Zig). +- `resourcesdir`: Zig `builtin.mode == .Debug` mapped to `cfg!(debug_assertions)` + (note: workspace `release` profile has debug-assertions on; `production` off). +- `TempDir`: path-based (parent + name) rather than Dir handles; deletes on Drop; + `deinit(self)` provided. Dir created mode 0o755. +- `file::fix_max_files` uses rustix rlimit; `None` == RLIM_INFINITY. +- `systemd::notify`: a leading `@` in NOTIFY_SOCKET is now treated as an abstract + socket (Zig copied it literally, which can't work). Port otherwise 1:1. +- `i18n::canonicalize_locale`: gnulib's `_libintl_locale_name_canonicalize` is + not available; pure-Rust approximation (zh fixes + `-` -> `_`). `tr()` == + `_()` (dgettext on bundle id domain). `static_locale` is a `const fn` that + panics at compile time for unsupported locales. i18n always enabled. +- `open::open(kind, url)`: local `OpenUrlKind` enum + (TODO(integration): apprt action kind). Snap handling keyed on `SNAP` env + instead of build flag. + +## Deferred / incomplete +- **locale::ensure_locale is a no-op.** `setlocale` and `setenv` are unsafe; + no safe crate wraps them (`gettextrs::setlocale` is `unsafe fn`). gtk_init + calls `setlocale(LC_ALL, "")` on Linux. TODO(integration): if needed, perform + the libc call in an unsafe-allowed crate (steps documented in locale.rs). +- **passwd Flatpak branch**: Zig shells out to host `getent passwd` via + FlatpakHostCommand + Pty. Needs ghostty-termio Pty -> TODO(integration); + currently warns and uses sandbox entry. +- **FlatpakHostCommand**: implemented with `zbus` blocking API (HostCommand, + HostCommandSignal, HostCommandExited signal, fd passing) in a helper thread; + `waitXev` replaced by `wait_callback`. Compiles and the state machine/error + paths are tested, but the D-Bus path is UNTESTED (no Flatpak portal here). + Constructor `FlatpakHostCommand::new(argv, stdin, stdout, stderr)`; fds are + `OwnedFd` and dup'd on spawn. +- `mouse::click_interval` is `None` on Linux (macOS only). + +## Tests +Ported Linux-applicable Zig tests (all passing, 88 total incl. extras): +xdg(5), homedir(2: expandHomeUnix, home), hostname(4), env(4), path(3), +shell(1 builder + 7 escape), string_encoding(10 printf_q + singles_percent + +7 percent), uri(3), desktop(1), kernel_info(1), args(1), passwd(1), TempDir(1), +i18n canonicalize (darwin-only in Zig, run here against the approximation). +Skipped: none of the Linux tests. Extras: path_join, file_type, systemd notify +(real unix datagram socket), resourcesdir, pipe, uri/std parse, etc. +Zig tests that mutate env were rewritten with `temp_env::with_vars`; tests that +read PATH/HOME take the same lock via `testutil::with_env_lock`.