diff options
Diffstat (limited to 'docs/ghostty-macos-notes.md')
| -rw-r--r-- | docs/ghostty-macos-notes.md | 198 |
1 files changed, 198 insertions, 0 deletions
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 = "<group>"`, 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. |
