Files
byre/docs/macos.md
T

26 KiB

byre for macOS

This is a design. Nothing in it is implemented: there is no macOS client, no native bridge and no new API. The work to build it is under "M8 — macOS client" in TODO.md.

Decision

The app is a small AppKit shell around one WKWebView that loads the configured byre server's own origin, the same Tailscale Serve HTTPS URL a browser uses. The workspace is the unmodified web/ client the server already serves: the same index.html, style.css, dist/ modules, fonts, and vendored Mermaid and xterm, lazy-loaded exactly as in a browser. The shell adds only what a browser tab gets from the browser and a WKWebView does not: a window, menus, a server address, link routing, and a delivery path for the Web Notifications API.

Version one changes nothing under web/ and nothing on the server. Every gap between Safari and WKWebView is closed on the native side, through a WKUIDelegate or WKNavigationDelegate method or a document-start WKUserScript. That is what makes "strictly copies the web client" checkable: the app and Safari run byte-identical client code against the same server, so any visible difference is a shell bug. A gap that can only be closed in web/ is a decision for the owner, because it ends that property.

The page is loaded over HTTPS from the server. It is not bundled under file://, not copied into the app, not proxied through a local server and not given a custom origin. Same-origin REST, SSE, images and the terminal WebSocket therefore work unchanged, with no CORS, no second copy of the API schema, no second Markdown parser and no skew between app and server assets.

Swift owns only the configured origin, the window frame, notification authorization and the short-lived calls the polyfill makes. It never subscribes to SSE, renders a transcript, reads model lists or sends task mutations. The web store in web/src/state.ts is the only application state.

Rejected: a native re-rendering

A SwiftUI or AppKit rewrite would reimplement the event reducer in web/src/state.ts, the transcript renderer and its tool grouping, the custom Markdown in markdown.ts, the inbox, task colours, the terminal protocol and the pane focus rules, and would drift from the web client on every change. A file-by-file parity table doesn't prevent that drift. It would also force differences the brief doesn't ask for: SwiftUI limits text selection to one view, SwiftTerm's defaults are not xterm's, and font metrics and equal-width layout would need proving rather than coming free. A native re-rendering stays possible later, with this design's acceptance list as its contract.

What "strictly copies" means

Inside the window, the app is the web client: two equal panes, the same tabs, labels, palette, font, dialogs, keyboard handling and empty states, because it is the same code. The identity banner is the only thing above the panes. Nothing is replaced with native controls, and no minimum width beyond what the web layout itself needs is imposed. Restrictions are copied along with capabilities: read-only Files, no file-mention picker, no closed-task browser, previews open outside.

"Same" means the same behaviour under the same inputs and viewport as the web client in Safari on the same Mac. It does not promise identical window chrome or identical rendering across browser engines.

That includes the web client's browser-flavoured wording. Settings says "This browser shows notifications while a byre page is open." (web/src/settings.ts, renderDevice). Version one keeps it, because changing it means changing web/. The two choices, for a user who has granted notifications:

Choice Settings → device line in the app Cost
Keep (version one) This browser shows notifications while a byre page is open. None; slightly odd wording in an app.
Reword This device shows notifications while byre is open. A web/ change that alters the browser too.

The only things outside the web content are the platform frame, listed in full:

Difference from the browser Boundary
macOS window frame and title bar The web viewport below it is unchanged; no byre controls in the title bar.
Application menu bar System menu fonts stay system fonts.
Connection sheet (first launch, Change Server…) An app has no address bar. No new tab or left-pane section.
Native panels the content asks for The macOS notification permission prompt.
Notification Center banners Same events, preferences and text as the browser.
External links open in the default browser The app stays on the configured origin; hash routes stay in the page.
App-private website storage Drafts and visit order from a browser are not imported; the storage format is unchanged.
Load-error and crash overlays Shown instead of the page, never as a third pane.

Window

One window, one server. No native window tabs (NSWindow.allowsAutomaticWindowTabbing = false), no second window, no toolbar, no sidebar, no inspector.

A standard titled window with the web view below the title bar, so the traffic lights never overlap the Lead tab or change its padding. Resizing changes the web viewport directly; the page is never scaled to a fixed canvas. The initial size fills most of the screen and the frame is saved with NSWindow.setFrameAutosaveName. A minimum width is set only after checking at what width the web client's own layout stops working in Safari; the shell never changes the 50/50 split, because it doesn't render it.

The window title is the page's <title> (web/index.html), bound through WKWebView.title. Appearance follows the system; the prefers-color-scheme palettes in web/style.css do the rest.

