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

+111 -13
View File
@@ -15,11 +15,18 @@ use ghostty_os::{file::Rlimit, resourcesdir::ResourcesDir};
pub struct Logging {
/// Whether to log to stderr.
pub stderr: bool,
/// Whether to log to macOS's unified logging. Enabled by default on
/// macOS.
pub macos: bool,
}
impl Default for Logging {
fn default() -> Self {
Logging { stderr: true }
Logging {
stderr: true,
macos: cfg!(target_vendor = "apple"),
}
}
}
@@ -32,8 +39,18 @@ impl Logging {
#[must_use]
pub fn parse(v: &str) -> Logging {
match v {
"1" | "t" | "T" | "true" => return Logging { stderr: true },
"0" | "f" | "F" | "false" => return Logging { stderr: false },
"1" | "t" | "T" | "true" => {
return Logging {
stderr: true,
macos: true,
};
}
"0" | "f" | "F" | "false" => {
return Logging {
stderr: false,
macos: false,
};
}
_ => {}
}
let mut out = Logging::default();
@@ -45,8 +62,7 @@ impl Logging {
};
match name {
"stderr" => out.stderr = value,
// The only other field (macOS unified logging) is a no-op.
"macos" => {}
"macos" => out.macos = value,
_ => return Logging::default(),
}
}
@@ -141,6 +157,14 @@ pub fn init(opts: InitOptions) -> &'static GlobalState {
// As early as possible, initialize our resource limits.
let rlimits = ResourceLimits::init();
// Initialize our crash reporting.
if let Err(err) = ghostty_crash::init(&ghostty_crash::Options {
release: ghostty_termio::stream_handler::VERSION_STRING.to_owned(),
..ghostty_crash::Options::default()
}) {
log::warn!("sentry init failed, no crash capture available err={err}");
}
// Find our resources directory once for the app so every launch
// hereafter can use this cached value.
let discover = opts
@@ -173,12 +197,60 @@ pub fn init(opts: InitOptions) -> &'static GlobalState {
/// `info(scope): message` on stderr.
struct Logger {
stderr: bool,
/// macOS unified logging (`os_log`), only effective on Apple targets.
macos: bool,
max: log::LevelFilter,
}
/// The scope shown in log output: the crate name without the `ghostty_`
/// prefix (Zig scopes are `@tagName(scope)`).
fn log_scope(target: &str) -> &str {
let target = target.strip_prefix("ghostty_").unwrap_or(target);
target.split("::").next().unwrap_or(target)
}
/// The prefix used for macOS unified logging messages: nothing for the
/// default scope, `scope: ` otherwise.
#[cfg_attr(not(any(target_vendor = "apple", test)), allow(dead_code))]
fn macos_message(scope: &str, args: &std::fmt::Arguments<'_>) -> String {
if scope.is_empty() || scope == "default" {
format!("{args}")
} else {
format!("{scope}: {args}")
}
}
/// Bundle id used as the unified logging subsystem
/// (`build_config.bundle_id`).
#[cfg(target_vendor = "apple")]
const BUNDLE_ID: &str = ghostty_os::i18n::BUNDLE_ID;
#[cfg(target_vendor = "apple")]
fn log_macos(record: &log::Record<'_>) {
// On Mac, we use unified logging. To view this:
//
// sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'
//
// macOS logging is thread safe so no need for locks/mutexes.
//
// Convert our levels to Mac levels.
let level = match record.level() {
log::Level::Debug | log::Level::Trace => oslog::Level::Debug,
log::Level::Info => oslog::Level::Info,
log::Level::Warn => oslog::Level::Error,
log::Level::Error => oslog::Level::Fault,
};
let scope = log_scope(record.target());
// Initialize a logger. This is slow to do on every operation but we
// shouldn't be logging too much.
let logger = oslog::OsLog::new(BUNDLE_ID, scope);
logger.with_level(level, &macos_message(scope, record.args()));
}
impl log::Log for Logger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
self.stderr && metadata.level() <= self.max
(self.stderr || self.macos) && metadata.level() <= self.max
}
fn log(&self, record: &log::Record<'_>) {
@@ -186,19 +258,23 @@ impl log::Log for Logger {
return;
}
#[cfg(target_vendor = "apple")]
if self.macos {
log_macos(record);
}
// skip if we are not logging to stderr
if !self.stderr {
return;
}
let level = match record.level() {
log::Level::Error => "error",
log::Level::Warn => "warning",
log::Level::Info => "info",
log::Level::Debug | log::Level::Trace => "debug",
};
let target = record.target();
let scope = target
.strip_prefix("ghostty_")
.unwrap_or(target)
.split("::")
.next()
.unwrap_or(target);
let scope = log_scope(record.target());
let _ = writeln!(
std::io::stderr().lock(),
"{level}({scope}): {}",
@@ -223,6 +299,7 @@ fn init_logger(logging: Logging) {
};
let logger = Box::new(Logger {
stderr: logging.stderr,
macos: logging.macos,
max,
});
if log::set_boxed_logger(logger).is_ok() {
@@ -230,16 +307,37 @@ fn init_logger(logging: Logging) {
}
}
/// Cleans up the global state. This doesn't _need_ to be called (Zig:
/// `GlobalState.deinit`); it deactivates crash capture ("flush crash logs").
pub fn deinit() {
ghostty_crash::deinit();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macos_log_message_prefix() {
assert_eq!(
macos_message("font", &format_args!("hi {}", 1)),
"font: hi 1"
);
assert_eq!(macos_message("default", &format_args!("hi")), "hi");
assert_eq!(log_scope("ghostty_font::collection"), "font");
assert_eq!(log_scope("other::x"), "other");
}
#[test]
fn logging_parse() {
assert!(Logging::parse("stderr").stderr);
assert!(!Logging::parse("no-stderr").stderr);
// Unmentioned fields keep their default (stderr on).
assert!(Logging::parse("macos").stderr);
assert!(!Logging::parse("no-macos").macos);
assert!(Logging::parse("macos").macos);
assert!(Logging::parse("true").macos);
assert!(!Logging::parse("0").macos);
assert!(!Logging::parse("false").stderr);
assert!(!Logging::parse("0").stderr);
assert!(Logging::parse("true").stderr);