69 lines
11 KiB
Markdown
69 lines
11 KiB
Markdown
# byre for macOS
|
||
|
||
The macOS app under `mac/` is byre's client, a native SwiftUI client of byre's API. This document covers what reading its code won't tell you.
|
||
|
||
## Decision
|
||
|
||
**No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. Mermaid needs a browser DOM, so the app draws flowcharts itself (`Diagram.swift`), and other diagram types show as source. byre's Markdown dialect (choice markers, `<details>` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself.
|
||
|
||
The app joins the tailnet as its own node with libtailscale and reaches byre's HTTPS origin on byre's node, so byre knows who is calling from the node itself. It never sends identity headers, and certificate validation is never relaxed.
|
||
|
||
## Stack
|
||
|
||
- Swift 6, SwiftUI, Observation. Minimum macOS is the major version byre's host runs (`sw_vers --productVersion` prints it); there is no back-deployment.
|
||
- AppKit where SwiftUI falls short (see "Gaps in SwiftUI"), and for `NSAlert`, the update progress panel, key and window monitoring, and Core Animation layers for the transcript's scanner and spinner, which animate in the render server so SwiftUI never redraws around them.
|
||
- Third-party code: Apple's `swift-openapi-generator` for the API client, SwiftTerm, Sparkle, and TailscaleKit.
|
||
|
||
The project is a Swift package built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target.
|
||
|
||
`mac/bundle.sh` builds in one directory shared by every worktree, `~/Library/Caches/byre/build`, under a lock: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree recompiles every dependency. It copies sources in with `rsync --checksum`, which leaves unchanged files' modification times alone. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. The server and helpers go in `Contents/Helpers`, not beside `Byre` in `Contents/MacOS`: the default APFS volume ignores case, so `byre` and `Byre` would be one file.
|
||
|
||
Only the release configuration is built. A debug build stops at an `#error` in `ByreApp.swift`, and even a failed one leaves the next release build in the same build directory recompiling every target, since the two configurations share it. Release targets build whole-module, so a one-file change to `Byre` recompiles the whole target; the generated API client is its own target so it isn't part of that.
|
||
|
||
`mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`), once per commit, and bundles it in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit.
|
||
|
||
## Signing
|
||
|
||
Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate, the hardened runtime and a secure timestamp, with the entitlements in `mac/byre.entitlements` and `mac/byre.provisionprofile` embedded. iCloud outside the App Store needs that profile: a Developer ID profile for the App ID `run.fc.byre.mac` with iCloud (CloudKit, container `iCloud.run.fc.byre.mac`) enabled, made at developer.apple.com under Certificates, Identifiers & Profiles → Profiles → Developer ID. It names no devices, so the app runs on any Mac. `security cms -D -i mac/byre.provisionprofile` prints it, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Only the team's Account Holder can create a Developer ID Application certificate (Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application, signed in as the Account Holder); a Mac that builds the app needs that certificate and its private key in its keychain. A build that isn't notarized still runs on a Mac it was copied to with `client_upload`, since only downloaded files are quarantined. Notification permission needs the fixed bundle identifier `run.fc.byre.mac`.
|
||
|
||
## Startup and streams
|
||
|
||
- Startup waits until the app's tailnet node lists the server's node among its peers, not just until it is `Running`: a node starting from libtailscale's netmap cache reports `Running` with no peers before control sends the real netmap, and a dial then waits out the SOCKS proxy's 5 s and is refused, which `URLSession` reports as "bad URL".
|
||
- The SSE client splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events.
|
||
- Run byre on this Mac and the Server tab run `byre install` through the user's login shell, since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`.
|
||
- SwiftUI's window restoration is off, but the scene keeps the id `main` that earlier builds used: AppKit restores their saved window by that id, and SwiftUI then opens no launch window at all when the id names no scene.
|
||
- The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
|
||
|
||
## Views
|
||
|
||
- The transcript is a plain `VStack` in a `ScrollView`, not lazy: a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum.
|
||
- The terminal cursor is a steady block: SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. The terminal's colors are set once per light/dark scheme, since each color set makes SwiftTerm redraw the whole screen.
|
||
|
||
## Updates
|
||
|
||
The app runs Sparkle with its own user driver instead of Sparkle's standard windows, so no update UI offers Skip or turning automatic updates off, which `byre-update` would override. A Mac that runs the server but not the app is updated by `byre-update`, which the server runs daily; it does nothing while the app is running from that bundle, leaving the update to the app's own updater. Both share the app's user defaults, so a check by either sets Sparkle's last-check time for both.
|
||
|
||
## Persistence
|
||
|
||
The app keeps its saved servers, drafts, task visit times and preferences in SwiftData, synced through CloudKit (`docs/operations.md`, "CloudKit schema"). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest server record per origin, the newest preferences and drafts records and the latest visit per task, and the next write deletes the rest. The store is named `byre.store` because the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app. Deleting the local files while the app isn't running makes the next launch download the records again.
|
||
|
||
## Gaps in SwiftUI
|
||
|
||
Places where SwiftUI alone may fall short, each checked side by side against its fallback; the fallback is used only where the check failed:
|
||
|
||
| Need | SwiftUI | Fallback | Spike result |
|
||
| --- | --- | --- | --- |
|
||
| Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, sending from `doCommand(by: insertNewline:)` | No fallback: `onKeyPress(.return)` runs before the `TextEditor`'s `NSTextView` and its input context. It sends, and returns `.ignored` for Shift so the text view adds the newline. Because it runs first, it must also return `.ignored` while the focused text view's `hasMarkedText()` is true, or Return sends the draft mid-composition. An in-process test that set marked text and delivered a Return `NSEvent` showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one passed Return on to the text view |
|
||
| Image paste and drop into every box that talks to an agent | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | No fallback, but not through these two modifiers: `TextEditor`'s own text view takes ⌘V and drops before them and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (`CommandGroup(replacing: .pasteboard)`): Paste attaches the clipboard's image when an image-taking box has focus and otherwise sends the ordinary `paste:`. Drops land on a `Color.clear` overlay on the `TextEditor` with `onDrop` and `allowsHitTesting(false)`: it still receives the drag, and clicks and typing pass through to the editor (with hit testing on, the editor can't be clicked) |
|
||
| Transcript of a long task scrolling smoothly and staying stuck to the bottom | `VStack` in a `ScrollView` | `List` with its own row reuse | No fallback: the stack stays at the bottom while rows arrive and a message streams, and stays put when scrolled up. A `LazyVStack` jumped while scrolling up (see "Views") |
|
||
| Selecting text across blocks and messages | `textSelection(.enabled)` selects within one `Text` only | A selectable `NSTextView` in an `NSViewRepresentable` | Fallback used within a message: each run of consecutive paragraphs, headings and lists is one `NSAttributedString` in one non-editable text view, so a drag crosses blocks and bullets. Code blocks, diffs, tables, quotes, diagrams, `<details>`, paragraphs with images and choice lists stay SwiftUI views and end a run. Messages are still separate views, so each message is copied on its own |
|
||
| The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | Fallback used, as planned |
|
||
| Font from `mac/fonts/` | Core Text | Convert the files to TTF in the build script | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) |
|
||
| SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | No fallback: `NSImage(data:)` decodes SVG and draws it sharp at any size |
|
||
|
||
The font files need one thing beyond loading. The latin and latin-ext files are subsets of one variable font with the same PostScript names, so registering both and looking the font up by name finds only the latin file, and characters such as `ő` and `Ł` fall back to a system font. The app instead makes a descriptor from each file's URL, sets the weight with `CTFontDescriptorCreateCopyWithVariation`, and puts the latin-ext descriptor in the latin one's `kCTFontCascadeListAttribute`; characters in neither file then fall back to the system cascade (`→` to Menlo).
|
||
|
||
## Not built
|
||
|
||
- A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files.
|