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:
1 parent
3c8fae1089
commit
452b782149
124 files changed
+14936
-1242
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
Reference in new issue
Block a user