Add native macOS client (PostaMac) + read-watermark wiring #1

Merged
arne merged 4 commits from mac-app into main 2026-05-11 14:05:58 +02:00
Owner

Summary

  • New PostaMac target in Posta.xcodeproj — native SwiftUI on macOS 26, two-pane NavigationSplitView, manual URL + bearer-token pairing, own SwiftData store + own keychain entry per the server's per-device token model. End-to-end: pair → contacts list → thread reading → compose/send → SSE-driven live updates.
  • Client-side wiring for the per-peer read watermark (POST /api/v1/contacts/read + read-watermark-changed SSE event) introduced by the server change. ContactDTO and Contact carry the new field; SyncReconciler handles the new event monotonically; iOS ThreadView and Mac ThreadView both call markRead on view appear and on incoming messages.
  • Cross-cutting: implicit https:// on the pairing URL (no need to type the scheme); KeychainStore opts into the data-protection keychain only when an accessGroup is supplied (Mac falls back to the legacy keychain until team-signed builds land).
  • All Swift package test suites green (PostaCore 100, Persistence 23, Sync 35, Testing 27); iOS app and Mac app both build clean.

Notes

  • UI for unread badges (per-contact + Mac dock) is not in this PR — the data path is in place, badge work is a follow-up.
  • Mac uses legacy keychain → expect ACL prompts on each rebuild during dev (Always Allow per build to silence). Switches to data-protection keychain once a development team is enrolled and keychain-access-groups becomes signable.
  • PostaFeatures stays iOS-only; Mac UI lives inside the app target by design.

Test plan

  • Open thread on Mac → contact's unread count on iOS drops via SSE
  • iOS marks read → Mac's stored watermark advances via SSE
  • Pair + sign-out + re-pair both Mac and iOS cleanly
  • Compose + send from Mac → message arrives on iOS via SSE
  • Verify pairing accepts bare hostname (no https:// prefix)
## Summary - New `PostaMac` target in `Posta.xcodeproj` — native SwiftUI on macOS 26, two-pane `NavigationSplitView`, manual URL + bearer-token pairing, own SwiftData store + own keychain entry per the server's per-device token model. End-to-end: pair → contacts list → thread reading → compose/send → SSE-driven live updates. - Client-side wiring for the per-peer read watermark (`POST /api/v1/contacts/read` + `read-watermark-changed` SSE event) introduced by the server change. `ContactDTO` and `Contact` carry the new field; `SyncReconciler` handles the new event monotonically; iOS `ThreadView` and Mac `ThreadView` both call `markRead` on view appear and on incoming messages. - Cross-cutting: implicit `https://` on the pairing URL (no need to type the scheme); `KeychainStore` opts into the data-protection keychain only when an `accessGroup` is supplied (Mac falls back to the legacy keychain until team-signed builds land). - All Swift package test suites green (PostaCore 100, Persistence 23, Sync 35, Testing 27); iOS app and Mac app both build clean. ## Notes - UI for unread badges (per-contact + Mac dock) is **not** in this PR — the data path is in place, badge work is a follow-up. - Mac uses legacy keychain → expect ACL prompts on each rebuild during dev (Always Allow per build to silence). Switches to data-protection keychain once a development team is enrolled and `keychain-access-groups` becomes signable. - PostaFeatures stays iOS-only; Mac UI lives inside the app target by design. ## Test plan - [ ] Open thread on Mac → contact's unread count on iOS drops via SSE - [ ] iOS marks read → Mac's stored watermark advances via SSE - [ ] Pair + sign-out + re-pair both Mac and iOS cleanly - [ ] Compose + send from Mac → message arrives on iOS via SSE - [ ] Verify pairing accepts bare hostname (no `https://` prefix)
Native macOS app sharing PostaCore/PostaPersistence/PostaSync. Two-pane
NavigationSplitView (contacts sidebar + thread reading pane). Manual URL +
bearer-token pairing (no QR). Sign Out clears Account + Keychain.

Cross-cutting changes that affect iOS too:
- Implicit https:// on pair: bare hostnames like `posta.example.com` are
  normalized before parsing/validating, so the user doesn't need to type
  the scheme.
- KeychainStore now sets kSecUseDataProtectionKeychain, switching macOS
  off the legacy login keychain (which prompted on every re-signed dev
  build) onto the iOS-style data-protection keychain. No-op on iOS.
PairedSessionView now constructs SyncReconciler + SyncService and runs
the SSE loop on view appear, stopping on disappear. Mac picks up live
contact-changed / inbound / outbound-state events the same way iOS does.

KeychainStore only opts into kSecUseDataProtectionKeychain when an
accessGroup is supplied — Mac sandboxed apps need a matching
keychain-access-groups entitlement (team-signed) to use it, and ad-hoc
signing without a team returns errSecMissingEntitlement (-34018).
Mac falls back to the legacy login keychain until team enrollment
lands; iOS keeps using the data-protection keychain (always the only
keychain there). PairingView surfaces the raw error so OSStatus codes
are diagnosable instead of "error 0".

PostaMac.entitlements carries the sandbox + network capabilities now
that CODE_SIGN_ENTITLEMENTS is set (added by the Keychain Sharing
capability toggle, which also flipped CODE_SIGN_IDENTITY to require a
team — reverted here).
Outbox is constructed alongside SyncService in PairedSessionView and
torn down on disappear / sign-out. ThreadView grows a Composer at the
bottom: TextField (Return or ⌘Return to send) + arrow button.

sendDraft inserts a queued-locally Message row with a fresh
idempotencyKey, saves, then calls outbox.flush(account:peer:) — the
Outbox actor handles the POST /messages with the idempotency-key
header, status transitions, and retries. MessageBubble grows a small
status label (queued → sending… → delivered, or failed) for outbound
rows in flight.
Server change is merged + deployed: GET /api/v1/contacts now returns
lastReadRowId, POST /api/v1/contacts/read advances the watermark
monotonically, and a read-watermark-changed SSE event fans out to all
paired devices.

Client side:
- ContactDTO grows lastReadRowId (Int, decodeIfPresent → 0 default for
  permissive decoding against pre-change servers).
- APIClient gains markRead(peer:rowId:) → Bool (200=advanced,
  204=no-op). URLSessionAPIClient + RetryingAPIClient + FakeAPIClient
  implement it.
- Contact model grows lastReadRowId: Int? — optional for safe SwiftData
  lightweight migration on existing iOS stores.
- SyncReconciler handles read-watermark-changed (monotonic advance);
  applyContactChanged also writes the watermark from the DTO so
  startup-time sync converges.
- ConversationListView (iOS) + ContactsSidebar (Mac) carry the
  watermark through their syncContacts paths.
- ThreadView (iOS) + ThreadView (Mac) call markRead on view appear and
  on messages.last?.serverRowId change, advancing to the max visible
  serverRowId. Best-effort; errors swallowed.

UI badges (per-contact unread + Mac dock badge) come next; the data
path is in place for any client to compute count(messages where peer=X
AND direction="in" AND serverRowId > (lastReadRowId ?? 0)).
arne merged commit 587e58f689 into main 2026-05-11 14:05:58 +02:00
arne deleted branch mac-app 2026-05-11 14:05:58 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/ios!1
No description provided.