macOS client store and API

This commit is contained in:
Ian Gulliver
2026-10-16 22:46:54 -07:00
parent b28968a0bc
commit a9bec7a7ea
22 changed files with 1508 additions and 251 deletions
+13 -7
View File
@@ -1,6 +1,6 @@
# byre for macOS
This is a design. What exists so far is the spike under `mac/`: a harness window with one tab per row of "Gaps in SwiftUI" (composer, transcript, terminal, font, SVG), not the client. The work to build the client is under "M8 — macOS client" in `TODO.md`.
This is a design. What exists so far under `mac/` is the layer beneath the views: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, and the generated palette. Once started, the window shows the spike harness from the "Gaps in SwiftUI" checks (composer, transcript, terminal, font, SVG tabs), now driven by `Store`: its task picker lists the active tasks and sets focus, and its transcript tab reads the selected task's `Feed`. The panes replace it. The rest of the work is under "M8 — macOS client" in `TODO.md`.
## Decision
@@ -28,8 +28,8 @@ Values the web client defines are generated into Swift, never copied by hand, so
| Value | Source | Swift |
| --- | --- | --- |
| API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin from a symlink to the spec |
| Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` writes `Palette.swift`: one dynamic `Color` per property, resolving light or dark with the window's appearance |
| API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin (`mac/Sources/Byre/openapi-generator-config.yaml`, idiomatic naming: `display_name` is `displayName`) from the symlink `mac/Sources/Byre/openapi.yaml` to the spec. The output lands in `mac/.build/plugins/outputs/`, not in git |
| Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` (`mac/palette`) writes `Palette.swift`: one dynamic `Color` per hex property, `--accent-tint` as `Palette.accentTint`, resolving light or dark with the window's appearance. Properties that aren't a hex colour (`--task`, `--shadow`, `--font`) are left out. The file is committed, so `swift build` needs no Go; a property missing from either block fails the generator |
| Font | `web/fonts/` | The build script copies the woff2 files into the bundle; the app loads each with `CTFontManagerCreateFontDescriptorsFromURL` (see "Gaps in SwiftUI") |
| App icon | `web/favicon-tile.svg` | The build script renders it to an `.icns` with `rsvg-convert` and `iconutil` |
@@ -65,11 +65,17 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam
`Store` holds what `state` in `web/src/state.ts` holds (me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads, connection) and ports its functions one for one: `handleEvent` with the same `switch` on event kinds, `taskState` with the same precedence and labels, `setFocus` with the optimistic apply and the latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`.
`Feed` ports the class of the same name: 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.
`Feed` ports the class of the same name: 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`.
`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. It handles `id`, `event: ping`, `event: ready` and `data`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold and retry delay, when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. As in the web client there are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, `after=0`), and only the global one drives the connection dot.
The web client's topics become Observation: views read `Store` and `Feed` properties and SwiftUI re-renders them on change. The topics that carry a value are `Listeners` instead, whose `add` returns the function that removes the listener: `Store.events` (every event, the `event` topic), `Feed.appended` (each batch of new stored events, `transcript` and `lead.transcript`) and `Feed.streamed` (each `agent.delta`, `stream` and `lead.stream`). Applying focus selects its task, which is what the web client's hash route does.
Startup follows `main` in `app.ts`: `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>`, as the web page does.
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, as `request` in `api.ts` does; 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`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold (40 s, checked every 5 s) and 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. As in the web client 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 follows `main` in `app.ts`: `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>`, as the web page does; Change Server… leaves that screen. Drafts are not loaded yet; they come with the composer. Likewise `handleEvent` doesn't call `notifyInbox` or `notifyTurn` until `Notifications.swift` exists.
The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
There is no URL hash. The web client mirrors focus into `#/task/<id>`; the app keeps focus in `Store` only, and in-app task links call `focusTask` directly. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do.
@@ -144,7 +150,7 @@ The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has t
## 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`). Certificate validation is never relaxed. The origin is kept in `UserDefaults`. Change Server… flushes drafts, reports pane focus false, drops the store and streams, and starts over against the new origin.
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 in the `byre.server` default and startup runs; later launches start against it directly. 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. Drafts and pane focus don't exist yet, so for now it only drops the store and streams.
## Gaps in SwiftUI
+1 -1
View File
@@ -13,7 +13,7 @@
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows").
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `favicon.svg` (a lead dot in the accent colour with spokes to five task-coloured dots; its colours are saturated versions of the `web/style.css` accent and task hues, not copies of the palette, since the palette's pastels blur together at 16px; they switch with the light/dark scheme), `favicon.png` (the same icon in its dark colours on an opaque dark `--surface` square at 180×180, rendered from `web/favicon-tile.svg`, which is not embedded; opaque because Orion puts a transparent icon on a white tile; linked as a second icon and as the `apple-touch-icon`, which Orion prefers over the SVG for its tab icon; Orion's icon store (`~/Library/Application Support/Orion/Defaults/website_icons`, kept apart from its cache) keys icons by hostname without the port, so another server on the same hostname can supply byre's tab icon; `favicon-tile.svg` repeats the shapes and dark colours of `favicon.svg`, so change both together, then regenerate the PNG with `rsvg-convert --width 180 --height 180 --output web/favicon.png web/favicon-tile.svg`), `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
- There is no macOS client yet; `docs/macos.md` is its design. `mac/` holds the spike that checks the design's SwiftUI gaps: `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into it (the same `tailscale serve` URL a browser uses).
- `mac/` is the macOS client in progress; `docs/macos.md` is its design and says what exists. `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into its connection screen (the same `tailscale serve` URL a browser uses). Its API client is generated from `api/openapi.yaml` at build time, and `go generate ./mac` regenerates the committed `mac/Sources/Byre/Palette.swift` from the custom properties in `web/style.css`, so run it after changing the palette.
## Data