12 KiB
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, one of its exposed previews, or a website an agent opened with show_page's url, 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/tailscalekit.sh builds TailscaleKit from github.com/tailscale/libtailscale at the commit pinned in the script (commit=), once per commit, as one xcframework for macOS, iOS and the iOS simulator; mac/bundle.sh and mac/phone.sh both run it, and the Mac app 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 reportsRunningwith no peers before control sends the real netmap, and a dial made then fails. - Every request reaches the tailnet through byre's own SOCKS5 proxy on a loopback port (
Proxy.swift), which dials withtailscale_dial, not libtailscale'stailscale_loopback. iOS reclaims a suspended app's listening sockets, and tsnet's proxy stops for good on the first failed accept, leaving the phone unable to reach any server. byre's proxy binds again on the same port each time the app returns to the foreground. - Run byre on this Mac and the Server tab run
byre installthrough the user's login shell, sincebyre installcopies itsPATHinto the service and the app's ownPATHfrom launchd finds none ofclaude,codex,agyornpx. - SwiftUI's window restoration is off, but the scene keeps the id
mainthat 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
VStackin aScrollView, not lazy: aLazyVStackestimates 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'sgetTaskRawendpoint. 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*.localhosteven withNSAllowsLocalNetworking; thelocalhostexception domain with subdomains inInfo.plistallows it (loopback IP addresses need no exception).NSAllowsArbitraryLoadsInWebContentlets the Page view load plain-HTTP websites without lifting ATS for the app's own requests. -
Each Page view tab's
WKWebViewbelongs toPages, not to the SwiftUI view, so it keeps its page while the sheet is closed and agents can drive it withdrive_page(navigate, JavaScript, capture) from the client connection. All tabs share WebKit's default persistent data store, so a site the human signs into stays signed in across launches. A link that opens a new window becomes a new tab of the same task. Capture refuses a tab that isn't on screen (sheet closed, or another tab showing), since its snapshot would show nothing the human sees. Passkey sign-in may fail, because WebKit allows WebAuthn in an embedded view only for apps with Apple's web browser entitlement; a password with a 2FA code works.
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.