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:5and the spec at line 108 both still say "ContentViewis.id(account.id)". It is not — the.idis on theTabViewinside it. The router's placement is still right, for a different reason. Two lines of prose.didAddAccountis awaited insidesignIn, 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.