Files
byre/docs/macos.md
T

10 KiB
Raw Blame History

byre for macOS

Platform constraints, build requirements and SwiftUI behavior observed live.

Decision

One web view only: a task's Page view, which shows an HTML file from the task's worktree or one of its exposed previews in a WKWebView, in the attached sheet over the lead pane. Nothing else renders HTML: no WKWebView or WebView in the window, a dialog, Markdown, diagrams or the terminal. Any other link to a web page opens in the default browser. Mermaid needs a browser DOM, and its automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre has its own ```diagram block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the diagrams knowledge-base article. It is byre's only diagram format: a ```mermaid block shows as code, and the workflow stage graphs are drawn by generating its source. Agents see their diagrams through the render_diagram tool, which runs the installed app's own binary as Byre render-diagram (source on stdin, PNG on stdout), so the picture is exactly what the transcript draws; that mode opens no window and never touches the app's iCloud-synced store, so it uses the default text size. 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

Minimum macOS is the major version byre's host runs (sw_vers --productVersion prints it); there is no back-deployment.

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.

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".
  • 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.

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. SwiftTerm redraws the whole screen whenever a terminal color is set, even when its value hasn't changed.

  • The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (http://127.0.0.1:<port>/<task id>/<path>), which fetches each file through the server's getTaskRaw endpoint. The page's relative links to its stylesheets, scripts and images resolve to files beside it, and "↗ Browser" opens the same address. Previews load from their loopback forwards at <name>-<task>.localhost. App Transport Security blocks plain HTTP to *.localhost even with NSAllowsLocalNetworking; the localhost exception domain with subdomains in Info.plist allows it (loopback IP addresses need no exception).

Updates

The update UI must not offer Skip or turning automatic updates off: on a Mac running the server, byre-update would override either choice while the app isn't running.

Persistence

CloudKit has no unique constraints, so two Macs can each create a record for the same thing; duplicate records are expected. See docs/operations.md, "CloudKit schema", before changing the SwiftData models. 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. 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: a drag can cross consecutive paragraphs, headings and lists. Other block types and message boundaries still interrupt selection
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

Each family's 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. Use descriptors from the font files with latin-ext in the cascade to keep characters such as ő and Ł in Atkinson Hyperlegible.