Push notifications !117

merged merged by cmc on 2026-09-21 00:22 UTC · krz/gitbay-ios:push into main

Discussion

cmc

Closes krz/gitbay#89 for the client. The app registers this device against every signed-in account, receives alerts, and routes a tap to the issue, merge request or build it names.

Depends on the server side, already merged: krz/gitbay!449 (the payload names the account), !450 (device add returns the id, aps.badge), !451 (Parity rows). Spec docs/superpowers/specs/2026-09-20-push-notifications-design.md, plan docs/superpowers/plans/2026-09-20-push-notifications.md.

What landed

PushPayload decodes an alert. PushTarget resolves it against the signed-in accounts — matching on instance and username, the pair Account.id is built from, because one APNs token is one install and path alone is ambiguous when two instances hold the same owner/name. Both sides of that comparison run through GitbayInstance's constructor, so a trailing slash or an explicit :443 in an operator's site_url still matches.

PushRegistrar registers the token against every account, using transient clients SessionStore.client(for:) vends. Registering only the active account would silently stop notifications from the others the moment you switched. It runs on token arrival, on sign-in, and on launch; device add upserts, so a repeat is free. Sign-out deregisters through a willRemoveAccount hook remove awaits before discarding the token — the call needs that credential.

A tap routes to the item. When it belongs to another account the app switches first, and the rebuild that causes is what delivers the route: PushRouter is owned above ContentView precisely so a pending target survives it.

The notifications screen carries the toggle and this account's devices. iOS permission is requested when the toggle is turned on, not on arrival — iOS asks once, and a prompt sprung by navigation earns a refusal nothing in the app can undo. The denied state says so and offers Settings.

Worth a reviewer's attention

GitbayInstance.init now folds an explicit default port. That type backs isOwn, the redirect guard and the token-host check, so the change was reviewed against those specifically: host equality is untouched, the fold is an exact match on the scheme's own default, and it fails closed — a redirect carrying :443 is refused, never accepted.

SessionStore.remove is now async. It has three callers, not the one the plan assumed; expire and activate deliberately bypass the hook, for different reasons documented at each site. expire runs only after a 401, so a deregistration carrying that token would fail identically.

The cold-launch tap — the primary path — buffers in the delegate. APNs can deliver before SwiftUI's root .task has wired session and router, so didReceive parks the raw userInfo and the drain resolves it once wiring completes.

Known residuals

  • PushRouter.swift:5 and the spec at line 108 both still say "ContentView is .id(account.id)". It is not — the .id is on the TabView inside it. The router's placement is still right, for a different reason. Two lines of prose.
  • didAddAccount is awaited inside signIn, so the sign-in spinner now also covers push registration.
  • The path reset also forces the Dashboard tab on an account switch.
  • Granting permission in iOS Settings without opening the notifications screen registers no token until the next launch.
  • The icon badge is never cleared when the inbox is read in-app. The spec is silent; it wants a decision rather than a patch.

Tests

461 passing. The payload decode, account resolution, registration and deregistration, the removal hook's ordering, and the instance normalisation all have unit tests. The delegate callbacks and the navigation drain do not — they need a running app and a real APNs round trip, which the Simulator cannot provide.

Note the suite is flaky: SearchViewModelTests, MRListFilterWiringTests and RepoSearchTests are debounce-timing tests that intermittently fail and pass on a re-run. One of three runs of this branch failed on them alone.

Not yet verified

No real push has ever been delivered. Nothing here proves the feature works end to end, and the device test has a trap: a debug build carries a sandbox token while gitbay.org is configured production, so the push fails BadDeviceToken and the server reaps the device row — that is the server behaving correctly. Either point the app at a sandbox-configured instance or archive to TestFlight.

The App Store resubmission, the privacy nutrition label declaring the device token under Identifiers, and the aps-environment production export remain to do.