feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)

Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
This commit is contained in:
ddidderr committed 2026-10-12 06:02:39 +02:00
1 parent 76771bed0d
commit bab49ee596
124 files changed
+14936 -1242

No files matched your search

+19
View File
@@ -5,10 +5,28 @@
//! don't panic).
/// Returns an iterator over the command line arguments.
#[cfg(not(target_os = "macos"))]
pub fn iterator() -> std::env::ArgsOs {
std::env::args_os()
}
/// Returns an iterator over the command line arguments.
///
/// On macOS this reads `NSProcessInfo.arguments` instead of libc
/// argc/argv, which are not valid for `NSApplicationMain`-based
/// applications (Zig: `IteratorMacOS`).
#[cfg(target_os = "macos")]
pub fn iterator() -> std::vec::IntoIter<std::ffi::OsString> {
use objc2_foundation::NSProcessInfo;
NSProcessInfo::processInfo()
.arguments()
.iter()
.map(|s| std::ffi::OsString::from(s.to_string()))
.collect::<Vec<_>>()
.into_iter()
}
/// Collect the process arguments as `String`s without ever panicking on
/// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers
/// are string based; `+action` dispatch only depends on the ASCII prefix.
@@ -30,6 +48,7 @@ mod tests {
assert!(!iter.next().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn lossy_does_not_panic_on_non_utf8() {
use std::os::unix::ffi::OsStringExt;
+193
View File
@@ -0,0 +1,193 @@
//! The CFRelease thread. Port of `os/cf_release_thread.zig`.
//!
//! Pools of `CFTypeRef`s are sent to this thread to be released, so that
//! their release callback logic doesn't block the execution of a high
//! throughput thread like the renderer thread.
//!
//! The libxev loop of the Zig version (wakeup + stop async handles around a
//! blocking mailbox) is replaced by a bounded channel (capacity 64, like
//! `BlockingQueue(Message, 64)`) plus a stop channel; a `select!` loop
//! drains the mailbox. The release function is injectable so the thread
//! logic is testable on every platform; on Darwin the default is
//! `CFRelease`.
use std::{ffi::c_void, ptr::NonNull, thread::JoinHandle};
use crossbeam_channel::{Receiver, Sender, bounded, select};
/// Mailbox capacity (Zig: `BlockingQueue(Message, 64)`).
pub const MAILBOX_CAPACITY: usize = 64;
/// A raw `CFTypeRef` owned by the sender (+1 retain to be balanced by one
/// release on this thread).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CfRef(pub NonNull<c_void>);
// SAFETY: a CFTypeRef is a thread-safe, reference-counted handle; ownership
// of one retain count is transferred with the message.
#[allow(unsafe_code)]
unsafe impl Send for CfRef {}
#[derive(Debug)]
pub enum Message {
/// Release a list of CFTypeRefs.
Release(Vec<CfRef>),
}
/// Function used to release one ref.
pub type ReleaseFn = fn(CfRef);
/// Darwin `CFRelease`.
#[cfg(target_vendor = "apple")]
pub fn cf_release(r: CfRef) {
use objc2_core_foundation::{CFRetained, CFType};
// SAFETY: the sender transferred one retain count to us, and `r` is a
// valid non-null CFTypeRef; dropping the CFRetained releases it.
#[allow(unsafe_code)]
unsafe {
drop(CFRetained::from_raw(r.0.cast::<CFType>()));
}
}
/// Handle used to talk to a running [`Thread`].
#[derive(Clone)]
pub struct Handle {
mailbox: Sender<Message>,
stop: Sender<()>,
}
impl Handle {
/// Push a message, blocking if the mailbox is full (`.forever`).
/// Returns false if the thread is gone.
pub fn send(&self, msg: Message) -> bool {
self.mailbox.send(msg).is_ok()
}
/// Ask the thread to stop on the next loop iteration.
pub fn stop(&self) {
let _ = self.stop.try_send(());
}
}
/// The CFRelease thread (not yet started).
pub struct Thread {
mailbox_rx: Receiver<Message>,
stop_rx: Receiver<()>,
handle: Handle,
release: ReleaseFn,
}
pub struct Running {
pub handle: Handle,
join: JoinHandle<()>,
}
impl Running {
/// Stop the thread and wait for it to exit.
pub fn stop_and_join(self) {
self.handle.stop();
let _ = self.join.join();
}
}
impl Thread {
/// Initialize the thread with the default (`CFRelease`) release function.
#[cfg(target_vendor = "apple")]
pub fn init() -> Thread {
Thread::with_release(cf_release)
}
/// Initialize the thread with a custom release function.
pub fn with_release(release: ReleaseFn) -> Thread {
let (tx, mailbox_rx) = bounded(MAILBOX_CAPACITY);
let (stop_tx, stop_rx) = bounded(1);
Thread {
mailbox_rx,
stop_rx,
handle: Handle {
mailbox: tx,
stop: stop_tx,
},
release,
}
}
pub fn handle(&self) -> Handle {
self.handle.clone()
}
/// Spawn the thread (named "cf_release" like the Zig version).
pub fn spawn(self) -> std::io::Result<Running> {
let handle = self.handle.clone();
let join = std::thread::Builder::new()
.name("cf_release".into())
.spawn(move || self.thread_main())?;
Ok(Running { handle, join })
}
/// The main entrypoint for the thread.
pub fn thread_main(self) {
// Right now, on Darwin, `std::thread::Builder::name` already names
// the thread through pthread_setname_np for the current thread, but
// keep the explicit call for parity with Zig.
#[cfg(target_vendor = "apple")]
crate::macos::pthread_setname_np(c"cf_release");
log::debug!("starting cf release thread");
loop {
select! {
recv(self.mailbox_rx) -> msg => match msg {
Ok(msg) => self.handle_message(msg),
// All senders gone: nothing more can arrive.
Err(_) => break,
},
recv(self.stop_rx) -> _ => break,
}
}
// Messages published before the stop request are still released so
// we never leak refs.
while let Ok(msg) = self.mailbox_rx.try_recv() {
self.handle_message(msg);
}
log::debug!("cf release thread exited");
}
fn handle_message(&self, msg: Message) {
match msg {
Message::Release(refs) => {
for r in refs {
(self.release)(r);
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
static RELEASED: AtomicUsize = AtomicUsize::new(0);
fn count_release(_: CfRef) {
RELEASED.fetch_add(1, Ordering::SeqCst);
}
#[test]
fn releases_all_refs_before_exit() {
let t = Thread::with_release(count_release);
let running = t.spawn().unwrap();
for _ in 0..3 {
let refs = (1..=5usize)
.map(|i| CfRef(NonNull::new(i as *mut c_void).unwrap()))
.collect();
assert!(running.handle.send(Message::Release(refs)));
}
running.stop_and_join();
assert_eq!(RELEASED.load(Ordering::SeqCst), 15);
}
}
+83 -2
View File
@@ -3,6 +3,23 @@
/// 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.
#[cfg(target_os = "macos")]
pub fn launched_from_desktop() -> bool {
// macOS apps launched from finder or `open` always have the init
// process as their parent.
macos_launched_from_desktop(false, std::env::var_os(MAC_LAUNCH_SOURCE_ENV), || {
rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid)
})
}
/// iPhone/iPad is always launched from the "desktop".
#[cfg(target_os = "ios")]
pub fn launched_from_desktop() -> bool {
true
}
/// See the other variants.
#[cfg(not(any(target_os = "macos", target_os = "ios")))]
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
@@ -14,14 +31,49 @@ pub fn launched_from_desktop() -> bool {
return false;
};
let pid = rustix::process::getpid().as_raw_nonzero().get();
let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::<i32>() else {
// (`std::process::id` is `getpid` / `GetCurrentProcessId`.)
let pid = std::process::id();
let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::<u32>() else {
return false;
};
gio_pid == pid
}
/// Environment variable set by the macOS app to tell libghostty how it was
/// launched ("app", "cli" or "zig_run").
pub const MAC_LAUNCH_SOURCE_ENV: &str = "GHOSTTY_MAC_LAUNCH_SOURCE";
/// `launched_from_desktop` on macOS. `artifact_is_lib` mirrors
/// `build_config.artifact == .lib`: when embedded as a library, the app
/// bundle may tell us via [`MAC_LAUNCH_SOURCE_ENV`] that it launched us even
/// though our parent isn't `launchd`.
#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
fn macos_launched_from_desktop(
artifact_is_lib: bool,
source: Option<std::ffi::OsString>,
getppid: impl FnOnce() -> i32,
) -> bool {
if artifact_is_lib && let Some(source) = source {
// Source can be "app", "cli", or "zig_run". We assume its the
// desktop only if its "app".
if source == "app" {
return true;
}
}
getppid() == 1
}
/// The libghostty variant of [`launched_from_desktop`] on macOS (honours the
/// `GHOSTTY_MAC_LAUNCH_SOURCE` environment variable).
#[cfg(target_os = "macos")]
pub fn launched_from_desktop_lib() -> bool {
macos_launched_from_desktop(true, std::env::var_os(MAC_LAUNCH_SOURCE_ENV), || {
rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_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`.
@@ -37,6 +89,13 @@ pub enum DesktopEnvironment {
/// on Linux and BSD to enable or disable certain features but there may be more uses in
/// the future.
pub fn desktop_environment() -> DesktopEnvironment {
if cfg!(windows) {
return DesktopEnvironment::Windows;
}
if cfg!(target_os = "macos") {
return DesktopEnvironment::Macos;
}
// 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") {
@@ -69,6 +128,8 @@ pub fn desktop_environment() -> DesktopEnvironment {
mod tests {
use super::*;
#[cfg(unix)]
#[allow(dead_code)]
fn de(session: Option<&str>, current: Option<&str>) -> DesktopEnvironment {
temp_env::with_vars(
[
@@ -79,6 +140,8 @@ mod tests {
)
}
#[cfg(unix)]
#[cfg(not(target_os = "macos"))]
#[test]
fn desktop_environment_detection() {
use DesktopEnvironment::{Gnome, Other};
@@ -93,6 +156,24 @@ mod tests {
assert_eq!(de(Some("foobar"), Some("GNOME")), Gnome);
}
#[test]
fn macos_launch_detection() {
use std::ffi::OsString;
// Parent is launchd.
assert!(macos_launched_from_desktop(false, None, || 1));
assert!(!macos_launched_from_desktop(false, None, || 4242));
// The env var only counts for the lib artifact and only for "app".
let app = Some(OsString::from("app"));
assert!(macos_launched_from_desktop(true, app.clone(), || 4242));
assert!(!macos_launched_from_desktop(false, app, || 4242));
assert!(!macos_launched_from_desktop(
true,
Some(OsString::from("cli")),
|| 4242
));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn launched_from_desktop_pid_match() {
let pid = std::process::id().to_string();
+47 -10
View File
@@ -1,11 +1,15 @@
use std::{
borrow::Cow,
collections::HashMap,
ffi::{OsStr, OsString},
};
use crate::flatpak::is_flatpak;
/// PATH-style delimiter (`std.fs.path.delimiter` on POSIX).
/// PATH-style delimiter (`std.fs.path.delimiter`).
#[cfg(windows)]
const DELIMITER: char = ';';
#[cfg(not(windows))]
const DELIMITER: char = ':';
/// A byte-preserving environment map (Zig `std.process.EnvMap`): keys and
@@ -18,21 +22,43 @@ impl EnvMap {
Self::default()
}
pub fn insert(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
/// On Windows environment variable names are case-insensitive (Zig's
/// `EnvMap` uses a case-insensitive hash context there), so lookups find
/// the existing key regardless of ASCII case. Elsewhere this is the key
/// itself.
#[cfg(windows)]
fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> {
let want = key.to_string_lossy().to_ascii_uppercase();
self.0
.insert(key.as_ref().to_owned(), value.as_ref().to_owned())
.keys()
.find(|k| k.to_string_lossy().to_ascii_uppercase() == want)
.map_or(Cow::Borrowed(key), |k| Cow::Borrowed(k.as_os_str()))
}
#[cfg(not(windows))]
#[allow(clippy::unused_self)]
fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> {
Cow::Borrowed(key)
}
pub fn insert(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
let key = self.canonical_key(key.as_ref()).into_owned();
self.0.insert(key, value.as_ref().to_owned())
}
pub fn get(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
self.0.get(key.as_ref()).map(OsString::as_os_str)
self.0
.get(&*self.canonical_key(key.as_ref()))
.map(OsString::as_os_str)
}
pub fn remove(&mut self, key: impl AsRef<OsStr>) -> Option<OsString> {
self.0.remove(key.as_ref())
let key = self.canonical_key(key.as_ref()).into_owned();
self.0.remove(&key)
}
pub fn contains_key(&self, key: impl AsRef<OsStr>) -> bool {
self.0.contains_key(key.as_ref())
self.0.contains_key(&*self.canonical_key(key.as_ref()))
}
pub fn len(&self) -> usize {
@@ -89,6 +115,13 @@ pub fn get_env_map() -> EnvMap {
/// drop `:`-separated components that start with one of the snap
/// directories. Variables we set ourselves (`TERMINFO`, `GHOSTTY*`) are
/// left alone.
#[cfg(windows)]
pub fn filter_snap_paths(_env: &mut EnvMap) {
// Snap only exists on Linux.
}
/// See the Windows no-op above.
#[cfg(unix)]
pub fn filter_snap_paths(env: &mut EnvMap) {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
@@ -216,10 +249,12 @@ pub fn getenv_not_empty(key: &str) -> Option<String> {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use super::*;
#[cfg(unix)]
#[test]
fn env_map_preserves_non_utf8() {
let mut env = EnvMap::new();
@@ -228,6 +263,7 @@ mod tests {
assert_eq!(env.get("K"), Some(v.as_os_str()));
}
#[cfg(unix)]
#[test]
fn filter_snap_paths_filters() {
let mut env = EnvMap::new();
@@ -255,9 +291,10 @@ mod tests {
#[test]
fn append_env_existing() {
assert_eq!(append_env("a:b", "foo"), "a:b:foo");
assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo"));
}
#[cfg(unix)]
#[test]
fn os_variants_preserve_bytes() {
let cur = OsString::from_vec(vec![0xff, b'a']);
@@ -275,13 +312,13 @@ mod tests {
#[test]
fn prepend_env_existing() {
assert_eq!(prepend_env("a:b", "foo"), "foo:a:b");
assert_eq!(prepend_env("a:b", "foo"), format!("foo{DELIMITER}a:b"));
}
#[test]
fn append_env_always_empty_value() {
assert_eq!(append_env_always("a", ""), "a:");
assert_eq!(append_env_always("", "x"), ":x");
assert_eq!(append_env_always("a", ""), format!("a{DELIMITER}"));
assert_eq!(append_env_always("", "x"), format!("{DELIMITER}x"));
}
#[test]
+15 -1
View File
@@ -1,17 +1,23 @@
#[cfg(unix)]
use rustix::process::{Resource, Rlimit as RustixRlimit, getrlimit, setrlimit};
/// Resource limit (`rlimit` in Zig). `None` means infinity.
#[cfg(unix)]
pub type Rlimit = RustixRlimit;
/// rlim_t value for infinity in the Zig implementation.
#[cfg(unix)]
const INFINITY: u64 = u64::MAX;
#[cfg(unix)]
fn cur(l: &Rlimit) -> u64 {
l.current.unwrap_or(INFINITY)
}
#[cfg(unix)]
fn max(l: &Rlimit) -> u64 {
l.maximum.unwrap_or(INFINITY)
}
#[cfg(unix)]
fn mk(cur: u64, max: Option<u64>) -> Rlimit {
Rlimit {
current: if cur == INFINITY { None } else { Some(cur) },
@@ -22,6 +28,7 @@ fn mk(cur: u64, max: Option<u64>) -> Rlimit {
/// 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.
#[cfg(unix)]
pub fn fix_max_files() -> Option<Rlimit> {
let old = getrlimit(Resource::Nofile);
@@ -58,12 +65,17 @@ pub fn fix_max_files() -> Option<Rlimit> {
Some(old)
}
#[cfg(unix)]
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<String> {
if cfg!(windows) {
// TODO: what is a good fallback path on windows? (same as Zig)
return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned());
}
if let Some(v) = std::env::var_os("TMPDIR") {
return Some(v.to_string_lossy().into_owned());
}
@@ -73,10 +85,11 @@ pub fn alloc_tmp_dir() -> Option<String> {
Some("/tmp".to_owned())
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn tmp_dir_env_precedence() {
temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || {
@@ -90,6 +103,7 @@ mod tests {
});
}
#[cfg(unix)]
#[test]
fn fix_and_restore_max_files() {
let old = fix_max_files().unwrap();
+70
View File
@@ -1,5 +1,7 @@
#[cfg(unix)]
use std::process::{Command, Stdio};
#[cfg(unix)]
use crate::passwd;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
@@ -28,6 +30,7 @@ pub fn home() -> Result<Option<String>, Error> {
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
#[cfg(unix)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
let check = |s: String| {
if cap < s.len() {
@@ -42,6 +45,18 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
return check(result.to_string_lossy().into_owned());
}
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
#[cfg(target_os = "macos")]
{
use objc2_foundation::NSFileManager;
let manager = NSFileManager::defaultManager();
let home_url = manager.homeDirectoryForCurrentUser();
if let Some(path) = home_url.path() {
return check(path.to_string());
}
}
// 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
@@ -65,6 +80,34 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
Ok(None)
}
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
/// either variable is missing.
#[cfg(windows)]
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
home_windows(
std::env::var("HOMEDRIVE").ok().as_deref(),
std::env::var("HOMEPATH").ok().as_deref(),
cap,
)
}
/// Pure part of [`home_with_capacity`] on Windows.
#[cfg(any(windows, test))]
fn home_windows(
drive: Option<&str>,
path: Option<&str>,
cap: usize,
) -> Result<Option<String>, Error> {
let (Some(drive), Some(path)) = (drive, path) else {
return Ok(None);
};
if cap < drive.len() + path.len() {
return Err(Error::BufferTooSmall);
}
Ok(Some(format!("{drive}{path}")))
}
#[cfg(unix)]
fn trim_space(input: &str) -> &str {
input.trim_matches([' ', '\n', '\t'])
}
@@ -80,6 +123,14 @@ pub fn expand_home(path: &str) -> Result<String, ExpandError> {
/// Like [`expand_home`] with an explicit output capacity (emulating the
/// caller-provided buffer of the Zig version).
#[cfg(windows)]
pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result<String, ExpandError> {
// `~/` is not an idiom generally used on Windows
Ok(path.to_owned())
}
/// See the Windows variant above.
#[cfg(unix)]
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
if !path.starts_with("~/") {
return Ok(path.to_owned());
@@ -100,6 +151,25 @@ pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, Expan
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_windows_joins_drive_and_path() {
assert_eq!(
home_windows(Some("C:"), Some("\\Users\\me"), 4096),
Ok(Some("C:\\Users\\me".to_owned()))
);
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
assert_eq!(
home_windows(Some("C:"), Some("\\Users"), 3),
Err(Error::BufferTooSmall)
);
}
}
#[cfg(all(test, unix))]
mod tests_unix {
use super::*;
use crate::testutil::with_env_lock;
#[test]
+17 -7
View File
@@ -84,12 +84,22 @@ pub fn is_valid(hostname: &str) -> bool {
true
}
/// The current machine's hostname (`GetComputerNameA`).
#[cfg(windows)]
fn our_hostname() -> Option<String> {
crate::windows::computer_name()
}
/// The current machine's hostname (`gethostname`).
fn our_hostname() -> String {
rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned()
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)]
fn our_hostname() -> Option<String> {
Some(
rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned(),
)
}
/// Checks if a hostname is local to the current machine. This matches
@@ -102,7 +112,7 @@ pub fn is_local(hostname: &str) -> Result<bool, LocalHostnameValidationError> {
}
// If hostname is not "localhost" it must match our hostname.
Ok(hostname == our_hostname())
Ok(our_hostname().is_some_and(|ours| hostname == ours))
}
#[cfg(test)]
@@ -147,7 +157,7 @@ mod tests {
#[test]
fn is_local_returns_true_when_hostname_is_local() {
let local = our_hostname();
let local = our_hostname().unwrap();
assert!(is_local(&local).unwrap());
}
+14
View File
@@ -1,3 +1,4 @@
#[cfg(not(windows))]
use std::path::Path;
use crate::i18n_locales::LOCALES;
@@ -27,6 +28,15 @@ pub enum InitError {
/// 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.
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
pub fn init(_resources_dir: &str) -> Result<(), InitError> {
// i18n is unsupported on Windows
Ok(())
}
/// See the Windows variant above.
#[cfg(not(windows))]
pub fn init(resources_dir: &str) -> Result<(), InitError> {
// Our resources dir is always nested below the share dir that
// is standard for translations.
@@ -52,12 +62,16 @@ pub fn init(resources_dir: &str) -> Result<(), InitError> {
/// 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> {
#[cfg(not(windows))]
gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// Translate a message for the Ghostty domain (Zig: `_`).
pub fn tr(msgid: &str) -> String {
#[cfg(windows)]
return msgid.to_owned();
#[cfg(not(windows))]
gettextrs::dgettext(BUNDLE_ID, msgid)
}
+83 -16
View File
@@ -16,6 +16,8 @@
pub mod args;
#[cfg(not(target_arch = "wasm32"))]
pub mod cf_release_thread;
#[cfg(not(target_arch = "wasm32"))]
pub mod cgroup;
#[cfg(not(target_arch = "wasm32"))]
pub mod dbus;
@@ -26,8 +28,17 @@ pub mod env;
#[cfg(not(target_arch = "wasm32"))]
pub mod file;
pub mod file_type;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
pub mod flatpak;
#[cfg(windows)]
pub mod flatpak {
//! Flatpak does not exist on Windows.
/// Always false on Windows.
pub fn is_flatpak() -> bool {
false
}
}
#[cfg(not(target_arch = "wasm32"))]
pub mod homedir;
#[cfg(not(target_arch = "wasm32"))]
@@ -39,11 +50,34 @@ pub mod i18n_locales;
pub mod kernel_info;
#[cfg(not(target_arch = "wasm32"))]
pub mod locale;
#[cfg(unix)]
pub mod mach;
#[cfg(unix)]
pub mod macos;
pub mod mouse;
#[cfg(not(target_arch = "wasm32"))]
pub mod open;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
pub mod passwd;
#[cfg(windows)]
pub mod passwd {
//! `passwd.zig` does not exist on Windows (it `@compileError`s). This
//! stand-in reports an empty entry, which is what Zig callers' fallback
//! paths expect.
/// Entry retrieved from the passwd API (same shape as the Unix one).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Entry {
pub shell: Option<String>,
pub home: Option<String>,
pub name: Option<String>,
}
/// No passwd database on Windows: always an empty entry.
pub fn get() -> std::io::Result<Entry> {
Ok(Entry::default())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub mod path;
#[cfg(not(target_arch = "wasm32"))]
@@ -52,13 +86,15 @@ pub mod pipe;
pub mod resourcesdir;
pub mod shell;
pub mod string_encoding;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
pub mod systemd;
#[cfg(not(target_arch = "wasm32"))]
pub mod temp_dir;
pub mod uri;
#[cfg(target_arch = "wasm32")]
pub mod wasm;
#[cfg(windows)]
pub mod windows;
#[cfg(target_arch = "wasm32")]
mod unsupported;
@@ -91,9 +127,13 @@ pub use env::{
prepend_env_os,
};
#[cfg(not(target_arch = "wasm32"))]
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
pub use file::alloc_tmp_dir;
#[cfg(unix)]
pub use file::{Rlimit, fix_max_files, restore_max_files};
#[cfg(unix)]
pub use flatpak::FlatpakHostCommand;
#[cfg(not(target_arch = "wasm32"))]
pub use flatpak::{FlatpakHostCommand, is_flatpak};
pub use flatpak::is_flatpak;
#[cfg(not(target_arch = "wasm32"))]
pub use homedir::{expand_home, home};
#[cfg(not(target_arch = "wasm32"))]
@@ -108,15 +148,32 @@ pub use pipe::pipe;
#[cfg(not(target_arch = "wasm32"))]
pub use resourcesdir::{ResourcesDir, resources_dir};
pub use shell::ShellEscapeWriter;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
pub use systemd::launched_by_systemd;
#[cfg(not(target_arch = "wasm32"))]
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.
/// The native path separator (`std.fs.path.sep`).
#[cfg(windows)]
const SEP: char = '\\';
#[cfg(not(any(windows, target_arch = "wasm32")))]
const SEP: char = '/';
/// Join path components like `std.fs.path.join` for the host platform:
/// empty components are skipped and exactly one separator is placed
/// between components.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn path_join(parts: &[&str]) -> String {
path_join_with(SEP, parts)
}
/// [`path_join`] with an explicit separator (so both flavours are testable
/// on any host).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn path_join_with(sep: char, parts: &[&str]) -> String {
// Windows accepts `/` as a separator too; Zig treats both as separators
// when joining.
let is_sep = |c: char| c == sep || (sep == '\\' && c == '/');
let mut out = String::new();
for p in parts {
if p.is_empty() {
@@ -126,12 +183,12 @@ pub(crate) fn path_join(parts: &[&str]) -> String {
out.push_str(p);
continue;
}
let prev_sep = out.ends_with('/');
let this_sep = p.starts_with('/');
let prev_sep = out.ends_with(is_sep);
let this_sep = p.starts_with(is_sep);
match (prev_sep, this_sep) {
(true, true) => out.push_str(&p[1..]),
(false, false) => {
out.push('/');
out.push(sep);
out.push_str(p);
}
_ => out.push_str(p),
@@ -142,13 +199,23 @@ pub(crate) fn path_join(parts: &[&str]) -> String {
#[cfg(test)]
mod tests {
use super::path_join;
use super::path_join_with;
#[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");
let j = |p: &[&str]| path_join_with('/', p);
assert_eq!(j(&["/a", ".cache", ""]), "/a/.cache");
assert_eq!(j(&["/a/", "/b"]), "/a/b");
assert_eq!(j(&["", "b"]), "b");
assert_eq!(j(&["/a", "b", "c"]), "/a/b/c");
}
#[test]
fn path_join_windows_flavour() {
let j = |p: &[&str]| path_join_with('\\', p);
assert_eq!(j(&["C:\\a", ".cache", ""]), "C:\\a\\.cache");
assert_eq!(j(&["C:\\a\\", "\\b"]), "C:\\a\\b");
assert_eq!(j(&["C:\\a", "/b"]), "C:\\a/b");
assert_eq!(j(&["", "b"]), "b");
}
}
+168 -1
View File
@@ -16,7 +16,10 @@
//! (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.
//! On Darwin (`target_vendor = "apple"`) the full Zig behaviour is
//! implemented in [`darwin`] (it needs `unsafe` for `setlocale`/`setenv`):
//! `LANG`/`LANGUAGE` are pre-populated from Cocoa when unset and
//! `setlocale(LC_ALL, "")` is called with the Zig fallback chain.
/// The locale Ghostty falls back to (and exports as `LANG`) when the
/// environment's locale can't be set.
@@ -27,15 +30,179 @@ pub const FALLBACK_LOCALE: &str = "en_US.UTF-8";
/// 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.
#[cfg(not(target_vendor = "apple"))]
pub fn ensure_locale() -> Option<String> {
log::debug!("ensure_locale: setlocale is not available from safe Rust; relying on the toolkit");
None
}
/// Ensure that the locale is set (Darwin: full port of `ensureLocale`).
/// Returns `Some("en_US.UTF-8")` if we had to fall back to it (and exported
/// it as `LANG`), otherwise `None`.
#[cfg(target_vendor = "apple")]
pub fn ensure_locale() -> Option<String> {
darwin::ensure_locale()
}
/// Format the system locale as `<lang>_<country>.UTF-8` (the value Zig's
/// `setLangFromCocoa` exports as `LANG`).
pub fn format_lang(lang: &str, country: &str) -> String {
format!("{lang}_{country}.UTF-8")
}
/// Build the `LANGUAGE` value from Apple's preferred languages
/// (`preferredLanguageFromCocoa`): each BCP-47 tag is canonicalized to the
/// POSIX format, gets `.UTF-8` appended and they are joined with `:`.
/// Returns `None` if there are no preferred languages.
///
/// gettext uses `LANGUAGE` to override only translations, with a priority
/// order given by colon-separated entries (e.g. `en:fr`).
pub fn preferred_language_value<S: AsRef<str>>(preferred: &[S]) -> Option<String> {
let mut out = String::new();
for lang in preferred {
if !out.is_empty() {
out.push(':');
}
out.push_str(&crate::i18n::canonicalize_locale(lang.as_ref()));
// The canonicalized locale never contains the encoding and all of
// our translations require UTF-8 so we add that.
out.push_str(".UTF-8");
}
(!out.is_empty()).then_some(out)
}
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
mod darwin {
use std::ffi::{CStr, CString};
use super::{FALLBACK_LOCALE, format_lang, preferred_language_value};
fn setlocale_all(locale: &str) -> Option<String> {
let c = CString::new(locale).ok()?;
// SAFETY: valid NUL-terminated string; setlocale returns a pointer to
// a static string (or null) which we copy immediately.
unsafe {
let r = libc::setlocale(libc::LC_ALL, c.as_ptr());
(!r.is_null()).then(|| CStr::from_ptr(r).to_string_lossy().into_owned())
}
}
fn set_env(key: &str, value: &str) {
// SAFETY: called during early process startup (locale setup) before
// other threads read the environment, mirroring the Zig code.
unsafe { std::env::set_var(key, value) };
}
fn unset_env(key: &str) {
// SAFETY: see `set_env`.
unsafe { std::env::remove_var(key) };
}
/// Sets LANG (and LANGUAGE) from the system preferences.
fn set_lang_from_cocoa() {
use objc2_foundation::NSLocale;
let locale = NSLocale::currentLocale();
let lang = locale.languageCode().to_string();
// `countryCode` is deprecated by Apple but is what the Zig code uses.
#[allow(deprecated)]
let country = locale.countryCode();
let Some(country) = country.map(|c| c.to_string()) else {
log::warn!("languageCode or countryCode not found. Locale may be incorrect.");
return;
};
let env_value = format_lang(&lang, &country);
log::info!("detected system locale={env_value}");
set_env("LANG", &env_value);
// Get our preferred languages and set that to the LANGUAGE env var
// in case our language differs from our locale.
let preferred: Vec<String> = NSLocale::preferredLanguages()
.iter()
.map(|s| s.to_string())
.collect();
if let Some(pref) = preferred_language_value(&preferred) {
log::debug!("setting LANGUAGE from preferred languages value={pref}");
set_env("LANGUAGE", &pref);
}
}
pub(super) fn ensure_locale() -> Option<String> {
// On macOS, pre-populate the LANG env var with system preferences.
// When launching the .app, LANG is not set so we must query it from
// the OS. When launching from the CLI, LANG is usually set by the
// parent process.
let lang = std::env::var_os("LANG");
if lang.as_ref().is_none_or(|v| v.is_empty()) {
set_lang_from_cocoa();
}
// Set the locale to whatever is set in env vars.
if let Some(v) = setlocale_all("") {
log::info!("setlocale from env result={v}");
return None;
}
// setlocale failed. This is probably because the LANG env var is
// invalid. Try to set it without the LANG var set to use the system
// default.
if std::env::var_os("LANG").is_some_and(|v| !v.is_empty()) {
// We don't need to do both of these things but we do them both
// to be sure that lang is either empty or unset completely.
set_env("LANG", "");
unset_env("LANG");
if let Some(v) = setlocale_all("") {
log::info!("setlocale after unset lang result={v}");
// If we try to setlocale to an unsupported locale it'll
// return "C" as the POSIX/C fallback, if that's the case we
// want to not use it and move to our fallback.
if v != "C" {
return None;
}
}
}
// Failure again... fallback to en_US.UTF-8
log::warn!(
"setlocale failed with LANG and system default. Falling back to {FALLBACK_LOCALE}"
);
if let Some(v) = setlocale_all(FALLBACK_LOCALE) {
set_env("LANG", FALLBACK_LOCALE);
log::info!("setlocale default result={v}");
return Some(FALLBACK_LOCALE.to_owned());
}
log::warn!("setlocale failed even with the fallback, uncertain results");
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_lang_value() {
assert_eq!(format_lang("en", "US"), "en_US.UTF-8");
}
#[test]
fn preferred_languages_to_language_var() {
assert_eq!(preferred_language_value::<&str>(&[]), None);
assert_eq!(
preferred_language_value(&["en-US"]).as_deref(),
Some("en_US.UTF-8")
);
assert_eq!(
preferred_language_value(&["zh-Hans-CN", "en", "fr-CA"]).as_deref(),
Some("zh_CN.UTF-8:en.UTF-8:fr_CA.UTF-8")
);
}
#[cfg(not(target_vendor = "apple"))]
#[test]
fn ensure_locale_is_noop() {
assert_eq!(ensure_locale(), None);
+176
View File
@@ -0,0 +1,176 @@
//! macOS virtual memory tags and a VM-tagged page allocator. Port of
//! `os/mach.zig`.
/// macOS virtual memory tags for use with `mach_vm_map`/`mmap`. These
/// identify memory regions in tools like vmmap and Instruments.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VmTag(pub u8);
impl VmTag {
pub const APPLICATION_SPECIFIC_1: VmTag = VmTag(240);
pub const APPLICATION_SPECIFIC_2: VmTag = VmTag(241);
pub const APPLICATION_SPECIFIC_3: VmTag = VmTag(242);
pub const APPLICATION_SPECIFIC_4: VmTag = VmTag(243);
pub const APPLICATION_SPECIFIC_5: VmTag = VmTag(244);
pub const APPLICATION_SPECIFIC_6: VmTag = VmTag(245);
pub const APPLICATION_SPECIFIC_7: VmTag = VmTag(246);
pub const APPLICATION_SPECIFIC_8: VmTag = VmTag(247);
pub const APPLICATION_SPECIFIC_9: VmTag = VmTag(248);
pub const APPLICATION_SPECIFIC_10: VmTag = VmTag(249);
pub const APPLICATION_SPECIFIC_11: VmTag = VmTag(250);
pub const APPLICATION_SPECIFIC_12: VmTag = VmTag(251);
pub const APPLICATION_SPECIFIC_13: VmTag = VmTag(252);
pub const APPLICATION_SPECIFIC_14: VmTag = VmTag(253);
pub const APPLICATION_SPECIFIC_15: VmTag = VmTag(254);
pub const APPLICATION_SPECIFIC_16: VmTag = VmTag(255);
/// Converts the tag to the format expected by `mach_vm_map`/`mmap`'s fd
/// argument. Equivalent to the C macro `VM_MAKE_TAG(tag)`.
pub fn make(self) -> i32 {
(u32::from(self.0) << 24) as i32
}
}
/// A page allocator that tags all allocated memory with a [`VmTag`]
/// (Zig: `taggedPageAllocator`, based on the Zig 0.15.2 PageAllocator
/// POSIX implementation).
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
pub mod tagged {
use super::VmTag;
#[derive(Debug, Clone, Copy)]
pub struct TaggedPageAllocator {
pub tag: VmTag,
}
fn page_size() -> usize {
// SAFETY: sysconf has no preconditions.
unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }
}
fn align_forward(n: usize, align: usize) -> usize {
(n + align - 1) & !(align - 1)
}
impl TaggedPageAllocator {
pub fn new(tag: VmTag) -> Self {
Self { tag }
}
/// Map `n` bytes aligned to `alignment` (a power of two). Returns
/// null on failure.
///
/// # Safety
/// `n` must be non-zero; release the result with [`Self::unmap`]
/// using the same `n`.
pub unsafe fn map(&self, n: usize, alignment: usize) -> *mut u8 {
let page = page_size();
if n >= usize::MAX - page {
return std::ptr::null_mut();
}
let aligned_len = align_forward(n, page);
let max_drop_len = alignment - alignment.min(page);
let overalloc_len = if max_drop_len <= aligned_len - n {
aligned_len
} else {
align_forward(aligned_len + max_drop_len, page)
};
// SAFETY: anonymous private mapping; the tag is smuggled via the
// fd argument as `mach_vm_map` expects.
let ptr = unsafe {
libc::mmap(
std::ptr::null_mut(),
overalloc_len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANON,
self.tag.make(),
0,
)
};
if ptr == libc::MAP_FAILED {
return std::ptr::null_mut();
}
let base = ptr.cast::<u8>();
let result = align_forward(base as usize, alignment) as *mut u8;
// Unmap the extra bytes that were only requested in order to
// guarantee alignment; they could be at the beginning, end or both.
let drop_len = result as usize - base as usize;
if drop_len != 0 {
// SAFETY: [base, base+drop_len) lies inside our mapping.
unsafe { libc::munmap(base.cast(), drop_len) };
}
let remaining_len = overalloc_len - drop_len;
if remaining_len > aligned_len {
// SAFETY: the tail lies inside our mapping.
unsafe {
libc::munmap(result.add(aligned_len).cast(), remaining_len - aligned_len);
}
}
result
}
/// # Safety
/// `ptr`/`len` must come from [`Self::map`] with the same length.
pub unsafe fn unmap(&self, ptr: *mut u8, len: usize) {
let aligned = align_forward(len, page_size());
// SAFETY: caller contract.
unsafe { libc::munmap(ptr.cast(), aligned) };
}
/// Try to resize in place (Zig `realloc` with no `mremap` on
/// Darwin): only shrinking within the mapping or staying on the same
/// page count succeeds. Returns the (unchanged) pointer or null.
///
/// # Safety
/// `ptr`/`old_len` must come from [`Self::map`].
pub unsafe fn realloc(&self, ptr: *mut u8, old_len: usize, new_len: usize) -> *mut u8 {
let page = page_size();
let new_aligned = align_forward(new_len, page);
let old_aligned = align_forward(old_len, page);
if new_aligned == old_aligned {
return ptr;
}
if new_aligned < old_aligned {
// SAFETY: the released tail lies inside the mapping.
unsafe {
libc::munmap(ptr.add(new_aligned).cast(), old_aligned - new_aligned);
}
return ptr;
}
std::ptr::null_mut()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_unmap_roundtrip() {
let a = TaggedPageAllocator::new(VmTag::APPLICATION_SPECIFIC_1);
// SAFETY: test-only use following the documented contract.
unsafe {
let p = a.map(10_000, 16384);
assert!(!p.is_null());
assert_eq!(p as usize % 16384, 0);
p.write(1);
p.add(9_999).write(2);
let q = a.realloc(p, 10_000, 4096);
assert_eq!(q, p);
a.unmap(p, 4096);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vm_tag_make() {
assert_eq!(VmTag::APPLICATION_SPECIFIC_1.make(), (240u32 << 24) as i32);
assert_eq!(VmTag::APPLICATION_SPECIFIC_16.make(), (255u32 << 24) as i32);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! macOS-specific helpers. Port of `os/macos.zig`.
//!
//! The platform-independent items ([`QosClass`], [`join_dir`]) compile
//! everywhere so they can be unit tested on any host; everything that talks
//! to Foundation / libpthread is gated on `target_vendor = "apple"`.
use std::path::{Path, PathBuf};
/// Quality-of-service classes (`pthread_set_qos_class_self_np`).
///
/// <https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QosClass {
UserInteractive = 0x21,
UserInitiated = 0x19,
Default = 0x15,
Utility = 0x11,
Background = 0x09,
Unspecified = 0x00,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SetQosClassError {
/// The thread can't have its QoS class changed usually because a
/// different pthread API was called that makes it an invalid target.
#[error("thread incompatible")]
ThreadIncompatible,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DirError {
#[error("apple api failed")]
AppleApiFailed,
}
/// `NSSearchPathDirectory` values we use.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u64)]
pub enum SearchPathDirectory {
CachesDirectory = 13,
ApplicationSupportDirectory = 14,
}
/// Join `base_dir` with the given sub paths like `std.fs.path.join`
/// (`commonDir`'s tail): empty components are skipped.
pub fn join_dir(base_dir: &str, sub_paths: &[&str]) -> PathBuf {
let mut parts = vec![base_dir];
parts.extend_from_slice(sub_paths);
PathBuf::from(crate::path_join(&parts))
}
/// Return the path to the application support directory for Ghostty
/// with the given sub path joined.
#[cfg(target_vendor = "apple")]
pub fn app_support_dir(sub_path: &str) -> Result<PathBuf, DirError> {
common_dir(
SearchPathDirectory::ApplicationSupportDirectory,
&[crate::i18n::BUNDLE_ID, sub_path],
)
}
/// Return the path to the system cache directory with the given sub path
/// joined.
#[cfg(target_vendor = "apple")]
pub fn cache_dir(sub_path: &str) -> Result<PathBuf, DirError> {
common_dir(
SearchPathDirectory::CachesDirectory,
&[crate::i18n::BUNDLE_ID, sub_path],
)
}
#[cfg(target_vendor = "apple")]
fn common_dir(directory: SearchPathDirectory, sub_paths: &[&str]) -> Result<PathBuf, DirError> {
use objc2_foundation::{NSFileManager, NSSearchPathDirectory, NSSearchPathDomainMask};
let manager = NSFileManager::defaultManager();
let url = manager
.URLForDirectory_inDomain_appropriateForURL_create_error(
NSSearchPathDirectory(directory as usize),
NSSearchPathDomainMask::UserDomainMask,
None,
true,
)
.map_err(|_| DirError::AppleApiFailed)?;
let path = url.path().ok_or(DirError::AppleApiFailed)?;
Ok(join_dir(&path.to_string(), sub_paths))
}
/// Verifies that the running macOS system version is at least the given
/// version.
#[cfg(target_vendor = "apple")]
pub fn is_at_least_version(major: isize, minor: isize, patch: isize) -> bool {
use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo};
NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion {
majorVersion: major,
minorVersion: minor,
patchVersion: patch,
})
}
/// Set the QoS class of the running thread.
///
/// <https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ>
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
pub fn set_qos_class(class: QosClass) -> Result<(), SetQosClassError> {
// SAFETY: plain libpthread call with a valid QoS value and no pointers.
let rc = unsafe { libc::pthread_set_qos_class_self_np(class.to_libc(), 0) };
match rc {
0 => Ok(()),
libc::EPERM => Err(SetQosClassError::ThreadIncompatible),
// EPERM is the only known error that can happen based on the man
// pages for pthread_set_qos_class_self_np.
e => panic!("unexpected pthread_set_qos_class_self_np error: {e}"),
}
}
#[cfg(target_vendor = "apple")]
impl QosClass {
fn to_libc(self) -> libc::qos_class_t {
match self {
QosClass::UserInteractive => libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE,
QosClass::UserInitiated => libc::qos_class_t::QOS_CLASS_USER_INITIATED,
QosClass::Default => libc::qos_class_t::QOS_CLASS_DEFAULT,
QosClass::Utility => libc::qos_class_t::QOS_CLASS_UTILITY,
QosClass::Background => libc::qos_class_t::QOS_CLASS_BACKGROUND,
QosClass::Unspecified => libc::qos_class_t::QOS_CLASS_UNSPECIFIED,
}
}
}
/// Name the *current* thread (`pthread_setname_np` on Darwin can only name
/// the calling thread).
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
pub fn pthread_setname_np(name: &std::ffi::CStr) {
// SAFETY: `name` is a valid NUL-terminated string for the duration of
// the call; Darwin copies it.
unsafe {
libc::pthread_setname_np(name.as_ptr());
}
}
/// Convenience for non-Darwin callers/tests: is `p` below a `Caches`
/// directory.
pub fn is_cache_path(p: &Path) -> bool {
p.components().any(|c| c.as_os_str() == "Caches")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qos_class_values() {
assert_eq!(QosClass::UserInteractive as u32, 0x21);
assert_eq!(QosClass::UserInitiated as u32, 0x19);
assert_eq!(QosClass::Default as u32, 0x15);
assert_eq!(QosClass::Utility as u32, 0x11);
assert_eq!(QosClass::Background as u32, 0x09);
assert_eq!(QosClass::Unspecified as u32, 0);
}
#[test]
fn search_path_values() {
assert_eq!(SearchPathDirectory::CachesDirectory as u64, 13);
assert_eq!(SearchPathDirectory::ApplicationSupportDirectory as u64, 14);
}
#[test]
fn cache_dir_paths() {
let id = crate::i18n::BUNDLE_ID;
// Test base path.
let p = join_dir("/Users/x/Library/Caches", &[id, ""]);
assert!(is_cache_path(&p));
assert!(p.to_string_lossy().contains(id));
// Test with subdir.
let p = join_dir("/Users/x/Library/Caches", &[id, "test"]);
assert!(is_cache_path(&p));
assert!(p.to_string_lossy().contains(&format!("{id}/test")));
}
#[cfg(target_vendor = "apple")]
#[test]
fn cache_dir_real() {
let p = cache_dir("test").unwrap();
assert!(is_cache_path(&p));
}
}
+28 -1
View File
@@ -1,6 +1,33 @@
/// The system-configured double-click interval if its available.
///
/// Only macOS can report this; on Linux this is always `None`.
/// Only macOS can report this; elsewhere this is always `None`.
#[cfg(not(target_os = "macos"))]
pub fn click_interval() -> Option<u32> {
None
}
/// Convert an `NSEvent.doubleClickInterval` (seconds) to milliseconds,
/// rounding up like Zig's `@ceil(interval * 1000)`.
pub fn click_interval_ms(seconds: f64) -> u32 {
(seconds * 1000.0).ceil() as u32
}
/// The system-configured double-click interval (`NSEvent.doubleClickInterval`).
#[cfg(target_os = "macos")]
pub fn click_interval() -> Option<u32> {
Some(click_interval_ms(
objc2_app_kit::NSEvent::doubleClickInterval(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_interval_rounds_up() {
assert_eq!(click_interval_ms(0.5), 500);
assert_eq!(click_interval_ms(0.2501), 251);
assert_eq!(click_interval_ms(0.0), 0);
}
}
+56 -4
View File
@@ -25,9 +25,7 @@ pub enum OpenUrlKind {
/// 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);
let mut cmd = open_command(kind, url)?;
// Pipe stdout/stderr so we can collect output from the command.
cmd.stdin(Stdio::null())
@@ -57,6 +55,52 @@ pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
Ok(())
}
/// Build the platform's "open URL" command (Zig `open`'s switch over
/// `builtin.os.tag`).
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
let _ = kind;
let mut cmd = Command::new("rundll32");
cmd.args(["url.dll,FileProtocolHandler", url]);
Ok(cmd)
}
/// Linux and the BSDs use `xdg-open`.
#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))]
#[allow(clippy::unnecessary_wraps)]
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
let _ = kind;
let mut cmd = Command::new("xdg-open");
cmd.arg(url);
Ok(cmd)
}
/// macOS uses `open` (`open -t` for text files).
#[cfg(target_os = "macos")]
#[allow(clippy::unnecessary_wraps)]
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
let mut cmd = Command::new("open");
cmd.args(macos_open_args(kind, url));
Ok(cmd)
}
/// Arguments of the macOS `open` command for a URL kind.
#[cfg_attr(not(any(target_os = "macos", all(test, unix))), allow(dead_code))]
fn macos_open_args(kind: OpenUrlKind, url: &str) -> Vec<&str> {
match kind {
OpenUrlKind::Text => vec!["-t", url],
OpenUrlKind::Html | OpenUrlKind::Unknown => vec![url],
}
}
/// iOS cannot spawn processes (`error.Unimplemented`).
#[cfg(target_os = "ios")]
#[allow(clippy::unnecessary_wraps)]
fn open_command(_kind: OpenUrlKind, _url: &str) -> io::Result<Command> {
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
}
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.
@@ -78,10 +122,18 @@ fn open_thread(child: std::process::Child) {
}
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn macos_open_command_args() {
assert_eq!(macos_open_args(OpenUrlKind::Text, "/a"), ["-t", "/a"]);
assert_eq!(macos_open_args(OpenUrlKind::Html, "u"), ["u"]);
assert_eq!(macos_open_args(OpenUrlKind::Unknown, "u"), ["u"]);
}
#[cfg(not(target_vendor = "apple"))]
#[test]
fn open_reports_spawn_failure_quickly() {
// With an empty PATH, xdg-open can't be found; Zig detects this
+6
View File
@@ -1,3 +1,4 @@
#[cfg(any(target_os = "linux", target_os = "android"))]
use crate::flatpak::is_flatpak;
/// Entry that is retrieved from the passwd API. This only contains the fields
@@ -28,6 +29,7 @@ pub fn get() -> std::io::Result<Entry> {
// If we're in flatpak then our entry is always empty so we grab it
// by shelling out to the host. note that we do HAVE an entry in the
// sandbox but only the username is correct.
#[cfg(any(target_os = "linux", target_os = "android"))]
if is_flatpak() {
log::info!("flatpak detected, will use host command to get our entry");
return host_entry(&user.name);
@@ -46,6 +48,7 @@ pub fn get() -> std::io::Result<Entry> {
/// Dependency note: Zig uses `pty.zig`; `ghostty-termio` (which owns the Rust
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn host_entry(name: &str) -> std::io::Result<Entry> {
use std::io::Read;
@@ -112,7 +115,9 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
Ok(parse_getent(&String::from_utf8_lossy(&output)))
}
#[cfg(any(target_os = "linux", target_os = "android"))]
/// Shell and home are the last two `:`-separated fields of the output.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn parse_getent(output: &str) -> Entry {
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
let shell = it.next().map(str::to_owned);
@@ -128,6 +133,7 @@ fn parse_getent(output: &str) -> Entry {
mod tests {
use super::*;
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn parse_getent_output() {
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
+32 -8
View File
@@ -1,4 +1,16 @@
use std::{io, os::unix::fs::PermissionsExt};
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// `std.fs.path.delimiter` / `sep`.
#[cfg(windows)]
const DELIMITER: char = ';';
#[cfg(not(windows))]
const DELIMITER: char = ':';
#[cfg(windows)]
const SEP: char = '\\';
#[cfg(not(windows))]
const SEP: char = '/';
/// Equivalent of `std.fs.max_path_bytes` on Linux.
const MAX_PATH_BYTES: usize = 4096;
@@ -27,14 +39,14 @@ pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
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()) {
for search_path in path_var.split(DELIMITER).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}");
let full_path = format!("{search_path}{SEP}{cmd}");
// Stat it
let f = match std::fs::File::open(&full_path) {
@@ -51,7 +63,7 @@ pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
},
};
let meta = f.metadata()?;
if !meta.is_dir() && is_executable(meta.permissions().mode()) {
if !meta.is_dir() && is_executable(&meta) {
return Ok(Some(full_path));
}
}
@@ -63,8 +75,14 @@ pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
Ok(None)
}
fn is_executable(mode: u32) -> bool {
mode & 0o0111 != 0
#[cfg(unix)]
fn is_executable(meta: &std::fs::Metadata) -> bool {
meta.permissions().mode() & 0o0111 != 0
}
#[cfg(windows)]
fn is_executable(_meta: &std::fs::Metadata) -> bool {
true
}
#[cfg(test)]
@@ -74,8 +92,13 @@ mod tests {
// `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());
let executable = if cfg!(windows) {
"hostname.exe"
} else {
"uname"
};
let path = crate::testutil::with_env_lock(|| expand(executable).unwrap().unwrap());
assert!(path.len() > executable.len());
}
#[test]
@@ -91,6 +114,7 @@ mod tests {
assert_eq!(path.len(), 7);
}
#[cfg(unix)]
#[test]
fn expand_skips_non_executable_and_dirs() {
let dir = crate::TempDir::init().unwrap();
+61 -1
View File
@@ -1,12 +1,53 @@
#[cfg(unix)]
use std::{io, os::fd::OwnedFd};
/// pipe() for POSIX. Sets CLOEXEC on the file descriptors.
/// Returns `(read, write)`.
#[cfg(all(unix, not(target_vendor = "apple")))]
pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).map_err(io::Error::from)
}
#[cfg(test)]
/// pipe() for Darwin, which has no `pipe2`: like Zig's `posix.pipe2`
/// fallback, create the pipe and then set CLOEXEC on both ends.
#[cfg(target_vendor = "apple")]
pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let (r, w) = rustix::pipe::pipe().map_err(io::Error::from)?;
for fd in [&r, &w] {
rustix::io::fcntl_setfd(fd, rustix::io::FdFlags::CLOEXEC).map_err(io::Error::from)?;
}
Ok((r, w))
}
/// pipe() for Windows (`CreatePipe` with default security attributes, so the
/// handles are not inheritable). Returns `(read, write)`.
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn pipe() -> std::io::Result<(
std::os::windows::io::OwnedHandle,
std::os::windows::io::OwnedHandle,
)> {
use std::os::windows::io::{FromRawHandle, OwnedHandle};
use windows_sys::Win32::{Foundation::HANDLE, System::Pipes::CreatePipe};
let mut read: HANDLE = std::ptr::null_mut();
let mut write: HANDLE = std::ptr::null_mut();
// SAFETY: both out pointers are valid for writes; null security
// attributes and size 0 request the defaults.
if unsafe { CreatePipe(&raw mut read, &raw mut write, std::ptr::null(), 0) } == 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: CreatePipe succeeded so both handles are valid and owned by us.
Ok(unsafe {
(
OwnedHandle::from_raw_handle(read.cast()),
OwnedHandle::from_raw_handle(write.cast()),
)
})
}
#[cfg(all(test, unix))]
mod tests {
use std::io::{Read, Write};
@@ -26,3 +67,22 @@ mod tests {
assert_eq!(s, "hi");
}
}
#[cfg(all(test, windows))]
mod tests_windows {
use std::io::{Read, Write};
use super::*;
#[test]
fn pipe_roundtrip() {
let (r, w) = pipe().unwrap();
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");
}
}
+26 -1
View File
@@ -29,8 +29,21 @@ impl ResourcesDir {
/// Sentinels we look for in the path to know we've found the resources
/// directory (Linux).
#[cfg(not(any(windows, target_os = "macos")))]
const SENTINELS: [&str; 2] = ["terminfo/g/ghostty", "terminfo/x/xterm-ghostty"];
/// Sentinel on macOS (terminfo directories are named by hex there).
#[cfg(target_os = "macos")]
const SENTINELS: [&str; 1] = ["terminfo/78/xterm-ghostty"];
/// Sentinel on Windows (a single terminfo source file).
#[cfg(windows)]
const SENTINELS: [&str; 1] = ["terminfo/ghostty.terminfo"];
/// The in-repo `resources/` tree always carries the Linux-style terminfo
/// layout (also on macOS, where the installed layout uses hex dirs).
const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
/// Gets the directory to the bundled resources directory, if it
/// exists (not all platforms or packages have it).
///
@@ -71,6 +84,17 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
exe = dir;
let dir_str = dir.to_string_lossy();
// On MacOS, we look for the app bundle path.
#[cfg(target_vendor = "apple")]
for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) {
return ResourcesDir {
app_path: Some(path_join(&[&v, "ghostty"])),
host_path: None,
};
}
}
// We look for a /usr/share style path.
for sentinel in SENTINELS {
if let Some(v) = maybe_dir(&dir_str, "share", sentinel) {
@@ -122,6 +146,7 @@ fn dev_resources_dir(exe: &Path) -> Option<String> {
let res = root.join("resources");
SENTINELS
.iter()
.chain(DEV_SENTINELS.iter())
.any(|s| res.join(s).exists())
.then(|| res.to_string_lossy().into_owned())
}
@@ -142,7 +167,7 @@ pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option<String> {
None
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
+22
View File
@@ -1,12 +1,15 @@
#[cfg(any(target_os = "linux", target_os = "android"))]
use std::io::Read;
/// Kernel `TASK_COMM_LEN`.
#[cfg(any(target_os = "linux", target_os = "android"))]
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.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn launched_by_systemd() -> bool {
// On Linux, systemd sets the `INVOCATION_ID` (v232+) and the
// `JOURNAL_STREAM` (v231+) environment variables. If these
@@ -58,6 +61,12 @@ pub fn launched_by_systemd() -> bool {
&comm_data[..end] == b"systemd"
}
/// For other platforms (macOS, ...), this returns false.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub fn launched_by_systemd() -> bool {
false
}
/// 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.
@@ -68,6 +77,7 @@ pub fn launched_by_systemd() -> bool {
/// 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.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub mod notify {
use rustix::net::{
AddressFamily,
@@ -172,8 +182,17 @@ pub mod notify {
}
}
/// systemd notifications are a no-op on platforms without systemd.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod notify {
pub fn ready() {}
pub fn reloading() {}
}
#[cfg(test)]
mod tests {
#[cfg(any(target_os = "linux", target_os = "android"))]
use std::os::unix::net::UnixDatagram;
use super::*;
@@ -190,6 +209,7 @@ mod tests {
);
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn recv_with_socket(f: impl FnOnce()) -> String {
let dir = crate::TempDir::init().unwrap();
let path = dir.path().join("notify.sock");
@@ -202,11 +222,13 @@ mod tests {
String::from_utf8(buf[..n].to_vec()).unwrap()
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn notify_ready_sends_message() {
assert_eq!(recv_with_socket(notify::ready), "READY=1");
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn notify_reloading_sends_message() {
let msg = recv_with_socket(notify::reloading);
+50 -6
View File
@@ -1,9 +1,10 @@
//! Creates a temporary directory at runtime that can be safely used to
//! store temporary data and is destroyed on drop.
#[cfg(unix)]
use std::os::unix::fs::DirBuilderExt;
use std::{
io,
os::unix::fs::DirBuilderExt,
path::{Path, PathBuf},
};
@@ -43,6 +44,48 @@ pub struct TempDir {
name: String,
}
/// Fill `buf` with random bytes (Zig: `std.crypto.random.bytes`).
#[cfg(any(target_os = "linux", target_os = "android"))]
fn fill_random(buf: &mut [u8]) -> io::Result<()> {
rustix::rand::getrandom(buf, rustix::rand::GetRandomFlags::empty())?;
Ok(())
}
/// Darwin has no `getrandom` syscall (Zig uses `getentropy`); read the
/// kernel CSPRNG device instead.
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn fill_random(buf: &mut [u8]) -> io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
}
/// Windows: the system preferred RNG (`BCryptGenRandom`).
#[cfg(windows)]
#[allow(unsafe_code)]
fn fill_random(buf: &mut [u8]) -> io::Result<()> {
use windows_sys::Win32::Security::Cryptography::{
BCRYPT_USE_SYSTEM_PREFERRED_RNG,
BCryptGenRandom,
};
// SAFETY: `buf` is valid for `len` bytes; a null algorithm handle is
// allowed together with BCRYPT_USE_SYSTEM_PREFERRED_RNG.
let status = unsafe {
BCryptGenRandom(
std::ptr::null_mut(),
buf.as_mut_ptr(),
buf.len() as u32,
BCRYPT_USE_SYSTEM_PREFERRED_RNG,
)
};
if status < 0 {
return Err(io::Error::other(format!(
"BCryptGenRandom failed: {status:#x}"
)));
}
Ok(())
}
impl TempDir {
/// Create the temporary directory.
pub fn init() -> io::Result<TempDir> {
@@ -56,13 +99,14 @@ impl TempDir {
// 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())?;
fill_random(&mut rand_buf)?;
let name = b64_encode(&rand_buf);
match std::fs::DirBuilder::new()
.mode(0o755)
.create(parent.join(&name))
{
#[allow(unused_mut)]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
builder.mode(0o755);
match builder.create(parent.join(&name)) {
Ok(()) => return Ok(TempDir { parent, name }),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
+11 -3
View File
@@ -21,6 +21,10 @@ pub struct Options<'a> {
pub enum Error {
#[error("no home directory could be determined")]
NoHomeDir,
/// A macOS Foundation API (e.g. `NSFileManager`) failed (Zig:
/// `error.AppleAPIFailed`).
#[error("apple api failed")]
AppleApiFailed,
}
/// Get the XDG user config directory.
@@ -49,8 +53,12 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
]));
}
// First check the env var.
if let Some(env) = getenv_not_empty(env) {
// First check the env var. On Windows we treat `LOCALAPPDATA` as a
// fallback for the XDG variable.
let env_value = getenv_not_empty(env);
#[cfg(windows)]
let env_value = env_value.or_else(|| getenv_not_empty("LOCALAPPDATA"));
if let Some(env) = env_value {
// If we have a subdir, then we join it.
if let Some(subdir) = opts.subdir {
return Ok(path_join(&[&env, subdir]));
@@ -90,7 +98,7 @@ pub fn parse_terminal_exec<'a>(argv: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> {
Some(&argv[start..])
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;