docs/superpowers/specs/2026-09-20-push-notifications-design.md

v1.14.1
gitbay-ios/docs/superpowers/specs/2026-09-20-push-notifications-design.md rendered · source · history · blame · raw

280 lines · 12720 bytes

Push notifications

Closes krz/gitbay#89 for the client. The app receives APNs alerts for activity on every account signed in on the device, and a tap opens the issue, merge request or build it names.

The server half shipped in gitbay v1.32.0 and is running on gitbay.org. Its design is docs/specs/2026-09-20-ios-push-notifications-design.md in krz/gitbay; this spec is the other half and does not restate it.

Problem

DESIGN.org records push as a known gap: "No push notifications — poll on foreground, use background refresh. Not planned; propose if the app makes the case." The server now delivers, so the app is what is left.

Background refresh was never going to close it. BGAppRefreshTask runs when the system feels like it, routinely fifteen minutes to hours after the event. A failed build is exactly the notice that is worthless late.

Decision

An AppDelegate adaptor receives the device token and notification taps. A PushRegistrar registers that token against every signed-in account. A PushRouter, owned above the account-keyed view tree, carries a tapped notification's destination across the account switch it may require. ContentView drains it and routes.

Decisions taken on the way, with the alternatives rejected:

  • Every signed-in account, not just the active one. The server keys a device row on (user, token), so one install can hold a row per account. Registering only the active account would silently stop notifications from the others the moment you switched — surprising for the feature whose whole job is reaching you when the app is closed. The cost is that the app must talk to accounts that are not active, which it never does today.
  • A tap goes to the item, not to the inbox. Opening the notifications list would need no routing machinery and cost one more tap. It is also not what a notification is for.
  • A cross-account tap switches accounts silently. Honouring the tap requires being in that account's context. A confirmation on every such tap would wear thin, and the switch is visible anyway — the tab bar resets. What is lost is unsaved state in the account being left, in practice a scroll position or an unsent ComposeSheet draft.
  • Permission is asked when you turn push on, from an explicit toggle, not on first sight of the notifications screen. iOS asks once. A prompt sprung by mere navigation earns a reflexive "Don't Allow", after which push is permanently silent with nothing in the app explaining why. A toggle the user deliberately taps both raises the grant rate and gives the denied state somewhere to live.
  • The badge comes from the server. Badging from dashboard.unread on launch would need no server change and be close to useless: the count would refresh only when the app opens, reading zero at exactly the moment it should be climbing.
  • No background modes. These are alerts. Silent pushes, background refresh and Live Activities are all out of scope and none of them is needed to close the gap.

Server prerequisite

Two additions to krz/gitbay, landing before any Swift is written. Both fold into the unreleased v1.32.1.

notifications device add returns the device id. It currently returns {"status": "registered"}, so an app that wants to deregister must call device list and match its own token against the truncated display value. That works — eight hex characters is four billion — but it is identity by rendered string. AddPushDevice already returns the id; the command just has to emit it.

The payload carries aps.badge. DuePush gains a correlated subquery counting the recipient's unread inbox rows, the same way it now carries the username:

(SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)

Counting at send rather than at enqueue costs nothing extra and is fresher: an inbox cleared in the seconds before delivery is reflected. No migration.

The payload's instance and user fields landed already (gitbay 66304b6) and are deployed.

Components

Each is small, has one job, and is testable without a view tree.

Unit Does Depends on
PushPayload Decodes APNs userInfo into instance, user, path, badge nothing
PushTarget (accountID, route); resolves a payload against known accounts SessionStore.accounts
PushRouter @Observable, holds pending: PushTarget? nothing
PushRegistrar Holds the APNs token; registers and deregisters per account SessionStore.client(for:)
AppDelegate Receives the token and taps; UNUserNotificationCenterDelegate the four above

PushTarget builds its route from the payload's path using the parsing InboxNotification.destination already does — four slash-separated segments, the last an integer, mapping to IssueRoute, MRRoute or BuildRoute. That parsing moves to a free function both call rather than being duplicated; a repository root or an unknown kind yields no route, and such a tap opens the app without navigating.

PushRouter is owned by gitbayApp beside SessionStore. That placement is the design: the TabView inside ContentView carries .id(account.id), so a cross-account tap tears that subtree down and rebuilds it. A target held inside would go with it; held above, the rebuilt tree drains it.

Note the .id is on the TabView, not on ContentView itself — ContentView's own @State survives an account switch, which is why the Dashboard path has to be reset explicitly rather than by identity.

Registration

PushRegistrar needs a client for accounts that are not active. SessionStore has the parts, but store and makeClient are both private, so it grows one method:

func client(for account: Account) -> GitbayClient?

It returns nil for an account with no stored token. The active-client model is untouched: this vends a transient client and does not change current or client.

