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,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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user