summaryrefslogtreecommitdiff
path: root/docs/ghostty-macos-notes.md
blob: 3974d0d87fb3f07735c9509c2df2fbda546c9ee5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# 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: exactly
90 exported function declarations, four opaque handles (`typedef void*
ghostty_app_t` and friends, `:57-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 reminders
scattered through the Zig — an `IMPORTANT: Any changes here update
include/ghostty.h` comment at `src/input/key.zig:83`, and a six-step checklist
for adding an action whose fourth step is "Update `include/ghostty.h`"
(`src/apprt/action.zig:69-78`). What keeps it honest is a
test: ghostty's own `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:1018-1027`) holds `userdata`,
one capability bool (`supports_selection_clipboard`) 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 three callbacks today
— `wakeup`, `set_clipboard`, `read_clipboard` (`Runtime` in `src/macos.zig:153`)
— 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-460`):

```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 and the Xcode project. Those three exist to
ship a universal bundle that something other than this app can link, and they
are the named upgrade path in `docs/macos.md`, in that order. Codesigning is no
longer on that list: the `codesign --force --sign` step in the macos branch of
pardes's `build.zig` is reached by `b.getInstallStep().dependOn(&sign.step)`, so
it runs as part of an ordinary `zig build -Dplatform=macos`, ad-hoc
(`-`) by default because an unsigned arm64 bundle does not launch at all, and
`-Dmacos-identity=` swaps in a Developer ID and adds `--options runtime
--timestamp`. So a dev build here is a `swiftc` invocation, a directory with a
plist in it, and one `codesign`.

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