feat(os): port Ghostty's Linux OS helpers
Ports ZIG/src/os for Linux into ghostty-os: XDG dirs, home dir, hostname, env/PATH helpers, passwd lookup (via nix), shell detection, resources dir discovery, string encodings (printf-q, URL percent), systemd sd_notify, i18n via gettextrs, xdg-open, desktop/cgroup/kernel info, temp dirs, URI parsing (port of std.Uri incl. Ghostty's extra options), and a zbus-based Flatpak HostCommand implementation. The crate stays free of unsafe. Consequences, all tracked as TODO(integration) in docs/rewrite/notes/ghostty-os.md: - locale::ensure_locale is a no-op (setlocale/setenv are unsafe; GTK calls setlocale itself); setenv/unsetenv are not exposed. - passwd's Flatpak branch needs the termio pty and falls back to the sandbox entry for now. - FlatpakHostCommand's D-Bus path is untested (no portal here). Intentional fix: a leading '@' in NOTIFY_SOCKET is treated as an abstract socket. macOS/Windows/WASM modules are out of scope. Test Plan: - cargo test -p ghostty-os (88 tests) - cargo clippy -p ghostty-os --all-targets -- -D warnings
This commit is contained in:
1 parent
04a6b7c97a
commit
b681241359
30 files changed
+3736
-1
No files matched your search
@@ -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/<ppid>/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::<i64>().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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user