Closing the window hides it. The WKWebView and its connections stay alive until Quit, so notifications and drafts behave as they would in a browser tab left open in the background. Clicking the Dock icon shows the same window again; there is no New Window command. Closing or quitting never stops server agents or terminal shells. There is no launch-at-login, background helper or Dock badge.

Connection

First launch shows a sheet with one field, Server URL, and Connect. Accepted input is an https origin with an optional port and no path, query, fragment or credentials, because the client's routes are origin-root (/api/..., #/task/...). Invalid input is explained inline. Debug builds alone also accept http://127.0.0.1:<port> for a development byre. The origin is always entered, never guessed from the local machine.

Check. Connect fetches GET /api/admin/status through the web view's own networking, so the request carries exactly what a Safari request from this Mac would. Tailscale Serve adds the identity headers on the server side, and internal/server/server.go rejects requests without them. The app never adds identity headers, keys or passwords, and never shows a login screen. Each outcome maps to sheet text and actions:

Response Sheet text Actions
200 (sheet closes, workspace loads) —
TLS failure The server's certificate isn't trusted. Edit, Retry
DNS or connect failure Can't reach the server. Is Tailscale connected? Edit, Retry
401 (no Tailscale identity) The server didn't receive a Tailscale identity. Use the server's ts.net HTTPS address. Edit
403 (not via Tailscale Serve) The server refused the request because it didn't arrive through Tailscale Serve. Edit

Certificate validation is never relaxed.

Change Server… (app menu) reopens the sheet. Navigating away from the origin fires pagehide, which already flushes drafts (web/src/drafts.ts) and reports pane focus false (web/src/panes.ts). The shell additionally drops the old page's pending notification calls and stops delivering for it before the new origin loads. It never erases the old origin's saved data.

Startup with a saved server loads it directly. If the load fails, the same table drives an in-window error with Retry and Change Server…. The web client's own fatal page ("byre failed to start: …") covers failures after the page loads.

Initial state, feed history, request generations, event replay and reconnect timing stay in app.ts, state.ts and api.ts, including the WebKit load-event workaround in api.ts. There are still two SSE streams, global and lead, and only the global one drives the connection dot. There is no offline editing queue and no automatic replay of a request whose outcome is unknown.

Gaps between Safari and WKWebView

This table is the core of the design. Each row is a web-platform feature the client uses, where it is used, what a bare WKWebView does, and the shell's fix.

