# 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.