It registers on three events:

  • the APNs device token arriving, which is the only time the token is known;
  • an account signing in, which adds one that has no row yet;
  • launch, when permission is already granted. Apple rotates device tokens, so re-registering on launch is ordinary practice, and device add upserts on token, so a repeat costs one call and changes nothing.

Each registration stores the returned device id against the account.

Deregistration has an ordering constraint: notifications device remove <id> needs that account's token, which SessionStore.remove discards. There is exactly one sign-out call site today (RepoListView.swift:283), so a caller could do it first — but a future second call site would silently skip it, and the failure is invisible: the server keeps pushing for an account no longer signed in, and those taps resolve to nothing.

So SessionStore gains a hook rather than the obligation:

var willRemoveAccount: ((Account) async -> Void)?

remove awaits it before discarding anything, which makes remove async; its one call site wraps the call in a Task. Awaiting rather than firing and forgetting is the point — remove's first act is store.deleteToken(for:), and a concurrent hook would race the credential it needs out from under itself.

gitbayApp points the hook at the registrar's deregistration at construction, in the same place the two objects are already wired together. SessionStore stays ignorant of push — it knows only that something wants to run first.

A deregistration that fails still removes the account. Blocking sign-out on a network call to the instance you are leaving would be the wrong trade; the stale device row is recoverable from the web settings page, and the account is gone either way.

A registration that fails is not retried and not surfaced. Push is a side channel; the account still works, and the next launch registers again.

Routing

A tap decodes the payload and resolves it against SessionStore.accounts, matching instance against Account.instance and user against Account.username — together exactly what Account.id is built from.

  • Same account. The subtree is live. Select the Dashboard tab, append the route to its path, clear pending.
  • Different account. session.activate() first. That changes account.id and rebuilds the TabView. pending survives, because PushRouter sits above it. The new subtree drains it on appear.
  • No matching account. You signed out of it. Nothing sensible to show, so the app opens without navigating. With deregistration working this is transient rather than a standing condition.

The drain is the mechanism, not a workaround: a rebuilt tree appearing with a target still pending is the signal to route, and the same code path serves both cases.

Only the Dashboard tab gains a bound NavigationPath. It is first, and it is where your own work already lives. The other four stacks are unchanged. TabView gains a selection binding. That is the entire change to ContentView.

A notification arriving while the app is in the foreground shows a banner with sound rather than being suppressed — you may be reading a different repository when one lands.

The notifications screen

NotificationsView gains a section above the list with one Push notifications toggle, representing two independent states:

State Toggle Behaviour
Not yet asked off Turning on asks iOS; on approval registers the device and sets notifications settings push on
Granted, server on on Turning off calls notifications settings push off; the OS permission is left alone
Denied in iOS off, disabled One line saying notifications are off in Settings, and a button opening them

The denied row is what the explicit-toggle decision buys. Without it a single reflexive "Don't Allow" leaves push silent forever with nothing accounting for it.

Below the toggle, this account's registered devices with a remove action — the same list the web settings page shows, tokens truncated the same way. Removal dispatches notifications device remove.

There is no add-a-device control, on any surface. Only the app can mint an APNs token, and it does so itself.

Project and submission

  • The Push Notifications capability on org.gitbay.gitbay, already enabled in the developer portal, needs its matching entitlement in the Xcode project.
  • The privacy nutrition label does not gain the device token. It is per-app, it rotates, it is not the advertising identifier, and it is not used for tracking under any reading Apple's rules care about — nothing is shared with a data broker and nothing is linked to third-party data. Decided, not skipped; do not add it on a later pass without raising it again. Note the token is stored against the account (push_devices.user_id), so if it were ever declared it would be "linked to the user", not unlinked.
  • An App Store resubmission. If Apple asks what push is for, it is activity on repositories the account follows.

DESIGN.org's "No push notifications" gap row is rewritten to point at the feature. The Parity page in krz/gitbay's wiki has three nos in its ios column for the device commands; they become yes.

Testing

Unit tests in the repository's existing Swift Testing style:

  • a payload decodes to the right PushPayload, including a missing badge;
  • a payload for a signed-in account resolves to that account's id and the right route;
  • a payload for an account signed out resolves to nil;
  • a payload whose path is a repository root, or names an unknown kind, resolves to an account but no route;
  • PushRegistrar against a stubbed store and client calls device add once per account, and on sign-out calls device remove with the id it stored for that account and no other.

Not unit tested: the delegate callbacks and the navigation drain, both of which need a running app and a real APNs round trip. Those go in the live smoke suite (smoke-account.sh and the live suite steps), run against a device. The Simulator cannot receive a real push.

Scope

Two stages, in order:

  1. krz/gitbay: the device id in device add's response, and aps.badge in the payload. One MR, folding into v1.32.1.
  2. krz/gitbay-ios: everything above.

The implementation plan that follows this spec owns both stages: stage 1 is its Task 1, in the other repository, and must merge before the registration and settings work can run against a real server. A prerequisite tracked outside the plan is a prerequisite that gets forgotten.