Feature used by web/ Where Bare WKWebView Shell
target="_blank" links (previews, Markdown links, images, review screenshots, exposed URLs, inbox URLs) preview.ts, markdown.ts, task.ts, reviews.ts, inbox.ts Nothing happens: without a WKUIDelegate there is no new window webView(_:createWebViewWith:for:windowFeatures:) returns nil and routes the URL: same-origin attachment and image URLs and all other http and https URLs go to NSWorkspace.shared.open (markdown.ts opens both schemes in _blank); other schemes are dropped. The https-only rule applies to the configured server, not to external links
Top-level navigation off-origin (a plain link that isn't _blank) any Navigates the workspace away decidePolicyFor navigationAction: same origin allowed; while the shell is in its teardown state (Quit or Change Server…), the about:blank load the shell itself started is also allowed; anything else cancelled and opened with NSWorkspace
#/task/<id>[/<tab>], #/project/<id> hash routing app.ts route, state.ts Works Nothing
<dialog>.showModal() (merge, discard, alerts, help) dom.ts Works Nothing. The client uses no alert, confirm or prompt, so no JS panel delegate methods are needed
navigator.clipboard.writeText (code-block Copy) dom.ts Works for user-initiated calls on a secure origin Verify in the feasibility build
Image paste and drop into composers task.ts paste and drop handlers Works Verify clipboardData.files for screenshots from the macOS clipboard and for Finder drops. The client has no file picker, so no open-panel delegate is needed
EventSource (global and lead streams) api.ts subscribe Works Nothing; see Lifecycle for throttling
Terminal WebSocket with Origin check terminal.ts Works; Origin is the server's own origin Nothing
Downloads None: the client has no download links; attachments open in _blank — Nothing. Attachments open in the default browser, which handles any download
Notification constructor, Notification.permission, requestPermission(), onclick notifications.ts, settings.ts Not available to a plain WKWebView app Polyfill below
window.focus() in a notification click notifications.ts show No-op The polyfill brings the window forward before calling onclick
navigator.serviceWorker unregister loop app.ts start Works or absent; either path is harmless Nothing
Keyboard shortcuts on ⌘ or Ctrl switcher.ts bindShortcuts Delivered to the page, unless a menu key equivalent swallows them Menu design below
Text selection across transcript messages, IME composition task.ts, inbox.ts Works Verify

Notification polyfill

A WKUserScript injected at document start, main frame only, into the configured origin defines window.Notification with the surface web/ uses and nothing more. web/src/notifications.ts keeps deciding which events notify, using the user's preferences, and formats title and body exactly as it does in a browser.

  • Notification.permission: read synchronously from a value the shell injects in the same user script, from UNUserNotificationCenter.getNotificationSettings, mapped authorized → "granted", denied → "denied", notDetermined → "default". The shell updates the value with evaluateJavaScript when the app becomes active, so a change made in System Settings shows the next time Settings renders.
  • Notification.requestPermission(): posts to a WKScriptMessageHandlerWithReply named byreNotify, which calls requestAuthorization(options: [.alert, .sound]) and replies with the mapped string. Permission is only ever requested from the Settings action, as in the browser.
  • new Notification(title, { body, tag }): posts {title, body, tag} to the handler, which delivers a UNNotificationRequest with identifier = tag. Posting the same tag again replaces the banner, matching the browser's tag behaviour.
  • onclick and close(): the polyfill keeps a map from tag to instance. userNotificationCenter(_:didReceive:) shows and focuses the window, then calls evaluateJavaScript to invoke that instance's onclick. The client's handler runs focusTask(taskId) itself, so the click lands exactly where it does in a browser. close() removes the delivered notification.

The handler accepts messages only when message.frameInfo.isMainFrame is true and message.frameInfo.securityOrigin matches the configured origin, and only those operations, with string fields bounded in length (the client already truncates bodies; see notifications.ts). It exposes nothing else of the Mac: no filesystem, process or task-mutation access beyond the page's existing API. An isolated WKContentWorld may hold the host plumbing, but isolation alone doesn't authenticate a message; the frame and origin checks do.

Worked example, from notifyInbox for a question on a task titled "Fix login redirect":

web/ calls:   new Notification("byre · Fix login redirect", { body: "Which branch should the fix target?", tag: "<inbox item id>" })
polyfill:     postMessage byreNotify {op: "show", title: "byre · Fix login redirect", body: "Which branch should the fix target?", tag: "<inbox item id>"}
shell:        UNNotificationRequest(identifier: "<inbox item id>", content.title/body as given)
user clicks:  window shown → Notification instance for "<inbox item id>" .onclick() → focusTask(task id)

The browser fires notifications only for events on a live stream: the global stream starts at the bootstrap last_event_seq (app.ts main) and resumes from the last seen seq after a reconnect, so history never floods. The app inherits this unchanged and needs no dedupe store of its own.

A banner still in Notification Center after the app quits and relaunches has no live instance to call. Clicking it shows the window and does nothing else. It never navigates a different server using a task id from another origin. A browser and the app open on the same server both notify, as two browsers would. Nothing is delivered while the app isn't running.

If a future WebKit exposes its own Notification to WKWebView, the shell prefers it only after checking that its permission and click behaviour match the above.

Menus and keyboard

The web client binds ⌘K, ⌘P, ⌘L, ⌘S and ⌘/ (each also with Ctrl), ignored while a <dialog> is open (switcher.ts bindShortcuts); Escape closes the open dialog (app.ts); Enter and Shift-Enter with IME protection submit in composers (task.ts, inbox.ts). The terminal stops propagation of Ctrl combinations so Ctrl-K, Ctrl-L and Ctrl-P reach the shell (terminal.ts).

Native menu key equivalents are matched before the web view sees a keystroke. So the menu must not claim those keys, or it would bypass the page's dialog guard and the terminal's Ctrl passthrough. Each byre command is listed in a Go menu with its shortcut shown, implemented by dispatching the same keydown the page already handles:

document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }))

The menu item's shortcut is display-only (an attributed title, no keyEquivalent). Typed shortcuts go straight to the page and behave exactly as in Safari; clicked menu items run the identical handler with its identical guards.

Worked example, the user is in the merge dialog and presses ⌘K:

Safari App
Typed ⌘K bindShortcuts sees dialog[open], does nothing Same code, same result (no menu key equivalent intercepts it)
Go ▸ Switch Task clicked — Synthetic ⌘K keydown → same guard → nothing. The item is not disabled; matching the page's own no-op is the copy

