From c3c8bbd8d8add99088c774c54bc1acf1e39ec895 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 8 Aug 2026 10:44:56 -0300 Subject: a native macOS backend: libpardes plus an AppKit shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass. --- docs/ghostty-macos-notes.md | 198 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/ghostty-macos-notes.md (limited to 'docs/ghostty-macos-notes.md') diff --git a/docs/ghostty-macos-notes.md b/docs/ghostty-macos-notes.md new file mode 100644 index 00000000..556831af --- /dev/null +++ b/docs/ghostty-macos-notes.md @@ -0,0 +1,198 @@ +# Notes: how ghostty integrates Zig with Swift + +Research notes taken while designing `docs/macos.md`, kept so the next person +does not have to read ghostty again. Line references are against the checkout at +`~/05-genizah/ghostty` (commit `ba38b493`, "Update to Zig 0.16.0"); they will +drift, the shapes will not. + +Ghostty is the only large Zig program with a shipping native macOS app, so it is +the reference whether or not you copy it. Read this next to `docs/macos.md`, +which records where pardes deliberately went the other way. + +## The seam is small, and it is a hand-written C header + +`include/ghostty.h` is 1207 lines for an application of ghostty's size: about 90 +exported functions, four opaque handles (`typedef void* ghostty_app_t` and +friends, `:56-60`), and one callback struct. Everything is a `void*` on the C +side; the Zig `export fn` signatures carry the real types, so the compiler checks +one end and the other is untyped by construction. + +The header is **not generated**. It is maintained by hand, with `IMPORTANT: Any +changes here update include/ghostty.h` comments scattered through the Zig +(`src/input/key.zig:83`, `src/apprt/action.zig:75`). What keeps it honest is a +test: `build.zig:359-365` runs `addTranslateC` over the header and imports the +result into the unit-test build, and `src/lib/enum.zig:91-141` walks every Zig +enum asserting each tag equals the matching `GHOSTTY_*` constant, failing in both +directions — a Zig tag with no C constant, and a C constant with no Zig tag. + +This is the single most transferable thing in the repository, it costs about ten +lines of build plumbing, and it runs on Linux. pardes now does the same thing in +`src/macos.zig`, extended to compare exported function arity and scalar widths as +well, because a hand-written header drifts in signatures too. + +## Zig→host is one callback, not N + +`ghostty_runtime_config_s` (`include/ghostty.h:999-1027`) holds `userdata` and +six function pointers: `wakeup`, `action`, `read_clipboard`, +`confirm_read_clipboard`, `write_clipboard`, `close_surface`. Only `wakeup` and +the clipboard ones are what they look like. Everything else the core wants from +the host — new window, set title, toggle fullscreen, desktop notification, +sixty-odd cases — travels through the single `action` callback as a tagged union. + +The union is generated at comptime from the action enum +(`src/apprt/action.zig:419-484`) with `@Union(.@"extern", ...)`, and asserted to +a fixed size so adding a case cannot silently move the ABI: + +```zig +comptime { + assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { 4 => 16, 8 => 24, else => unreachable }); +} +``` + +The payoff is that adding a feature adds an enum case, not a struct field: hosts +that do not implement it return `false` and the ABI never changed. Worth copying +the day a fixed callback list starts growing. pardes has two callbacks today and +does not need it yet. + +Two userdata scopes, worth noting: the runtime struct's `userdata` is +app-scoped, and each surface carries its own. Swift round-trips both with +`Unmanaged.passUnretained(self).toOpaque()` — libghostty never retains, so +lifetime stays entirely on the Swift side. + +## Rendering: Zig owns the layer, Swift never draws + +This is the surprising part and the main thing pardes did *not* copy. + +Swift creates an `NSView`, gives it a non-zero frame, and passes the bare +pointer through a tagged platform union (`include/ghostty.h:448-465`): + +```c +typedef struct { void* nsview; } ghostty_platform_macos_s; +``` + +Zig then makes that view layer-hosting and installs a layer *it* owns +(`src/renderer/Metal.zig:108-146`): + +```zig +var layer = try IOSurfaceLayer.init(); +info.view.setProperty("layer", layer.layer.value); +info.view.setProperty("wantsLayer", true); +``` + +Note the order — assigning `.layer` before `wantsLayer = true` is what makes the +view layer-*hosted*, meaning AppKit will never draw into it. And it is not a +`CAMetalLayer` or an `MTKView`: `src/renderer/metal/IOSurfaceLayer.zig` builds a +`CALayer` subclass at runtime with `objc.allocateClassPair`, renders into an +`IOSurface`, and sets it as `layer.contents`. + +The consequence is that Swift is not in the render loop at all. There is no +`ghostty_surface_draw` call anywhere in the Swift sources; the renderer thread +drives its own vsync. Swift pushes only metadata: `set_size` (from a SwiftUI +`GeometryReader`, not `NSView.resize` — macOS 12 did not call it), +`set_content_scale` from `viewDidChangeBackingProperties`, `set_display_id` so +Zig can pick the right refresh rate, and `set_occlusion`. + +The debug inspector is the counter-example and shows both models are supported: +there Swift owns an `MTKView` and passes a command buffer *into* Zig +(`ghostty_inspector_metal_render`, `macos/Sources/Ghostty/Surface View/InspectorView.swift:417`). + +pardes inverts this deliberately — see `docs/macos.md`. The short version: a +terminal grid rendered by a hand-written Metal atlas is what `src/gui/gui.zig` +already spends thousands of lines on, and a cell grid is exactly what CoreText +draws natively. + +## Threading + +Per surface, ghostty spawns two threads (`src/Surface.zig:727-741`): a renderer +thread owning Metal and its own frame clock, and an IO thread doing pty reads and +VT parsing. Both run libxev loops with a `BlockingQueue` mailbox. + +Everything in `ghostty.h` is main-thread-only. The one documented exception is +`wakeup`, and its whole Swift body is a hop: + +```swift +DispatchQueue.main.async { state.appTick() } +``` + +The detail worth stealing: the wakeup is baked into the mailbox push +(`src/App.zig:559-576`), so it is impossible to enqueue without waking. Worker +threads never call host callbacks directly — `action_cb` fires from +`drainMailbox` on the main thread, so although `wakeup` is any-thread, in +practice every other callback is not. + +## Build plumbing, and how much of it is distribution + +Two build systems glued together, and ghostty's own CI never uses the integrated +path — `zig build -Demit-macos-app=false` then `xcodebuild`, because Nix breaks +xcodebuild (`.github/workflows/release-tip.yml:436-441`, and `macos/AGENTS.md` +says the same to humans). + +The Zig half: + +- `src/build/GhosttyLib.zig:19-58` — a static lib rooted at `src/main_c.zig` + with `bundle_compiler_rt = true` and `bundle_ubsan_rt = true`, then every + dependency archive merged into one. The comment is blunt: *"These must be + bundled since we're compiling into a static lib. Otherwise, you get undefined + symbol errors."* pardes's `build.zig` copies exactly this. +- `LibtoolStep.zig` / `CombineArchivesStep.zig` — `libtool -static` on Darwin, + an MRI script through `zig ar -M` elsewhere. Note the workaround at + `LibtoolStep.zig:54-78`: newer Xcode `libtool` drops 64-bit archive members + unless each input is copied and `ranlib`'d first. +- `LipoStep.zig` — 42 lines, `lipo -create`, fed by building the same static lib + twice against retargeted deps (`GhosttyLib.zig:169-199`). +- `XCFrameworkStep.zig` — 77 lines, `xcodebuild -create-xcframework`. It writes + to a fixed repo-relative path outside the Zig cache, which is why it needs + `has_side_effects` and an explicit `rm -rf`; the price of a stable path for + Xcode to reference. +- `GhosttyXCFramework.zig:56-60` stages a headers directory containing *only* + `ghostty.h` plus `module.modulemap`, because pointing at `include/` directly + drags in the separate libghostty-vt headers and Clang complains that the + umbrella header does not include them. + +The Xcode side needs almost nothing: one `PBXFileReference` for the xcframework +with `sourceTree = ""`, added to the frameworks build phase, plus +`OTHER_LDFLAGS = -lstdc++` for the vendored C++. No xcconfig, no search paths, no +bridging header for libghostty — Swift says `import GhosttyKit` and the module +map inside the framework does the rest. (The bridging header that does exist is +for two unrelated Objective-C helpers.) + +Resources are Xcode *folder references* pointing at `../zig-out/share/*`, so Zig +writes the tree and Xcode copies it verbatim. Fonts are not resources at all — +they are `@embedFile`'d (`src/font/embedded.zig`). Metal shaders are compiled to +a `.metallib` by `xcrun` at Zig build time and embedded into the library +(`MetallibStep.zig`, `SharedDeps.zig:504-511`), which is much simpler than +routing shaders through Xcode build rules. + +**What genuinely needs a Mac:** `libtool`, `lipo`, `xcodebuild`, `xcrun metal`, +`dsymutil`, `codesign`, and Swift itself. Everything else — including the +header-drift test — runs on Linux. `flake.nix:63-66` excludes Darwin from +buildable platforms entirely. + +pardes skips lipo, the xcframework, the Xcode project and codesigning. All four +exist to ship a signed universal bundle to someone else's machine; a dev build +needs a `swiftc` invocation and a directory with a plist in it. They are the +named upgrade path in `docs/macos.md`, in that order. + +## Small things worth remembering + +- Enum value 0 is reserved as "invalid" throughout the C API + (`PlatformTag` at `src/apprt/embedded.zig:390-395`), so a zero-initialized + struct is detectable, and tags are validated with `intToEnum` rather than + trusted. +- Zig errors never cross the boundary. Every export is a thin wrapper: + `fn foo_() !T` plus `catch |err| { log; return null; }` + (`src/apprt/embedded.zig:1398-1406`). There is no error channel at all. +- The exports live inside `pub const CAPI = struct {...}` and are only emitted + because `src/main_c.zig:34-47` references the struct in a `comptime` block. +- libghostty defends against host garbage rather than trusting it — + `updateContentScale` rejects NaN and clamps below 1 with the comment *"We are + an embedded API so the caller can send us all sorts of garbage"* + (`src/apprt/embedded.zig:777-793`), and `updateSize` drops no-op resizes + because *"Runtimes sometimes generate superfluous resize events even if the + size did not actually change (SwiftUI)"*. +- Swift keeps a dependency-free `GhosttyPackageMeta.swift` so auxiliary bundles + (the dock tile plugin) can share types without linking the Zig library. +- The Swift wrapper layer compiles unchanged for iOS, which is the real proof + that the C seam is the right width. It manages that by turning Zig→host + actions into `NSNotification`s keyed on the surface, decoupling the callback + from any particular view controller. -- cgit v1.3