diff options
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 191 |
1 files changed, 135 insertions, 56 deletions
diff --git a/docs/macos.md b/docs/macos.md index 940d5a58..2b50e6bc 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -27,10 +27,10 @@ Pardes goes the other way because its frame is *already* a cell grid. `pardes.Surface` is `cols × rows` of `Cell`, and CoreText is the native way to draw one: attributed runs, the system font stack, and Apple's own subpixel and color-emoji handling, for free. The alternative is a hand-rolled glyph atlas, -and the SDL backend is the measurement of what that costs — roughly three -thousand of `src/gui/gui.zig`'s 4,700 lines are a 2048² FreeType-hinted R8 atlas, -GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys -nothing the grid needs. +and the SDL backend is the measurement of what that costs — a large part of +`src/gui/gui.zig`'s 6,348 lines is a 2048² FreeType-hinted R8 atlas (`atlas_w` +and `atlas_h`, `src/gui/gui.zig:140`), GPU pipelines, transfer buffers and +shaders. Writing a second one, blind, buys nothing the grid needs. Blind is the operative word: this backend was scaffolded on a Linux machine. A Metal renderer written there would have been untestable code — compiled at best, @@ -65,13 +65,28 @@ removing; it is the encoding being right. The one real difference is IO. The browser has no ptys, so `src/web.zig` forwards every effect out to JavaScript as a numbered code plus a byte payload, -and JavaScript performs it. macOS has `forkpty`, `read` and `write` right there, -so almost no effect crosses the boundary at all: `pardes_tick` drains the -core's effect queue and performs each one in Zig, the way `drainEffects` in -`src/tty/tty.zig` does. That is why the runtime struct is three callbacks and -not twelve — and why two of the three are the clipboard: the pasteboard is -AppKit's, in both directions, and is the one piece of IO the Zig side cannot -reach for itself. +and JavaScript performs it. macOS has `forkpty`, `read` and `write` right +there, so almost no effect crosses the boundary at all: `pardes_tick` runs +`while (core.nextEffect()) |e| core.perform(e)` (`src/macos.zig:1154`) and each +effect lands on this host's own `Host.VTable`, of whose twenty-one methods +fourteen are filled in (`src/macos.zig:1761`). + +The machine-local half of those methods is no longer this file's. `forkShell`, +`writeFileBytes` and `writeFd` live in `src/host_io.zig` and are shared with +the tty shell, the SDL shell and the detached daemon; `src/macos.zig` calls +them (`:1807`, `:1823`, `:1848`, `:1863`) and its own copies, together with the +four `extern "c"` declarations they needed (`forkpty`, `execv`, `chdir`, +`_exit`), are gone. Two `extern "c"` declarations remain here: `setenv`, and +`pardes_host_watch_file`, which FileWatcher.swift satisfies +(`src/macos.zig:40`, `:45`). The bug all four hosts' private `forkShell` +copies had in common is the argument for the shared file existing: none set +`FD_CLOEXEC` on +the pty master, so a program in one pane could read and write another pane's +terminal and closing a master did not reliably hang its shell up. + +That is why the runtime struct is three callbacks rather than a second vtable — +and why two of the three are the clipboard: the pasteboard is AppKit's, in both +directions, and is the one piece of IO the Zig side cannot reach for itself. ## The seam @@ -426,8 +441,11 @@ and it answers both. `Look` resolves against it, so it has to follow the shell rather than stay where the pane was spawned. -*When* it is read differs from the other two shells, and deliberately. The tty -and SDL hosts poll every pane every frame; here the drain has just finished +*When* it is read differs from the other three shells, and deliberately. The +tty, SDL and detached-daemon hosts poll every pane every frame — inline in the +tty loop's frame path (`src/tty/tty.zig:1228-1231`), `pollCwds` in the SDL +shell (`src/gui/gui.zig:4048`) and `pollFrame` in the daemon +(`src/detached/server.zig:889`); here the drain has just finished saying exactly which shells produced bytes, and nothing else can have moved one — a `cd` is a command, and a shell that ran a command writes at least its next prompt. So `refreshCwds` reads only for panes flagged by that tick's @@ -446,19 +464,25 @@ against `getppid()`. The socket half needed real portability work rather than a second spelling. Darwin has no `SOCK_CLOEXEC` and no `accept4`, so the flag is set with an -`fcntl` after the fact — a race only against a fork on another thread, and both -callers are past that. `sun_path` is 104 bytes here against 108 there, so no +`fcntl` after the fact — a race only against a fork on another thread, and +every caller is past that (`src/nested.zig:81-93` names all three). +`sun_path` is 104 bytes here against 108 there, so no buffer in the file spells a number any more; they are all sized from the field itself, and an address that does not fit is refused rather than truncated into a path pointing somewhere else. -Identity gained a third sibling. `bin/pardes` and -`pardes.app/Contents/MacOS/pardes` are one build installed twice and share no -directory at all, so the comparison is made at the *install prefix* — the -directory holding the `.app`, or the parent of a `bin` — and the bundle's name -can never carry the `-os-arch` tail the installed binary does, because -`CFBundleExecutable` is a fixed string. `zig build -Dplatform=macos` then -`pardes src/foo.zig` inside the app's own shell opens a pane in the app. +Identity gained a third sibling. `bin/pardes`, `bin/pardes-gui` and +`pardes.app/Contents/MacOS/pardes` are three installed frontends of one +program, so `samePardesExecutable` (`src/nested.zig:142`) compares BASENAMES +and ignores the paths entirely — the GUI may be system-wide while the tty +frontend sits in the user's own bin directory. `familyTail` strips a leading +`pardes-gui` or `pardes` and requires what is left to be either empty or a +valid `-os-arch` pair, which is what keeps `pardes-snap` and `pardes-perf` out +of the family; `sameFamily` then accepts two names whose tails agree, or either +of which has no tail at all. The bundle's copy always has none, because +`CFBundleExecutable` is a fixed string: the bundled `pardes-macos-aarch64` is +called `pardes` and nothing in the name records what it was. So `pardes +src/foo.zig` inside the app's own shell opens a pane in the app. ## Fonts and zoom @@ -717,24 +741,39 @@ synchronize and no mailbox protocol to get wrong. ## Building -Two commands, because they are two different machines' problems. +One command builds everything this shell has. The two extra steps below exist +only for handing a bundle to somebody else. ```sh zig build -Dplatform=macos ``` produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h` -beside it. +beside it — and on a Darwin host a signed `zig-out/pardes.app` as well, which +the next section is about. -`-Dplatform=macos` is the one platform that overrides the repo's default -target. Everything else defaults to the Steam Deck (x86_64 linux-gnu, glibc -pinned low), and that default is not survivable here: swiftc links this archive, -so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app -link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the -default becomes the host arch at `macos_min_version`, which is the same triple -the app's swiftc link is given, so the two halves of the app cannot disagree about -how old a macOS they support. On any other host it stays plain native, which is -what keeps the Linux dev loop below runnable. +`zig-out` and not `~/.local`, and that now needs saying. A bare `zig build` +redirects the install prefix to `$HOME/.local` and prints that it has, but only +behind the guard `also_gui and prefixIsUntouched(b)`: `also_gui` is +`requested_platform == null`, and `prefixIsUntouched` refuses when a `DESTDIR`, +a `--prefix` or a `--prefix-*dir` has already chosen somewhere. `-Dplatform=macos` +names a shell, so neither condition holds and every path in this document is +relative to `zig-out` unless you pass `--prefix` yourself. The `<prefix>/dev` +directory the benchmark binaries install into likewise never appears in this +backend; nothing here is a dev binary. + +`-Dplatform=macos` is one of the two platforms that override the repo's default +target; `esp32p4` is the other, and pins its own riscv32-freestanding query. +The tty, SDL and web shells default to the Steam Deck (x86_64 linux-gnu, glibc +pinned to 2.38), and that default is not survivable here: swiftc links this +archive, so a Steam Deck build hands ld64 ELF objects inside a GNU archive and +the app link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac +the default becomes the host arch at `macos_min_version`, which is the same +triple the app's swiftc link is given, so the two halves of the app cannot +disagree about how old a macOS they support — the arch is spelled rather than +left null so the CPU model resolves to generic, which is ghostty's +`Config.genericMacOSTarget` workaround. On any other host it stays plain +native, which is what keeps the Linux dev loop below runnable. That archive is also *fat*. `b.addLibrary` emits only this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and ghostty-vt's simdutf/highway stay in archives @@ -749,23 +788,37 @@ through `ranlib` first, because ld64 otherwise refuses zig's layout outright missing, which links almost far enough to look like a source problem. ```sh -zig build -Dplatform=macos # zig-out/lib/libpardes.a + include/pardes.h -zig build macos-app -Dplatform=macos # ...and a signed zig-out/pardes.app -zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over +zig build -Dplatform=macos # libpardes.a + pardes.h, and on Darwin a signed pardes.app too +zig build macos-app -Dplatform=macos # the signed bundle on its own +zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over ``` -All three need a Darwin host: the app branch is gated on -`builtin.os.tag.isDarwin()`, and off Darwin `macos-app` and `macos-dmg` resolve -to an explicit build failure rather than a bundle that could not have been -signed. A plain `zig build -Dplatform=macos` still gives you the library and -the header anywhere, which is what the Linux dev loop below uses. +The first line is not "the library only", and that is the part worth stating: +`b.getInstallStep().dependOn(&sign.step)` puts the bundle on the DEFAULT +install step, so on a Darwin host an ordinary `zig build -Dplatform=macos` +assembles `zig-out/pardes.app` and ad-hoc-signs it. build.zig says why in as +many words — the app is "part of an ORDINARY build rather than a verb to +remember". Only the dmg is opt-in, because it is for handing over rather than +for running. + +What is gated is that whole branch, and on the TARGET as well as the host: +`builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()` is what decides +whether `fatArchive` runs, and without a Mach-O archive there is nothing for +swiftc to link. Off either, `macos-app` and `macos-dmg` resolve to an explicit +`addFail` — *"pardes.app needs a Darwin host and target; drop -Dtarget= or pass +-Dtarget=native"* — rather than a +bundle that could not have been signed, and the default install stops at the +library. The library and the header build anywhere, which is what the Linux dev +loop below uses. The bundle is assembled by `build.zig` itself, not by a script it shells out -to. An `.app` is a directory with a plist, a binary and an icon in it, and each -of those is one step whose inputs the build graph knows — so the app rebuilds -when a Swift source or the archive moves and is left alone when nothing does. -There is no Xcode project: a hand-written `pbxproj` would be a second build -system to keep in step for what three `addInstallFileWithDir` calls already do. +to. An `.app` is a directory with a plist, a binary, a shader and an icon in +it, and each of those is one step whose inputs the build graph knows — so the +app rebuilds when a Swift source or the archive moves and is left alone when +nothing does. There is no Xcode project: a hand-written `pbxproj` would be a +second build system to keep in step for what four `addInstallFileWithDir` calls +already do (`install_app_bin`, `install_plist`, `install_scene_kernel`, +`install_icon`). - **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed `src/macos/Info.plist` and writes a stamped copy into the cache. The source @@ -784,16 +837,24 @@ system to keep in step for what three `addInstallFileWithDir` calls already do. `defaultBG`, Glenda `defaultFG`, the strip above her the tag bar, the block cursor at the end of it `ansi16[11]`), and there is no binary blob in the tree to disagree with the app it ships in. -- **The link.** The same swiftc invocation as before, with the optimize mode - following `-Doptimize` so both halves of the app are built the same way: +- **The scene kernel.** `shaders/crt.ci.metal` is installed verbatim as + `Contents/Resources/crt.ci.metal` and compiled at runtime with + `CIKernel.kernels(withMetalString:)`. The same file is also an anonymous + module import named `effect-source-crt.ci.metal`, added by `build.zig`'s + per-shell module wiring under `if (shell == .macos)`, which is what lets + `EffectCode` print the exact source the app executes. +- **The link.** One swiftc invocation, with the optimize mode following + `-Doptimize` — `-Onone` for Debug, `-Osize` for ReleaseSmall, `-O` otherwise + — so both halves of the app are built the same way: ```sh -swiftc -O -target <host-arch>-apple-macos13.0 \ +swiftc <-O|-Osize|-Onone> -target <arch>-apple-macos13.0 \ -import-objc-header src/macos/pardes.h \ -o <cache>/pardes \ - src/macos/Sources/{main,AppDelegate,PardesView}.swift \ + src/macos/Sources/{main,AppDelegate,PardesView,ScenePostprocessor,FileWatcher}.swift \ <cache>/libpardes.a -lc++ \ - -framework AppKit -framework CoreText -framework CoreGraphics + -framework AppKit -framework CoreText -framework CoreGraphics \ + -framework CoreImage -framework Metal ``` The arch is derived from the build target, which defaults to the host — `arm64` @@ -846,10 +907,13 @@ for letting something other than this app link the core. This ships arm64. Three layers, and each one exists because the layer above it cannot reach where it goes. -**The Linux dev loop.** The Zig half of this backend is plain POSIX. `forkpty`, -`read`, `write`, `ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty -backend by a string constant. So `-Dplatform=macos` compiles on a Linux host, -and its tests run there: +**The Linux dev loop.** The Zig half of this backend is plain POSIX, and most +of it is no longer even this backend's: `forkShell`, `writeFileBytes` and +`writeFd` are `src/host_io.zig`'s, byte-identical on both systems, and what is +left that differs is `ioctl(TIOCSWINSZ)` (absent from `std.c.T` on darwin), +`/usr/bin/open` against `/usr/bin/xdg-open` (`src/look.zig:33-37`), and libproc +against `/proc`. So `-Dplatform=macos` compiles on a Linux host, and its tests +run there: ```sh zig build unit-test -Dplatform=macos @@ -897,7 +961,8 @@ product. Scripts are `test/macos-snapshots/*.snap` (seven of them: boot, cwd, drop, font, keys, rotate, trackpad) and speak the tty suite's vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`, `mouse`, `click`, `wheel`, `resize`, `draw`) plus what only -exists here: `fingers <n>`, `force`, `rotate <degrees> [gap_ms]`, `rotate_end`, +exists here: `fingers <n> <col> <row>`, `force <col> <row>`, +`rotate <degrees> [gap_ms]`, `rotate_end`, `drop <path> <col> <row>`, `scroll <rows> <col> <row>`, `haptic <none|exec|look>`, `nsclick` (the AppKit-event path, as opposed to `click`'s direct entry-point call), `font <name>`, `font-size <points>`, `zoom`, @@ -983,6 +1048,20 @@ used for the table and is not folded into the direct-path claim. - **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a job, a worker to run the command, and a `pipe_resp` event back. Same shape as `lsp`, one more response type. +- **Detached sessions.** `Attach` (`SPC s a`) and `Detach` (`SPC s D`) exist on + every hosted platform, this one included, because `Builtin.enabled` is + `pardes.hosted`. Neither works here. `Detach` emits `Effect.detach`, this + host fills in no `push_detach`, and `Pardes.perform` therefore reports + `error.NotAttached` on the pane's message row (`src/pardes.zig:6837`). + `Attach` emits `Effect.attach`, which `perform` turns into an `attach_req` + the shell is supposed to consume from OUTSIDE `pump` with `takeAttach` — and + `src/macos.zig` never calls `takeAttach`, so the word does nothing at all and + says nothing either. Wiring it up means a unix-socket frontend loop beside + the AppKit one, which is `src/detached/client.zig`'s job in the tty and SDL + shells; see `docs/detached.md`. +- **`tty_taken` on darwin.** The pull is wired (`ttyTaken`, `src/macos.zig:1838`) + but `look.ttyTaken` has no libproc implementation and answers `false`, so an + `Exec` here always believes the pane is still at the prompt it forked. - **IME and marked text.** Only finished characters reach `pardes_key`, so a dead key composes nothing and Option is Alt rather than a compose modifier. Real composition means implementing `NSTextInputClient` *and* giving the core |
