Compass Desktop (macOS)
Status: Shipped on main (native cutover, October 2026). Release tags,
founder acceptance, and open bugs stay on the tracking issue
#4215.
Board:
Compass Desktop,
milestone
Desktop native v1.
This doc holds the locked decisions and reference material only.
Owner: Tyler (credentials, production deploys, QA). Agents build the rest through the agent loop.
Goal
The best native calendar app on the Mac: a Swift app with a native window, sidebar, header, time grid, event form, command palette, and shortcuts legend, talking to the Compass backend directly over HTTP and server-sent events. Plus the things only a Mac app can do: menu bar agenda, Dock badge, notifications that fire with the window closed, a global quick-add hotkey, native menus, deep links, launch at login, and silent auto-updates.
macOS only. Windows and Linux are out of scope and may never ship.
History
Desktop v1 (milestone 44, September 2026) shipped a Swift shell that hosted the web app in a WKWebView. It proved the native services (notifications, agenda, hotkey, deep links, Sparkle, signing, release pipeline) and it proved the shell approach was not good enough: the web header fought the traffic lights, and every chrome detail was a web page pretending to be a window. The shell never launched publicly and was deleted once the native app on this milestone replaced it.
Decisions
Each of these is a judgment call. Tyler can veto any of them on the tracking issue. After that they are settled; the loop treats the tracking issue's Locked decisions and Deferred sections as binding.
- Fully native Swift. The WKWebView shell is replaced, not wrapped. Not Electron, not a hybrid with web sheets. The calendar surface, forms, palette, legend, settings, billing, onboarding, and Life view are all Swift. One named wart: the card-entry step of billing stays on Stripe's hosted Checkout page because Stripe has no native macOS checkout; everything around it is native.
- Keyboard-only, like the web. The mouse is inert for editing and navigation. Clicking the grid shows the shortcut hint for that intent and does nothing else. Right-click opens the event menu, the one mouse affordance the web keeps. No drag, no resize, no click-to-create.
- Minimum macOS 14 Sonoma. Universal binary. Swift 6 with strict concurrency. The Observation framework and modern SwiftUI are the floor.
- One app,
apps/calendar-macos, three local SwiftPM packages.CompassKit(pure Swift, Foundation only: generated contracts, keyboard engine, calendar math, deck layout, recurrence, HTML fragment, SSE parser),CompassData(URLSession client, Keychain session, bearer interceptor, SSE stream, GRDB cache, repositories,@Observablestores), andCompassUI(SwiftUI chrome and forms, an AppKit time grid). The app target stays AppKit for the window, menus, status item, panels, hotkey, notifications, and deep-link routing. XcodeGenproject.yml, no checked-in.xcodeproj. - Swift never hand-copies a contract.
bun cli contracts:swiftemits Codable types from a manifest ofpackages/coreZod schemas, andbun cli desktop:exportemits the shortcut registry, theme tokens, product event names, and parity fixtures from the TypeScript implementations. Both have--checkmodes that run on Linux CI, so drift fails before a Mac build runs. Swift parity tests replay the fixtures. - Header sessions, same backend. SuperTokens accepts
Authorization: Beareronce a client signs in withst-auth-mode: header;/api/events/streamruns the sameverifySession(). The native client uses that. No new backend service, no cookie jar, no CORS origin. - OAuth through
ASWebAuthenticationSessionand the existing relay. Google refuses sign-in inside embedded web views; the system authentication session is allowed. The native app builds the desktopstatemarker, opens the provider URL, the existing web callback page redirects tocompass://auth/<provider>/callback?..., and the native app finishes the exchange withPOST /api/signinup. No new OAuth clients, no new redirect URIs. Apple keeps the web form-post path through the same session. - GRDB (SQLite) for the local cache. Generated Codable structs fit
FetchableRecorddirectly, GRDB 7 is Sendable-clean under Swift 6, FTS5 gives palette event search, and migrations are reviewable diffs. SwiftData's@Modelclasses andModelActorstory on macOS 14 are fragile for this shape of data. - Theme stays explicit.
light-beachanddark-abyssare chosen in the app, not followed from the system appearance, for parity with the web. Tokens come fromapps/calendar-web/src/index.cssvia the export. - Distribution is unchanged. Signed, notarized, universal DMG on
GitHub Releases under
macos-v*tags with a Sparkle appcast. No Mac App Store, no Homebrew cask for v1. Owner QA runs an unsigned dev channel (release-macos-dev.yml): every merge that touches the app publishes an ad-hoc signed build to the rollingmacos-devprerelease, and builds stampedCOMPASS_UPDATE_CHANNEL=devfollow that feed, so a laptop stays current without tags. It needs only the Sparkle keys, not Apple enrollment. Install one dev DMG by hand once (right-click, Open); after that it updates itself. The native app keeps this channel from its first skeleton build, so weekly founder acceptance never waits on a tag. - Every build and test runs on GitHub's macOS runners. Agents in the
loop cannot compile AppKit. Pure packages run
swift testwithout XcodeGen in a separate job;CompassUIand XCUITest go throughxcodebuild test. - Three loop partitions for Swift.
desktop(app target,project.yml, Resources, CompassUITests),desktop-kit(CompassKit, CompassData),desktop-ui(CompassUI). Issues sharing a partition never run together, so three Swift work packages can run at once.
Architecture
apps/calendar-macos/
project.yml XcodeGen; min macOS 14; three local packages; Swift 6 strict
Compass/ AppKit app: AppDelegate, window, menus, status item, panels,
notifications, hotkey, DeepLinkRouter, PostHogCapture,
ASWebAuthenticationSession host
CompassKit/ pure Swift. Generated/ (Contracts.swift, ShortcutIds.swift,
ThemeTokens.swift, ProductEvent.swift), Keyboard/, Calendar/,
Layout/, Recurrence/, HTMLFragment/, SSEParser.swift,
Resources/ (shortcuts.json, Fixtures/*.json)
CompassData/ CompassAPIClient, KeychainSessionStore, AuthInterceptor,
ServerEventStream, AppDatabase (GRDB), repositories,
Stores/ (@Observable)
CompassUI/ RootView, Sidebar, Header, TimeGridView (NSView), EventForm,
dialogs, CommandPalette, ShortcutsLegend, WhichKeyPanel,
Settings, Billing, Onboarding, BlockParty, LifeView (Canvas)
CompassKitTests/ XCTest, no AppKit
CompassUITests/ XCUITest: launch, demo-mode flows, deep link, menu dispatch
Resources/ icon, entitlements, Info.plist values
Per-surface choice
| Surface | Choice | Why |
|---|---|---|
| Window, menus, status item, Dock, panels, hotkey | AppKit | Already native from Desktop v1. |
| Sidebar, header, month picker, forms, dialogs, settings, billing, onboarding, palette, legend, which-key, chips | SwiftUI as in-window overlays | One keyboard dispatcher owns every key; no NSWindow sheets fighting first responder. |
| Time grid (week and day) | TimeGridView: NSView, layer-backed, reused EventCardView per event, hosted by NSViewRepresentable | Hundreds of positioned cards, pixel-exact overlap deck, custom focus ring, accessibility identifiers for XCUITest. |
| Description editor | NSTextView behind a controlled HTML subset (p, strong, em, a, ul, ol, li, br) | Rich text with round-trip fidelity to the web's TipTap output. |
| Life view | SwiftUI Canvas | Thousands of cells drawn once per state change. |
| Quick-add panel | NSPanel hosting SwiftUI | Reuses the existing panel controller; drops the second WKWebView. |
State
@MainActor @Observable stores mirror the web's Zustand stores one to one so
an agent ports by reading the TypeScript file: ViewStore, EventsStore
(range loads keyed like TanStack Query, optimistic create/replace/delete/rsvp
with no rollback and invalidate on settle, SSE hookup), DraftStore
(activities, nudges, quick-time), FocusStore (event registry, Tab edges,
page-jump targets), UndoStore (series undo refused), ClipboardStore,
HiddenEventsStore, AuthStore, BillingStore, ConfigStore,
SettingsStore, LevelsStore, OnboardingStore, NotificationsStore.
Keyboard
One NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged])
feeds ShortcutDispatcher in CompassKit. It holds a scope stack
(modal > form > grid > global), gates bare letters when a text field is
first responder, runs the e leader state machine (armed with a timeout,
drives the which-key panel), and a hold-modifier detector (Mod held shows
page-jump chips, hold-F shows event-jump chips). Bindings come only from the
generated shortcuts.json; a test asserts that the set of handler ids equals
the registry ids. Rules: docs/frontend/shortcut-commandments.md.
Data
GRDB tables mirror the web's query keys: event, calendar,
hidden_event, loaded_range(source, start, end, fetchedAt),
user_metadata, local_event (anonymous mode), event_fts. SSE messages
map to invalidations with the same table the web uses in
apps/calendar-web/src/sse/client/sse.client.ts. The stream honors the
server's retry: hint and the 20-minute stream lifetime.
Contract sharing
packages/core/src/desktop/swift-contracts.manifest.tslists the Zod schemas to export.bun cli contracts:swift [--check]:z.toJSONSchemathen a small emitter for the subset Compass uses (object tostruct: Codable, Hashable, Sendable; string enum toenum: String; discriminatedanyOfto an enum with a custominit(from:); branded ids toRawRepresentablestructs). Unknown constructs fail with the schema path so a new Zod feature forces an emitter update instead of a silentAny.bun cli desktop:export [--check]:shortcuts.jsonandShortcutIds.swiftfrom the registry data inpackages/core/src/shortcuts,ThemeTokens.swiftfromindex.css,ProductEvent.swiftfrom the analytics event union, and fixtures produced by running the TypeScript implementations: timed deck layout, event positions, nudges, rrule expansion and summaries, go-to-date parsing, HTML fragments, booking slots, Block Party tasks, and the demo seed.
Backend and web changes
| Area | Change |
|---|---|
| Header sessions and SSE | No code expected. One backend integration test proves sign-in with st-auth-mode: header, Bearer profile, refresh rotation, SSE with Bearer, and sign-out. |
| OAuth | Reuse the relay page. Native Sign in with Apple is deferred. |
| Stripe | returnTo: "desktop" on checkout and payment-method sessions; success and cancel URLs land on a web route that redirects to compass://billing/checkout?outcome=.... |
| PostHog | Plain HTTP capture from Swift with platform: desktop and the same distinct id after sign-in; the key and host are served by /api/config if they are web-build-time only today. |
| Cutover | Delete apps/calendar-web/src/desktop/*, the bridge contracts, desktop settings sections, and the desktop notification port. Keep the OAuth relay and the billing return page. |
Owner setup reference
The Desktop v1 secrets and credentials carry over unchanged: APPLE_TEAM_ID,
CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY_P8, APPLE_API_KEY_ID,
APPLE_API_ISSUER, SPARKLE_PRIVATE_KEY, the app icon. Two new items:
| # | What | Where | Time |
|---|---|---|---|
| 1 | Add milestone Desktop native v1 to the front of repo var AGENT_LOOP_MILESTONES, replacing Desktop v1. | Repo variables | 2 min |
| 2 | Change the board's auto-add filter from label:desktop to the new milestone, since desktop is now one of three partition labels and most work packages do not carry it. | Project settings | 2 min |
Defaults that stay: app name Compass, bundle id
com.compasscalendar.desktop, URL scheme compass://, internal builds
default to production with a hidden staging switch, releases under
macos-v* tags.
QA and acceptance
swift teston CompassKit and CompassData replays the exported parity fixtures. The Linux--checksteps fail when TypeScript changes without a re-export, so parity drift is caught before any Swift builds.- Grid rendering is asserted through a
GridLayoutSnapshotJSON dump (frames, labels, z-order) at fixed metrics, which agents can record without a Mac. Image snapshots are recorded by aworkflow_dispatchjob. - XCUITest runs in anonymous demo mode on every PR, so it needs no server. Signed-in flows use a fixture transport in CompassData.
- No scheduled staging smoke. The header-session backend test and weekly founder acceptance on a real build cover the authenticated path.
- Visual checks, Notification Center behavior, OAuth per provider, Stripe test checkout, and Sparkle updates are manual: use Desktop acceptance on a signed build and record results on the tracking issue with the build number from About.
Later, explicitly not v1
- Mouse editing of any kind: drag, resize, click-to-create, multi-select.
- iOS, iPadOS, Catalyst.
- EventKit integration for local macOS calendars (iCloud works over CalDAV).
- WidgetKit, Shortcuts.app intents, Spotlight indexing.
- Mac App Store, sandboxing, Homebrew cask.
- Multiple windows, tabs, Handoff, iCloud sync of device preferences.
- Native Sign in with Apple through
AuthenticationServices. - Bundled offline web assets. The shell is deleted, not kept as a fallback.
- An offline write queue for signed-in users (the web has none either).
- System-following appearance.
- Windows and Linux builds.
Risks
| Risk | Mitigation |
|---|---|
| SwiftUI focus and AppKit first responder fight over Enter and Escape | One dispatcher, overlays rendered in-window, a text-input gating table with tests. |
Rich text fidelity between TipTap HTML and NSAttributedString | Constrained subset with round-trip fixtures; untouched HTML outside the subset is preserved verbatim. |
| SuperTokens overrides assume cookies somewhere | The header-session test is a week-one work package. |
| Apple sign-in through the relay misbehaves | Verified in the OAuth work package before release notes rely on it. |
| macOS CI minutes with three Swift lanes | swift test split off from xcodebuild, path filtering, SwiftPM and DerivedData caches. This is a budget line Tyler owns. |
| Generator rigidity blocks a TypeScript change | By design; the emitter lives in packages/scripts so the same agent extends it in the same PR. |
| Parity drifts after cutover | The export checks stay in CI forever; a web shortcut or token change without a re-export is a red build. |