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 3c8fae1089
commit 452b782149
124 files changed
+14936 -1242

No files matched your search

+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]