Menus:

  • byre: About, Change Server…, Hide, Hide Others, Quit. No Settings… item and no ⌘,: the web client has no Settings shortcut, and its Settings tab has no route to reach from outside (panes.ts holds the left tab in memory).
  • Edit: Undo, Redo, Cut, Copy, Paste, Select All, with standard key equivalents. They reach the focused text field or the terminal's xterm helper textarea as in Safari. No Find: Safari's find-in-page isn't part of the client.
  • Go: Switch Task (⌘K), Project (⌘P), Lead (⌘L), Merge (⌘S), Keyboard Help (⌘/), Back and Forward through the hash-route history (⌘[ and ⌘] as real key equivalents, since the page binds neither).
  • View: Reload (⌘R, a real equivalent; the page doesn't bind it), Actual Size, Zoom In and Zoom Out via pageZoom, Enter Full Screen.
  • Window: Minimize, Zoom, Bring All to Front.
  • Help: Keyboard Help (same as Go ▸ Keyboard Help).

No File menu: there's nothing to open, save or print, and the standard template's Save (⌘S) and Print (⌘P) would steal Merge and Project.

Lifecycle

Event Page sees Existing web behaviour that handles it Shell adds
App deactivates or window loses key blur panes.ts reports pane focus false; drafts flush on textarea blur Nothing
App reactivates focus panes.ts re-reports from the real document.activeElement Refreshes the injected notification permission
Window closed (hidden) blur, visibilitychange → hidden Same as above Keeps the web view in the hidden window so it isn't torn down
Window reopened visibilitychange → visible api.ts onVisible runs the stale check and reconnects if the stream is quiet Nothing
Sleep and wake Stream drops; online and visibilitychange on wake api.ts reconnects from the last seq Nothing
Quit pagehide Drafts flush; pane focus false with keepalive Tears the page down before exiting (below), because closing the window only hides it and never fires pagehide
Web content process crash — — webViewWebContentProcessDidTerminate reloads the same URL once. A second crash shows an overlay with Reload and Open in Browser instead of reloading in a loop. Drafts survive up to their last flush, as with a crashed tab; pasted image bytes and the last unflushed keystrokes don't
Server restart Stream error api.ts retries Nothing

Quit sequence. applicationShouldTerminate returns .terminateLater, the shell enters its teardown state, and it navigates the web view to about:blank. The navigation policy allows that one load only in that state, and only when the shell started it; a page-initiated about:blank is still cancelled. Leaving the page runs the existing pagehide handlers in web/src/drafts.ts and web/src/panes.ts: drafts flush to localStorage and the pane-focus request goes out with keepalive. When webView(_:didFinish:) reports about:blank loaded, those handlers have run, and the shell calls reply(toApplicationShouldTerminate: true). If the navigation hasn't finished within a short bound, the shell terminates anyway, so a hung page never blocks Quit. Change Server… uses the same teardown: once about:blank has loaded, the shell saves the new origin as the configured one, leaves the teardown state and loads the new origin, which the policy then allows as same-origin.

Risks the browser doesn't share, measured in the feasibility build rather than assumed:

  • Timer throttling while hidden. WebKit throttles timers in hidden pages, and App Nap throttles the process. The stale check in api.ts (check, threshold staleAfterMs) can then run late, so a silently dead stream may go unnoticed longer while the window is closed. Events on a live EventSource still arrive, so notifications keep working. Safari behaves the same way for a background tab. If the app proves worse than Safari, ProcessInfo.beginActivity(options: .userInitiatedAllowingIdleSystemSleep) while connected is the candidate fix.
  • Pane-focus reporting while hidden. It matches the browser only if blur fires when the app deactivates and when the window is occluded. The right pane must never be reported focused while another app is active.
  • Shutdown reporting is best effort. Even with the quit sequence, the keepalive pane-focus request may not reach the server before the process exits, and nothing is sent on a crash or sleep. The server's existing stale-focus handling covers this, as it does for a browser.

A browser and the app used side by side share focus and read markers through the server, exactly like two browser tabs, including the existing race where one user has a single focus pair across clients. Fixing that race is a server design of its own.

Local state

State Lives in
Tasks, sessions, inbox, reviews, snapshots, workflows, focus (task, tab, tab order), read markers, notification categories Server, as for the browser
Drafts, visit order localStorage of the configured origin in the app's default persistent WKWebsiteDataStore, with the web client's keys and formats unchanged. Separate from Safari's, and per origin, so a different hostname or port is a different draft namespace
Left tab, file position, disclosure and scroll state Page memory, lost on reload, as in the browser
Server URL, window frame UserDefaults
Notification authorization macOS (System Settings ▸ Notifications ▸ byre)

The app never clears website data and never clears unsent text because a request failed.

Repository layout

  • mac/Byre.xcodeproj and mac/Byre/: the AppKit target, with the window, connection sheet, navigation and UI delegates, menus, lifecycle handling, the notification handler and the polyfill script as a resource.
  • The app icon comes from the favicon artwork (web/favicon-tile.svg), with the conversion reproducible.
  • Swift and the system frameworks only; no third-party dependencies, no Swift API generator, no copied fonts or palette, no Markdown port, no SwiftTerm.

All byre feature logic stays in web/. A change to web/ reaches the app on its next page load, with the same update lifecycle as an open browser tab. AGENTS.md's checks (go vet ./..., go generate ./web) stay as they are, with an Xcode build added for changes under mac/. No tests are added. Installing the app and deploying the byre service are separate actions.

Packaging, signing, notarization, the minimum macOS version and update delivery are decided at implementation. Notification authorization needs a stable bundle identifier and signing, and whether development signing is enough is a feasibility-build result. Nothing depends on the App Store, APNs or a background helper.

Feasibility build

Before anything else, a throwaway shell with just the web view, the UI and navigation delegates and the notification polyfill, pointed at a development byre (never the production service), checked against Safari on the same Mac and server:

  1. Load, Tailscale identity, both SSE streams, terminal attach and typing, including Ctrl-C and Ctrl-K into the shell.
  2. Typed ⌘K, ⌘P, ⌘L, ⌘S and ⌘/ with and without a dialog open, and the matching Go menu items.
  3. Screenshot paste (⌘⇧⌃4 then ⌘V) and Finder drag into a composer; IME composition and Enter.
  4. Code-block Copy; text selection across transcript messages.
  5. Every _blank link type: preview, Markdown link, transcript image, review screenshot, inbox URL, exposed-port link.
  6. Notification permission flow, a question notification, a turn summary notification, and clicking each.
  7. blur on app switch and on window close (watch PUT /api/me/pane-focus in the server log).
  8. The window closed for an hour with the Mac awake, then a question arrives: does the banner appear, and how late?
  9. Sleep and wake. Quit with a nonblank draft and a focused right pane, with the window open and again with it closed: check that the about:blank teardown runs, the pane-focus request reaches the server log, and the draft is back after relaunch.
  10. An http:// link in a transcript opens in the default browser.
  11. Kill the web content process; check the single reload and the overlay on a second kill.

Record the macOS and SDK versions and the signing used. Any failure is fixed in the shell. A failure that can only be fixed in web/ is reported to the owner as a decision.

Acceptance

Manual, side by side with Safari at the same window size and appearance, same server, using disposable tasks for anything that mutates (never merging or discarding real work). Pane focus is shared through the server, so reset it between comparisons so one client doesn't change the other's result.

The comparison follows the web client's own surfaces:

  • Lead and Status, Settings (notification preferences, lead notes, deletion confirmation).
  • The switcher: filtering, project-prefix: title create, workflow second step.
  • Project: nested tasks, TODO Start and Discuss.
  • Transcript: grouped tools, thinking, subagents, held turns, pending sends, streaming, compaction, large histories and sticky scrolling.
  • Composer: model rules, attachment limits and upload failure, interrupt, drafts across Quit and relaunch.
  • Changes: Since main, This turn, pinned snapshots.
  • Files, Terminal (Ctrl keys, shell exit, New shell), Reviews (Fix, Ignore), Workflow.
  • Inbox: every choice type, recommendation markers, image grids, Other, 409 on a concurrent answer.
  • Merge and discard dialogs, forks, the Git identity banner.
  • Light and dark, narrow and large windows, Unicode.
  • Invalid origin, 401 and 403, server restart, content-process recovery, two-client focus.

Because the code is identical, each check is "looks and behaves the same", and any difference is a shell bug. Keep a short pass/fail record, with screenshots for visible differences.

Not in version one

  • A Settings… menu item and ⌘, (needs a Settings shortcut or route in web/).
  • Device-neutral notification wording in Settings (a web/ change).
  • A byre: URL scheme or universal links.
  • Notifications after Quit (needs a server push path, which byre doesn't have).
  • Several servers or several windows.
  • Automatic updates and offline mode.
  • Any native re-rendering of the workspace, which would take the Acceptance list as its contract.