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
1388f117d3
commit
f8c5ce06d6
30 files changed
+3736
-1
No files matched your search
@@ -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<String>,
|
||||
pub host_path: Option<String>,
|
||||
}
|
||||
|
||||
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<ResourcesDir> {
|
||||
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<String>) -> 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user