35 KiB
byre for macOS
The macOS app under mac/ is byre's client. The UI's behaviour, shared with the server, is described in docs/operations.md; this document covers how the app is built.
Decision
The app is a native SwiftUI client of byre's API. 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.
The app connects to byre's tailscale serve HTTPS URL, so its requests get the Tailscale identity headers (see "Identity" in docs/operations.md). It never sends identity headers itself, and never shows a login.
Stack
- Swift 6, SwiftUI, Observation (
@Observable). Minimum macOS is the major version byre's host runs (sw_vers --productVersionprints it); there is no back-deployment. - AppKit only through
NSViewRepresentable, and only where the "Gaps in SwiftUI" table below says so: the terminal. - Foundation's
URLSessionfor REST, the SSE streams and the terminal WebSocket. UserNotificationsfor notifications.- Third-party code: Apple's
swift-openapi-generator(withswift-openapi-runtimeandswift-openapi-urlsession) for the API types and client, SwiftTerm for the terminal, and Sparkle for updates (its framework is a binary target, loaded fromContents/Frameworksthrough the@executable_path/../Frameworksrpath set inmac/Package.swift).
The project is a Swift package under mac/, 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 the release configuration in one directory shared by every worktree, ~/Library/Caches/byre/build: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a --scratch-path, recompiles every dependency. Under a lockf lock on ~/Library/Caches/byre/build.lock, so builds from different worktrees take turns, it copies the worktree's mac/, api/ and hands/ there with rsync --checksum, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to mac/.build/Byre, mac/.build/byre-hands and mac/.build/Sparkle.framework. The server is built outside the lock with go build -o mac/.build/server/byre, since Go's own cache is shared and safe to use concurrently. 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. It then assembles mac/.build/Byre.app (Byre in Contents/MacOS, the server and byre-hands in Contents/Helpers, Sparkle in Contents/Frameworks, mac/Info.plist with the fixed bundle identifier run.fc.byre.mac and a ByreCommit key added holding the worktree's git rev-parse HEAD, which API sends as the Byre-Commit header on every request beside Byre-Host, the Mac's name, CFBundleShortVersionString from git describe --tags --always and CFBundleVersion from git rev-list --count HEAD, the mac/fonts/ files and an .icns rendered from mac/icon.svg, cached as mac/.build/byre.icns until the SVG changes) and signs it; notification permission needs the bundle identifier. docs/operations.md, "macOS app", lists the signing order.
Signing
Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate (codesign --sign "Developer ID Application"), --options runtime (the hardened runtime notarization requires) and --timestamp, with the entitlements in mac/byre.entitlements (the team-prefixed application identifier, the iCloud container with CloudKit in its Production environment, production push for sync, and Apple Events for driving Orion) and mac/byre.provisionprofile embedded as Contents/embedded.provisionprofile. 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, DeveloperCertificates included, 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 that didn't download it, such as one it was copied to with client_upload, since only downloaded files are quarantined; mac/release.sh notarizes releases (see "Releases" in docs/operations.md).
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. The generated API client is its own target, ByreAPI, so an edit to the app recompiles only the Byre target. SwiftPM builds release targets whole-module, so that is one compile of the whole Byre target, and a one-file change still costs that. The app keeps its server origin, drafts and visits in SwiftData (see "Persistence").
SwiftUI's own macros don't build here: the SDK declares @State, @Entry, @Animatable and the rest as macros implemented in the SwiftUIMacros plugin, which ships with Xcode and not with the Command Line Tools (ls /Library/Developer/CommandLineTools/usr/lib/swift/host/plugins lists the plugins there). @State is reached through its property-wrapper type instead, as typealias ViewState = State and @ViewState var …; other SwiftUI macros aren't used. @Observable and @Bindable (Observation's plugin, which the Command Line Tools have), @Environment and @FocusState work as usual.
Resources
| Value | Source | Swift |
|---|---|---|
| API types and client | api/openapi.yaml |
Generated at build time into the ByreAPI target by the swift-openapi-generator plugin (mac/Sources/ByreAPI/openapi-generator-config.yaml, public access, idiomatic naming: display_name is displayName) from the symlink mac/Sources/ByreAPI/openapi.yaml to the spec. App files that use it import ByreAPI. The output lands in ~/Library/Caches/byre/build/mac/.build/plugins/outputs/, not in git |
| Palette, light and dark | mac/Sources/Byre/Palette.swift |
One dynamic Color per role, each a light and a dark hex value, resolving with the window's appearance; Palette.task0 … Palette.task10 are the task colours |
| Font | mac/fonts/ |
The build script copies the woff2 files into the bundle; the app loads each with CTFontManagerCreateFontDescriptorsFromURL (see "Gaps in SwiftUI") |
| App icon | mac/icon.svg |
The build script renders it to an .icns with rsvg-convert and iconutil |
Shape of the code
mac/Sources/Byre/ |
What it is |
|---|---|
API.swift, EventStream.swift |
The generated client over URLSession; the SSE client |
Store.swift, Feed.swift |
The one @Observable store, handleEvent, per-task feeds, focus |
ByreApp.swift, Startup.swift |
The App, window, menus, startup sequence, identity banner |
Panes.swift, PaneFocus.swift |
The two panes, tab strips, More menu, pane-focus reporting |
Chat.swift, TranscriptBuilder.swift, Composer.swift, MergeDialog.swift |
Transcript and composer for tasks and the lead |
Markdown.swift, MarkdownView.swift |
byre's Markdown parser to a block tree, and its views |
Diagram.swift |
Native flowchart parser, layout and drawing for Mermaid blocks and inbox diagram attachments |
Questions.swift |
The Questions tab: one item at a time, choices, Other, Let the agent decide, answered items read-only |
Changes.swift |
Since main, This turn, pinned snapshots, per-file diffs, line comments |
Files.swift |
Read-only worktree browser |
Terminal.swift |
The terminal view and its WebSocket |
Reviews.swift |
Review cards, finding diffs, Fix and Ignore |
Workflow.swift |
Stage graph and attempts |
Project.swift |
Project tab and TODO items |
Status.swift, Settings.swift |
Left-pane tabs |
Switcher.swift |
⌘K overlay, the shortcuts help and the Go actions (runGo) |
Drafts.swift |
Composer, answer and line-comment drafts |
Notifications.swift |
UNUserNotificationCenter delivery |
Dialogs.swift, Format.swift |
Overlays (alerts, modals with showConfirm and showChoice, the switcher's slot, the More menu, the image view) and the window's key monitor, ago, Copy, shared button and layout styles, diff lines |
Images.swift |
Blob, worktree and data: images, their cache and the full-size overlay |
Fonts.swift, Palette.swift |
The font and the colours |
Persistence.swift |
The SwiftData records |
Store and events
Store holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: handleEvent with a switch on event kinds, taskState, setFocus with an optimistic apply and a latest-request-wins guard (claimRequest), loadTasks creating a feed for each active task, and dropClosedFeeds.
Feed holds one task's events: history paged from GET /api/tasks/{taskId}/events 1000 at a time, live events held back until the history is in, duplicates dropped by seq, unstored agent.delta events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of GET /api/me/lead/events and pages back with loadOlderLead.
Views read Store and Feed properties and SwiftUI re-renders them on change. Changes that carry a value are Listeners instead, whose add returns the function that removes the listener: Store.events (every event), Feed.appended (each batch of new stored events) and Feed.streamed (each agent.delta). Applying focus selects its task.
API calls go through the generated Client over URLSessionTransport. A middleware turns any response of 400 or more into ApiError(status, message), the message being the body's Error.message for JSON and <status> <reason>: <body text> otherwise; the generated client wraps it in ClientError, and errorText unwraps it. Dates go through one transcoder, ISO 8601 with optional fractional seconds, which reads Go's RFC 3339 times with any number of fraction digits or none.
EventStream is a hand-written SSE client over URLSession.bytes(for:). It splits lines itself, because AsyncBytes.lines drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles id, event: ping, event: ready and data: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (NWPathMonitor), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap last_event_seq) and lead (?lead=true, after the last event of the lead's first page), and only the global one drives the connection dot.
Startup: GET /api/admin/status, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with byre failed to start: <error>; Change Server… leaves that screen. handleEvent calls notifyInbox on inbox.created and notifyTurn on turn.ended.
The app logs through os.Logger with subsystem run.fc.byre.mac: log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"' prints it.
Focus lives in Store. In-app #/ links go through Store.route: #/task/<id>/<tab> opens that tab, #/task/<id> and #/project/<id> keep the current tab, and anything else clears focus. Focus is shared through the server, so every Mac running the app follows it.
Window and layout
One window. The panes follow the "UI layout" rules in AGENTS.md: an HStack of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar.
- Left pane: tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip.
- Right pane: the task bar (task picker
<project> ▸ <title> ▾and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a More ▾ menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour.
Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane.
Overlays live in Overlays (Dialogs.swift), one environment object for the window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its canSubmit holds, the switcher's highlighted row) through a local key monitor, which passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in Overlays too, so ⌘L can switch to Lead and focus its composer.
Closing the window leaves the app running with its store and streams (applicationShouldTerminateAfterLastWindowClosed returns false), so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting.
Views
- Transcript and Lead.
TranscriptBuilderis a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns,NO_REPLYturns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item'sinbox.updatedevents and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is aScrollViewwith a plainVStack, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because 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. While the user is scrolling (onScrollPhaseChange), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (ChatModel.older,prependOlder), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all foldedNO_REPLYturns) fetches the next one at once. Each Markdown text run'sNSTextViewmeasures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (markSummaryRead). The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. - Composer. A
TextEditor, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded toPOST /api/blobson send; any other imageNSBitmapImageRepdecodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. - Questions. The question a transcript bubble picked (
Store.question), read-only once it is no longer open, or else the selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. - Changes, Files, Reviews, Workflow, Project, Status, Settings. Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the
WorkflowRun(stages as boxes coloured by state,afteras edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - Terminal. A terminal view attached to
GET /api/tasks/{taskId}/terminalwithURLSessionWebSocketTask: binary input and output,TerminalSizeJSON on resize, close code 4000 forshell ended: <status>with New shell, 1011 for a start failure, reconnect after 2 s while the task is active, one socket per open tab and selected task. Colours come from the palette. Ctrl combinations typed in it go to the shell. The cursor is a steady block (setCursorStyle(.steadyBlock)when the view is made): SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion (variable refresh rate) display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. A program in the shell can still ask for a blinking cursor with DECSCUSR. - Switcher. The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the
name: titleproject prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Questions tab when it has open items, its unread summary when it has one, and its Transcript otherwise. - Merge and Discard. The transcript head's Merge button is enabled once
GET /api/tasks/{taskId}/unmergedreports commits or files, refetched 1.5 s after a tool result or turn end.confirmMerge(MergeDialog.swift): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Questions tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task.
Markdown
Markdown.swift parses byre's dialect line by line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because the dialect (choice markers, <details> sections, #/task/, #/todo/ and #/suggest/ links, worktree image paths, NO_REPLY) matches no library. Inline content becomes an AttributedString with code, strong, emphasis and links. Links: http and https open in the default browser; #/todo/<project>/<title> becomes the TODO link with its start and discuss actions or the task's state; #/suggest/<project>/<title>/<brief> becomes the same, its start creating the task with the brief and its discuss messaging the lead; any other #/ link goes through Store.route (see "Store and events"); anything else is plain text. The TODO actions are accent link text in the selectable run, so the line stays selectable; while one runs both lose their link and the clicked one reads "starting…" (or "sending…" for a suggestion's discuss).
Diagrams
Mermaid needs a browser DOM, so the app doesn't use it. Diagram.swift draws a ```mermaid block or an inbox diagram attachment itself when the source is a flowchart, the only kind agents have written so far. It parses:
- the header
flowchartorgraphwithLR,RL,TD/TBorBT; - nodes
A,A[box],A(rounded),A{diamond}andA((circle)), with"quoted"labels; - edges
-->,---,-.->and==>, chained (A --> B --> C), with labels written-- text -->or-->|text|; classDef,classand:::for fill, stroke and text colour.
It lays the nodes out in layers along the header's direction, orders each layer to reduce crossings, and draws with SwiftUI Canvas, in the palette's colours. Any other diagram type, or a flowchart line it can't parse, shows the source with diagram not rendered: <reason> under it. The layout isn't Mermaid's pixel for pixel. The Workflow tab's stage graph uses the same layout and drawing code, fed from the WorkflowRun rather than from Mermaid text.
For example, this source from an inbox question:
flowchart LR
A[ask_human] --> B{blocking?}
B -- yes --> C[end turn]
B -- no --> D[keep working]
draws a box ask_human, an arrow to a diamond blocking?, and from it an arrow labelled "yes" to a box end turn and one labelled "no" to a box keep working, in three layers from left to right.
Images
Images load through the API: GET /api/blobs/{id}, GET /api/tasks/{taskId}/image?path= for worktree paths and file:// URLs, data:image/ URLs decoded in place, other schemes shown as a link with the alt text and never fetched, and "image not available: " on failure. Clicking an image opens it full size in an in-window overlay (a native image view, not a browser).
Notifications
Notifications.swift posts through UNUserNotificationCenter: the preferences from GET /api/me, the events (inbox.created by kind, turn.ended with summary: true), title byre · <task title>, body the first line cut at 200 characters, and the item id or turn-<seq> as the request identifier, so a repeat replaces the earlier one. They show as banners even while byre is the active app (Notifier, the centre's delegate, set at launch). Clicking one activates the app, shows the window (reopening it if it was closed) and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; Notifier holds its task until startup has a store, then focuses it. Settings shows the authorization state (This Mac shows notifications while byre is running., This Mac doesn't show notifications from byre. with a Turn on for this Mac button, which asks macOS for permission, or Notifications are blocked for byre in System Settings.). Nothing notifies while the app isn't running.
Pane focus
PaneFocus.swift sends PUT /api/me/pane-focus with focused (the window is key, the app is active and the keyboard focus is inside the right pane) and drafting (any right-pane text field other than the terminal is non-empty) whenever either changes, and again when the global stream reconnects. Deactivating the app, closing the window or quitting sends focused: false.
Drafts
Drafts.swift keeps drafts in a SwiftData DraftRecord (see "Persistence"), one per server and login: the record's scope is the server origin, normalized to lower-case scheme and host with the port always written, then the login: <origin>.<login>, for example https://byre.example.ts.net:443.you@example.com. Its json holds composer text by message:<task id> or message:lead, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Change Server… leaves the old server's drafts in place, and switching back finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching.
Keyboard and menus
The app menu has About, Check for Updates…, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a Go menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an alert or modal is open (dialogOpen); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in Overlays, except while the terminal's TerminalView is the first responder (terminalFocused), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay.
Connection
On first launch the window shows one field, Server URL (an https origin with no path), and Connect. Connect calls GET /api/admin/status and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the ts.net HTTPS address), 403 (the request didn't come through tailscale serve); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept as the app's one ServerRecord (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (NSPersistentCloudKitContainer.eventChangedNotification) looks for a ServerRecord again and starts against it, so on a new Mac the address another Mac saved connects without typing.
Updates
Updater.swift runs Sparkle's standard updater (SPUStandardUpdaterController), started at launch. It reads SUFeedURL from mac/Info.plist, the appcast of the newest release (see "Releases" in docs/operations.md), checks it daily (SUEnableAutomaticChecks, so Sparkle doesn't first ask whether to), compares the appcast's sparkle:version with CFBundleVersion, and verifies the download's EdDSA signature against SUPublicEDKey and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. At launch, the app compares its CFBundleVersion with lastLaunchedVersion in UserDefaults and stores the new one; when a stored version differs, however the bundle was replaced (Sparkle relaunching, installing on quit, or a new copy dragged in), it reads ~/Library/LaunchAgents/run.fc.byre.plist, and when the service's program is this bundle's Contents/Helpers/byre, runs launchctl kickstart -k gui/<uid>/run.fc.byre, so the server updates with the app. A server installed from a checkout is left alone.
Persistence
Persistence.swift holds the app's state in SwiftData, synced through CloudKit to the private database of container iCloud.run.fc.byre.mac, so it follows the iCloud account to every Mac: ServerRecord (the server origin), DraftRecord (drafts, see "Drafts") and VisitRecord (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the newest ServerRecord and DraftRecord and the latest VisitRecord per task, and the next write keeps one and deletes the rest. The store is byre.store in run.fc.byre.mac under Application Support (the app isn't sandboxed, so SwiftData's default default.store would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (applicationShouldTerminate in ByreApp.swift).
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:), which the input method never reaches while it holds marked text |
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 (checked by hand). 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 with setMarkedText and delivered a Return NSEvent to the window showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one sent nothing and passed Return on to the text view. From there the input method handles Return as it does in any NSTextView, so the NSTextView fallback would behave the same. The guard reads the key window's first responder, which is the composer while the user types |
| Image paste and drop into the composer | 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 onPasteCommand and onDrop and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (CommandGroup(replacing: .pasteboard) with Cut, Copy, Paste and Select All sent to the first responder): Paste attaches the clipboard's image when the composer has focus (focusedValue) and otherwise sends the ordinary paste:, so text paste works in every field and the terminal. 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). Both attach thumbnails and upload to POST /api/blobs |
| Transcript of a long task (thousands of events) 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 (ScrollPosition.scrollTo(edge: .bottom) on each change while within 80 points of the bottom, tracked with onScrollGeometryChange), and stays put when scrolled up. A LazyVStack jumped while scrolling up through rows it hadn't shown yet, as it corrected their estimated heights |
| Selecting text across blocks and messages | textSelection(.enabled) selects within one Text only |
A selectable NSTextView in an NSViewRepresentable |
Fallback used within a message: MarkdownView draws each run of consecutive paragraphs, headings and lists (bullets as text, hanging indents from tab stops and paragraph styles) as one NSAttributedString in one non-editable FlowText 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 a drag selects within one run of text in one message and 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: SwiftTerm over URLSessionWebSocketTask gives a working shell; Ctrl-C, Ctrl-L, Ctrl-K and Ctrl-R reach it. A local keyDown monitor takes Ctrl-K/P/L/S// as Go shortcuts except while the first responder is the TerminalView |
Font from mac/fonts/ |
Core Text | Convert the files to TTF in the build script if Core Text refuses them | 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 the SVG from the worktree image endpoint (_NSSVGImageRep) 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). Fonts.swift does this. SwiftUI gets the result through Font(CTFont).
Building and checking
mac/bundle.sh is the compile check for changes under mac/, alongside the checks AGENTS.md already names. No tests are added. Installing the app is a separate action from deploying the byre service.
Not built
- A Dock badge, a
byre:URL scheme, Quick Look, a closed-task browser, editing files. - Several servers or windows.