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

+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);
@@ -390,6 +390,7 @@ impl Surface {
/// `textCallback`: text input that is pasted verbatim.
pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> {
let _crash = self.crash_guard();
self.complete_clipboard_paste(text.as_bytes(), true)
}
}
+28 -4
View File
@@ -906,6 +906,33 @@ impl Surface {
}
}
/// Build the key encoding options (Zig: `encodeKeyOpts`). On macOS the
/// `macos-option-as-alt` value comes from the config, or is detected from
/// the keyboard layout when unset.
fn encode_key_opts(&self) -> key_encode::Options {
let mut opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
if !cfg!(target_os = "macos") {
return opts;
}
opts.macos_option_as_alt = self.cfg().macos_option_as_alt.unwrap_or_else(|| {
// If we don't have alt pressed, it doesn't matter what this
// config is so we can just say "false" and avoid more expensive
// checks below.
if !self.mouse.borrow().mods.alt {
return ghostty_input::OptionAsAlt::False;
}
// Alt is pressed, we're on macOS. We break some encapsulation
// here and assume libghostty for ease...
self.rt_app.keyboard_layout().detect_option_as_alt()
});
opts
}
/// Encodes the key event into a write request. Returns `None` if
/// nothing was encoded.
fn encode_key(
@@ -914,10 +941,7 @@ impl Surface {
insp_ev: Option<&mut inspector::KeyEvent>,
) -> Option<IoMessage> {
// Build our encoding options, which requires the lock.
let opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
let opts = self.encode_key_opts();
let mut data: Vec<u8> = Vec::with_capacity(64);
if let Err(err) = key_encode::encode(&mut data, event, &opts) {
+91 -13
View File
@@ -90,8 +90,11 @@ use ghostty_font::{
use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods};
use ghostty_renderer::{
Health,
Renderer,
generic::Host as RendererHost,
// The generic renderer over the OpenGL API (the GTK runtime's graphics
// API). `ghostty_renderer::Renderer` is the platform default, which is
// Metal on Apple targets; the embedded (macOS) runtime provides its own
// Metal-based surface setup (TODO(integration): L1).
generic::{Host as RendererHost, Renderer},
message::{FontGridChange, Message as RendererMessage},
opengl::OpenGL,
options::Options as RendererOptions,
@@ -258,6 +261,9 @@ pub struct Surface {
/// All our sizing information.
pub(crate) size: Cell<Size>,
/// The dimensions recorded in crash reports (see `ghostty_crash`).
crash_dims: Arc<ghostty_crash::SurfaceDims>,
/// The configuration derived from the main config.
config: RefCell<Rc<DerivedConfig>>,
@@ -468,7 +474,7 @@ impl Surface {
let renderer_state = Arc::clone(termio.renderer_state());
// Create the renderer thread
let render_thread = ghostty_renderer::Thread::new(
let mut render_thread = ghostty_renderer::Thread::new(
&config,
Arc::clone(&renderer),
Arc::clone(&renderer_state),
@@ -478,9 +484,14 @@ impl Surface {
let _ = renderer_link.handle.set(renderer_handle.clone());
// Create the IO thread
let io_thread = IoThread::new()?;
let mut io_thread = IoThread::new()?;
let io_stop = io_thread.stop_handle();
// Crash context shared with the renderer and IO threads.
let crash_dims = ghostty_crash::SurfaceDims::new();
render_thread.crash_dims = Some(Arc::clone(&crash_dims));
io_thread.crash_dims = Some(Arc::clone(&crash_dims));
let surface = Rc::new(Surface {
id,
app: Rc::clone(&app),
@@ -504,6 +515,7 @@ impl Surface {
keyboard: RefCell::new(keyboard::Keyboard::default()),
pressed_key: RefCell::new(None),
size: Cell::new(size),
crash_dims,
config: RefCell::new(derived_config),
config_conditional_state: Cell::new(app.config_conditional_state.get()),
child_exited: Cell::new(false),
@@ -971,8 +983,10 @@ impl Surface {
// If our runtime was below some threshold then we assume that this
// was an abnormal exit and we show an error message.
if info.runtime_ms <= u64::from(self.cfg().abnormal_command_exit_runtime_ms) {
// If the exit code is 0 then it was a good exit.
if info.exit_code != 0 {
// On macOS, our exit code detection doesn't work, possibly
// because of our `login` wrapper. More investigation required.
// Elsewhere, if the exit code is 0 then it was a good exit.
if is_abnormal_exit(info.exit_code, cfg!(target_vendor = "apple")) {
log::warn!("abnormal process exit detected, showing error message");
// Try and show a GUI message. If it returns true, don't do
@@ -1084,13 +1098,17 @@ impl Surface {
t.print_string(&runtime_str)?;
t.set_attribute(Attribute::Unset)?;
let exit_code_str = info.exit_code.to_string();
t.carriage_return();
t.linefeed()?;
t.print_string("Exit Code: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&exit_code_str)?;
t.set_attribute(Attribute::Unset)?;
// We don't print this on macOS because the exit code is always 0
// due to the way we launch the process.
if !cfg!(target_vendor = "apple") {
let exit_code_str = info.exit_code.to_string();
t.carriage_return();
t.linefeed()?;
t.print_string("Exit Code: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&exit_code_str)?;
t.set_attribute(Attribute::Unset)?;
}
t.carriage_return();
t.linefeed()?;
@@ -1436,6 +1454,31 @@ impl Surface {
// ------------------------------------------------------------------
// Fonts and sizes
/// Publish the current size for crash reports (Zig reads the surface
/// size at crash time).
fn sync_crash_dims(&self) {
let s = self.size.get();
let g = s.grid();
self.crash_dims.set(ghostty_crash::DimsSnapshot {
screen_width: s.screen.width,
screen_height: s.screen.height,
grid_columns: u32::from(g.columns),
grid_rows: u32::from(g.rows),
cell_width: s.cell.width,
cell_height: s.cell.height,
});
}
/// Crash context for the main thread while inside a surface callback
/// (Zig: `crashThreadState`).
pub(crate) fn crash_guard(&self) -> ghostty_crash::ThreadGuard {
self.sync_crash_dims();
ghostty_crash::ThreadGuard::new(
ghostty_crash::ThreadType::Main,
Some(Arc::clone(&self.crash_dims)),
)
}
/// Change the cell size for the terminal grid. This can happen as a
/// result of changing the font size at runtime.
fn set_cell_size(&self, size: CellSize) {
@@ -1444,6 +1487,7 @@ impl Surface {
s.cell = size;
self.size.set(s);
self.balance_padding_if_needed();
self.sync_crash_dims();
// Notify the terminal
self.queue_io(IoMessage::Resize(self.size.get()));
@@ -1545,6 +1589,8 @@ impl Surface {
);
}
self.sync_crash_dims();
// Mail the IO thread
self.queue_io(IoMessage::Resize(s));
}
@@ -1624,6 +1670,7 @@ impl Surface {
/// Callback for when the surface is fully visible or not, regardless of
/// focus state.
pub fn occlusion_callback(&self, visible: bool) -> Result<(), CoreError> {
let _crash = self.crash_guard();
self.renderer_handle.send(RendererMessage::Visible(visible));
self.queue_render();
Ok(())
@@ -1666,6 +1713,7 @@ impl Surface {
/// Schedule a render (Zig: `refreshCallback`).
pub fn refresh_callback(&self) -> Result<(), CoreError> {
let _crash = self.crash_guard();
// The point of this callback is to schedule a render, so do that.
self.queue_render();
Ok(())
@@ -1837,6 +1885,13 @@ impl Drop for Surface {
/// (`rt_surface.defaultTermioEnv()`, which already removed the variables it
/// does not want to leak); only if it provides nothing (the Zig error path)
/// do we fall back to the process environment (`getEnvMap`).
/// Whether a quick child exit counts as abnormal. On Darwin our exit code
/// detection doesn't work (the `login` wrapper), so every quick exit is
/// treated as abnormal; elsewhere only non-zero exit codes are.
fn is_abnormal_exit(exit_code: u32, darwin: bool) -> bool {
darwin || exit_code != 0
}
fn rt_surface_env(rt: &dyn RtSurface) -> ghostty_os::EnvMap {
let rt_env = rt.default_termio_env();
if rt_env.is_empty() {
@@ -1854,14 +1909,17 @@ impl CoreSurface for Surface {
}
fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result<InputEffect, CoreError> {
let _crash = self.crash_guard();
Surface::key_callback(self, event)
}
fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::preedit_callback(self, text)
}
fn focus_callback(&self, focused: bool) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::focus_callback(self, focused)
}
@@ -1871,26 +1929,32 @@ impl CoreSurface for Surface {
button: MouseButton,
mods: Mods,
) -> Result<bool, CoreError> {
let _crash = self.crash_guard();
Surface::mouse_button_callback(self, state, button, mods)
}
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::cursor_pos_callback(self, pos, mods)
}
fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::scroll_callback(self, xoff, yoff, mods)
}
fn mouse_pressure_callback(&self, stage: ghostty_input::MousePressureStage, pressure: f64) {
let _crash = self.crash_guard();
Surface::mouse_pressure_callback(self, stage, pressure);
}
fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::size_callback(self, size)
}
fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::content_scale_callback(self, scale)
}
@@ -1903,6 +1967,7 @@ impl CoreSurface for Surface {
}
fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::color_scheme_callback(self, scheme)
}
@@ -2012,3 +2077,16 @@ impl CoreSurface for Surface {
Surface::render_inspector(self, ui);
}
}
#[cfg(test)]
mod darwin_tests {
use super::*;
#[test]
fn abnormal_exit_detection_per_platform() {
assert!(!is_abnormal_exit(0, false));
assert!(is_abnormal_exit(1, false));
assert!(is_abnormal_exit(0, true));
assert!(is_abnormal_exit(1, true));
}
}
+31 -1
View File
@@ -103,13 +103,43 @@ fn is_mouse_mode_override_state(mods: Mods) -> bool {
/// cause a rectangle select.
#[must_use]
pub fn is_rectangle_select_state(mods: Mods) -> bool {
mods.ctrl_or_super() && mods.alt
is_rectangle_select_state_for(mods, ghostty_input::platform::is_darwin())
}
/// [`is_rectangle_select_state`] for an explicit platform (Zig picks with
/// `builtin.target.os.tag.isDarwin()` at comptime): on Darwin option alone
/// selects a rectangle, elsewhere it is ctrl+alt.
#[must_use]
pub fn is_rectangle_select_state_for(mods: Mods, darwin: bool) -> bool {
if darwin {
mods.alt
} else {
mods.ctrl_or_super() && mods.alt
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_select_state_per_platform() {
let alt = Mods {
alt: true,
..Mods::default()
};
let ctrl_alt = Mods {
alt: true,
ctrl: true,
..Mods::default()
};
// macOS: alt alone.
assert!(is_rectangle_select_state_for(alt, true));
// Elsewhere: ctrl+alt.
assert!(!is_rectangle_select_state_for(alt, false));
assert!(is_rectangle_select_state_for(ctrl_alt, false));
}
fn m(
physical_key: Key,
mouse_event: MouseEvents,