Commit 00360ca116

00360ca1163500cf71b7d9fe82fef8ad16e4db9c

parent: a4707bc1b0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 21:18 UTC

docs: implementation plan for push notifications

Eleven tasks. Task 1 is the two server additions, in krz/gitbay, which
the rest depends on; the remainder is the app. The spec's scope note
moves with it: a prerequisite tracked outside the plan is one that
gets forgotten.

Ref krz/gitbay#89

Layout: unified · split

docs/superpowers/plans/2026-09-20-push-notifications.md added +1353
@@ -0,0 +1,1353 @@
1# Push notifications Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** The iOS 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.
6
7**Architecture:** An `AppDelegate` adaptor receives the device token and taps. `PushRegistrar` registers that token against every signed-in account using transient clients. `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 into the Dashboard tab's navigation path.
8
9**Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, `UserNotifications`. iOS 18.0 minimum, built with the iOS 26.5 SDK.
10
11**Spec:** `docs/superpowers/specs/2026-09-20-push-notifications-design.md`
12
13## Global Constraints
14
15- **Two repositories.** Task 1 lands in `~/git/krz/gitbay` (Go). Tasks 2-11 land in `~/git/krz/gitbay-ios` (Swift). Check which one you are in before editing.
16- **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. This is absolute.
17- **Commits must be signed.** Use `git -c commit.gpgsign=true commit`. Both repositories refuse unsigned commits.
18- **Never push to `main`. Never merge.** Commit locally only; the branches already exist (`push` in gitbay-ios, and Task 1 creates its own in gitbay).
19- **Commit messages reference the issue:** `Ref krz/gitbay#89` in gitbay-ios, `Ref #89` in gitbay.
20- **Swift 6, default `MainActor` isolation.** Types are main-actor isolated unless marked `nonisolated`. Pure data types (`PushPayload`, `PushTarget`) are `nonisolated` and `Sendable`; observable state (`PushRouter`, `PushRegistrar`) is `@MainActor`.
21- **The Xcode project uses `PBXFileSystemSynchronizedRootGroup`.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically — do NOT edit `project.pbxproj` to add file references. Build settings are a different matter and Task 6 does edit them.
22- **Merge requests are `mrs` on the wire**, never `merge_requests`. The path a notification carries is `<owner>/<repo>/<section>/<n>`.
23- Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary.
24- **Do not run the full iOS test suite repeatedly.** Run the focused test, then the suite once before committing.
25
26---
27
28### Task 1: Server prerequisites
29
30**Repository: `~/git/krz/gitbay`** (Go, not Swift).
31
32**Files:**
33- Modify: `internal/control/notifications.go` — `runNotificationsDeviceAdd`
34- Modify: `internal/store/push.go` — `QueuedPush`, `DuePush`
35- Modify: `internal/push/apns.go` — `Send`
36- Modify: `internal/push/push.go` — the drainer's `Send` call
37- Modify: `docs/specs/2026-09-20-ios-push-notifications-design.md`
38- Modify: `CHANGELOG.org`
39- Test: `internal/control/notifications_test.go`, `internal/store/push_test.go`, `internal/push/apns_test.go`, `e2e/push_test.go`
40
41**Interfaces:**
42- Consumes: nothing.
43- Produces: `notifications device add` returns `{"id": <n>, "status": "registered"}`; the APNs payload gains `aps.badge`.
44
45- [ ] **Step 1: Branch**
46
47```bash
48cd ~/git/krz/gitbay && git checkout main && git pull --ff-only && git checkout -b push-device-id-badge
49```
50
51- [ ] **Step 2: Write the failing test for the returned id**
52
53Append to `internal/control/notifications_test.go`:
54
55```go
56// device add returns the row id. Without it a client that wants to
57// deregister has to list devices and match its own token against the
58// truncated display value, which is identity by rendered string.
59func TestNotificationsDeviceAddReturnsTheID(t *testing.T) {
60 c := notifTestCtx(t, "alice")
61 c.Stdin = strings.NewReader("DEVTOKEN\n")
62 var out bytes.Buffer
63 c.Stdout, c.JSON = &out, true
64 if code := runNotificationsDeviceAdd(c, nil); code != 0 {
65 t.Fatalf("exit %d", code)
66 }
67 devices, _ := c.Store.PushDevices(c.User.ID)
68 if len(devices) != 1 {
69 t.Fatalf("want one device, got %d", len(devices))
70 }
71 want := fmt.Sprintf(`"id":%d`, devices[0].ID)
72 if !strings.Contains(out.String(), want) {
73 t.Fatalf("output %s does not carry %s", out.String(), want)
74 }
75}
76```
77
78Check `notifTestCtx`'s real signature in that file before relying on it; earlier work in this feature found the plan's test helpers invented. If it takes different arguments, use what is there.
79
80- [ ] **Step 3: Run it and confirm it fails**
81
82Run: `go test ./internal/control/ -run TestNotificationsDeviceAddReturnsTheID -v`
83Expected: FAIL — the output carries `"status":"registered"` and no id.
84
85- [ ] **Step 4: Return the id**
86
87In `runNotificationsDeviceAdd`, `AddPushDevice` already returns the id — capture it instead of discarding it, and emit it:
88
89```go
90 id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label"))
91 if err != nil {
92 return c.fail(protocol.ExitFailure, "%v", err)
93 }
94 return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
95 fmt.Fprintf(w, "device %d registered\n", id)
96 })
97```
98
99- [ ] **Step 5: Confirm it passes**
100
101Run: `go test ./internal/control/ -run TestNotifications -v`
102Expected: PASS, including the pre-existing notification tests.
103
104- [ ] **Step 6: Write the failing test for the badge**
105
106Append to `internal/store/push_test.go`:
107
108```go
109// The queue row carries the recipient's unread count, so the alert can
110// badge the app icon. Counted at send rather than at enqueue: an inbox
111// cleared in the seconds before delivery is reflected.
112func TestDuePushCarriesTheUnreadCount(t *testing.T) {
113 s := pushFixture(t)
114 uid, err := s.CreateUser("alice", false)
115 if err != nil {
116 t.Fatal(err)
117 }
118 repoID, err := s.CreateRepo("user", uid, "app", "public")
119 if err != nil {
120 t.Fatal(err)
121 }
122 if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil {
123 t.Fatal(err)
124 }
125 // Two unread inbox rows, then a queued push.
126 for i := 0; i < 2; i++ {
127 if err := s.AddNotice(uid, repoID, "issue", "bob", "opened issue #1", "alice/app/issues/1"); err != nil {
128 t.Fatal(err)
129 }
130 }
131 if err := s.EnqueuePush(uid, "alice/app", "bob opened issue #1", "alice/app/issues/1"); err != nil {
132 t.Fatal(err)
133 }
134 due, err := s.DuePush(20)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if len(due) != 1 {
139 t.Fatalf("want one queued push, got %d", len(due))
140 }
141 if due[0].Badge != 2 {
142 t.Fatalf("Badge = %d, want 2", due[0].Badge)
143 }
144}
145```
146
147Check `CreateRepo` and `AddNotice`'s real signatures before relying on them.
148
149- [ ] **Step 7: Run it and confirm it fails**
150
151Run: `go test ./internal/store/ -run TestDuePushCarriesTheUnreadCount -v`
152Expected: FAIL — `QueuedPush has no field or method Badge`.
153
154- [ ] **Step 8: Carry the count**
155
156Add to `QueuedPush` in `internal/store/push.go`:
157
158```go
159 // Badge is the recipient's unread inbox count, for the alert's badge.
160 // Counted here rather than at enqueue so a cleared inbox is reflected.
161 Badge int
162```
163
164In `DuePush`, add the subquery to the SELECT and the scan target:
165
166```sql
167 SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts,
168 (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)
169```
170
171```go
172 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil {
173```
174
175- [ ] **Step 9: Send it**
176
177In `internal/push/apns.go`, add `badge int` to `Send`'s parameters after `user`, and put it in the `aps` dictionary:
178
179```go
180 "aps": map[string]any{
181 "alert": map[string]string{"title": title, "body": body},
182 "sound": "default",
183 "badge": badge,
184 "thread-id": title,
185 },
186```
187
188In `internal/push/push.go`, pass it: `d.Cl.Send(ctx, q.Token, q.Username, q.Badge, q.Title, q.Body, q.Path)`.
189
190Add a payload assertion to `internal/push/apns_test.go`'s `TestSendNamesTheAccount`, and fix the other three `Send` call sites in that file — `go build` will not catch them, only `go vet`.
191
192- [ ] **Step 10: Verify**
193
194Run: `go build ./... && go vet ./... && go test ./internal/... ./cmd/...`
195Expected: all pass. `go vet` is what catches stale test callers after the signature change.
196
197Then add a badge assertion to `e2e/push_test.go` beside the existing `user` and `instance` checks, and run `go test ./e2e/ -run TestPush -v`.
198
199- [ ] **Step 11: Docs**
200
201Update the payload block and prose in `docs/specs/2026-09-20-ios-push-notifications-design.md` to include `aps.badge`, and note that `device add` returns the id. Add both to the existing unreleased `v1.32.1` entry in `CHANGELOG.org` — do not create a new version heading.
202
203- [ ] **Step 12: Commit and open the MR**
204
205```bash
206git add -u && git -c commit.gpgsign=true commit -m "push: return the device id and badge the alert
207
208device add returned only a status, so a client that wants to
209deregister had to list devices and match its own token against the
210truncated display value. It now returns the id AddPushDevice already
211had.
212
213The alert carries the recipient's unread count, counted in DuePush at
214send rather than at enqueue so a cleared inbox is reflected.
215
216Ref #89"
217```
218
219```bash
220git push -u origin push-device-id-badge
221```
222
223Open the MR with `gitbay mr create --source push-device-id-badge --target main --title "push: return the device id and badge the alert" --body "..."`. Do not merge — report back instead.
224
225---
226
227### Task 2: PushPayload
228
229**Repository: `~/git/krz/gitbay-ios`** for this and every task below. Branch `push` already exists.
230
231**Files:**
232- Create: `gitbay/Push/PushPayload.swift`
233- Create: `gitbayTests/PushPayloadTests.swift`
234
235**Interfaces:**
236- Consumes: nothing.
237- Produces:
238 - `nonisolated struct PushPayload: Equatable, Sendable { let instance: URL; let user: String; let path: String; let badge: Int }`
239 - `init?(userInfo: [AnyHashable: Any])`
240
241- [ ] **Step 1: Write the failing test**
242
243`gitbayTests/PushPayloadTests.swift`:
244
245```swift
246import Foundation
247import Testing
248@testable import gitbay
249
250@Test func payloadDecodesTheAccountAndPath() throws {
251 let userInfo: [AnyHashable: Any] = [
252 "aps": ["alert": ["title": "krz/gitbay", "body": "cmc opened issue #12"], "badge": 3],
253 "instance": "https://gitbay.org",
254 "user": "cmc",
255 "path": "krz/gitbay/issues/12",
256 ]
257 let payload = try #require(PushPayload(userInfo: userInfo))
258 #expect(payload.instance == URL(string: "https://gitbay.org"))
259 #expect(payload.user == "cmc")
260 #expect(payload.path == "krz/gitbay/issues/12")
261 #expect(payload.badge == 3)
262}
263
264@Test func payloadTreatsAMissingBadgeAsZero() throws {
265 let userInfo: [AnyHashable: Any] = [
266 "aps": ["alert": ["title": "t", "body": "b"]],
267 "instance": "https://gitbay.org", "user": "cmc", "path": "a/b/issues/1",
268 ]
269 let payload = try #require(PushPayload(userInfo: userInfo))
270 #expect(payload.badge == 0)
271}
272
273// A payload from a server that predates the account fields, or one
274// mangled in transit, is dropped rather than guessed at.
275@Test func payloadRejectsAnIncompleteNotification() {
276 #expect(PushPayload(userInfo: ["aps": ["alert": "x"], "path": "a/b/issues/1"]) == nil)
277 #expect(PushPayload(userInfo: ["instance": "https://gitbay.org", "user": "cmc"]) == nil)
278 #expect(PushPayload(userInfo: ["instance": "not a url at all", "user": "cmc", "path": "a/b/issues/1"]) == nil)
279}
280```
281
282- [ ] **Step 2: Run it and confirm it fails**
283
284```bash
285cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
286 -destination 'platform=iOS Simulator,name=iPhone 17' \
287 -only-testing:gitbayTests/payloadDecodesTheAccountAndPath test 2>&1 | tail -20
288```
289
290Expected: FAIL — `cannot find 'PushPayload' in scope`.
291
292- [ ] **Step 3: Write the implementation**
293
294`gitbay/Push/PushPayload.swift`:
295
296```swift
297import Foundation
298
299/// A push notification as the app reads it.
300///
301/// `instance` and `user` name the account the notice belongs to. One
302/// device token is one install, and an install registers against every
303/// account signed in on it, so `path` alone is ambiguous — two
304/// instances can hold the same `owner/name`.
305nonisolated struct PushPayload: Equatable, Sendable {
306 let instance: URL
307 let user: String
308 let path: String
309 /// The recipient's unread count, for the app icon badge. Absent on
310 /// a notification from a server that predates it.
311 let badge: Int
312
313 init?(userInfo: [AnyHashable: Any]) {
314 guard let raw = userInfo["instance"] as? String,
315 let instance = URL(string: raw), instance.scheme != nil,
316 let user = userInfo["user"] as? String, !user.isEmpty,
317 let path = userInfo["path"] as? String, !path.isEmpty
318 else { return nil }
319 self.instance = instance
320 self.user = user
321 self.path = path
322 self.badge = (userInfo["aps"] as? [AnyHashable: Any])?["badge"] as? Int ?? 0
323 }
324}
325```
326
327- [ ] **Step 4: Confirm it passes**
328
329Run the three tests by name as in Step 2.
330Expected: PASS.
331
332- [ ] **Step 5: Commit**
333
334```bash
335git add gitbay/Push/PushPayload.swift gitbayTests/PushPayloadTests.swift
336git -c commit.gpgsign=true commit -m "push: decode a notification payload
337
338instance and user name the account a notice belongs to; path alone is
339ambiguous when one install is registered against several.
340
341Ref krz/gitbay#89"
342```
343
344---
345
346### Task 3: Route parsing shared with the inbox, and PushTarget
347
348**Files:**
349- Modify: `gitbay/Account/NotificationsViewModel.swift:29-40` (`InboxNotification.destination`)
350- Create: `gitbay/Push/PushTarget.swift`
351- Create: `gitbayTests/PushTargetTests.swift`
352
353**Interfaces:**
354- Consumes: `PushPayload` from Task 2; `NotificationDestination` (existing, cases `.issue`/`.mr`/`.build`, each `(repo: String, number: Int64)`).
355- Produces:
356 - `nonisolated func notificationDestination(forPath path: String) -> NotificationDestination?`
357 - `nonisolated struct PushTarget: Equatable, Sendable { let accountID: String; let destination: NotificationDestination }`
358 - `init?(payload: PushPayload, accounts: [Account])`
359
360- [ ] **Step 1: Extract the existing path parsing**
361
362`InboxNotification.destination` already parses the path. Move its body to a free function in the same file so both callers share one parser, and have the property call it:
363
364```swift
365/// Where a notification's `path` points — a web-style route,
366/// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong
367/// section, missing or non-numeric id, wrong number of components)
368/// yields nil. The merge request section is `mrs` on the wire, never
369/// `merge_requests`.
370nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? {
371 let parts = path.split(separator: "/", omittingEmptySubsequences: false)
372 guard parts.count == 4, let number = Int64(parts[3]) else { return nil }
373 let repo = "\(parts[0])/\(parts[1])"
374 switch parts[2] {
375 case "issues": return .issue(repo: repo, number: number)
376 case "mrs": return .mr(repo: repo, number: number)
377 case "builds": return .build(repo: repo, number: number)
378 default: return nil
379 }
380}
381```
382
383and in `InboxNotification`:
384
385```swift
386 var destination: NotificationDestination? { notificationDestination(forPath: path) }
387```
388
389Keep the existing doc comment on the property.
390
391- [ ] **Step 2: Write the failing test**
392
393`gitbayTests/PushTargetTests.swift`:
394
395```swift
396import Foundation
397import Testing
398@testable import gitbay
399
400private func account(_ host: String, _ user: String) throws -> Account {
401 Account(instance: try GitbayInstance(url: "https://\(host)"), username: user)
402}
403
404private func payload(_ instance: String, _ user: String, _ path: String) throws -> PushPayload {
405 try #require(PushPayload(userInfo: [
406 "aps": ["alert": ["title": "t", "body": "b"]],
407 "instance": instance, "user": user, "path": path,
408 ]))
409}
410
411@Test func targetResolvesToTheMatchingAccount() throws {
412 let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")]
413 let target = try #require(PushTarget(
414 payload: try payload("https://dev.local", "cmc", "krz/gitbay/mrs/7"),
415 accounts: accounts))
416 #expect(target.accountID == accounts[1].id)
417 #expect(target.destination == .mr(repo: "krz/gitbay", number: 7))
418}
419
420// Same username on two instances is two accounts; the instance decides.
421@Test func targetDistinguishesTheSameUserOnTwoInstances() throws {
422 let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")]
423 let target = try #require(PushTarget(
424 payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"),
425 accounts: accounts))
426 #expect(target.accountID == accounts[0].id)
427}
428
429@Test func targetIsNilForAnAccountSignedOut() throws {
430 let accounts = [try account("gitbay.org", "cmc")]
431 #expect(PushTarget(payload: try payload("https://other.example", "cmc", "a/b/issues/1"),
432 accounts: accounts) == nil)
433}
434
435// A repository root, or a section the app has no screen for, resolves
436// to no target: the tap opens the app without navigating.
437@Test func targetIsNilForAnUnroutablePath() throws {
438 let accounts = [try account("gitbay.org", "cmc")]
439 #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay"),
440 accounts: accounts) == nil)
441 #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay/wiki/Home"),
442 accounts: accounts) == nil)
443}
444```
445
446Check `GitbayInstance`'s real initializer before relying on `init(url:)`.
447
448- [ ] **Step 3: Run it and confirm it fails**
449
450Expected: FAIL — `cannot find 'PushTarget' in scope`.
451
452- [ ] **Step 4: Write the implementation**
453
454`gitbay/Push/PushTarget.swift`:
455
456```swift
457import Foundation
458
459/// A tapped notification resolved against the accounts signed in on
460/// this device: which account it belongs to, and the screen it opens.
461nonisolated struct PushTarget: Equatable, Sendable {
462 let accountID: String
463 let destination: NotificationDestination
464
465 /// Nil when no signed-in account matches — you signed out of it —
466 /// or when the path names nothing the app can route to.
467 init?(payload: PushPayload, accounts: [Account]) {
468 guard let destination = notificationDestination(forPath: payload.path) else { return nil }
469 let match = accounts.first {
470 $0.username == payload.user && $0.instance.baseURL == payload.instance
471 }
472 guard let match else { return nil }
473 self.accountID = match.id
474 self.destination = destination
475 }
476}
477```
478
479Check how `GitbayInstance` exposes its URL — the spec assumes `baseURL`, matching `Account.id`'s construction. If comparing `URL`s directly proves brittle over a trailing slash, compare `absoluteString` normalised the same way `Account.id` does, and say so in your report.
480
481- [ ] **Step 5: Confirm it passes**
482
483Run all four `PushTarget` tests plus the existing notification tests, since Step 1 touched shared parsing:
484
485```bash
486xcodebuild -project gitbay.xcodeproj -scheme gitbay \
487 -destination 'platform=iOS Simulator,name=iPhone 17' \
488 -only-testing:gitbayTests test 2>&1 | tail -20
489```
490
491Expected: PASS, with no regression in the inbox notification tests.
492
493- [ ] **Step 6: Commit**
494
495```bash
496git add gitbay/Push/PushTarget.swift gitbayTests/PushTargetTests.swift gitbay/Account/NotificationsViewModel.swift
497git -c commit.gpgsign=true commit -m "push: resolve a payload to an account and a screen
498
499The path parsing the inbox already did becomes a free function both
500callers share rather than a second copy.
501
502Ref krz/gitbay#89"
503```
504
505---
506
507### Task 4: SessionStore vends clients and announces removals
508
509**Files:**
510- Modify: `gitbay/Auth/SessionStore.swift`
511- Modify: `gitbay/Views/Repos/RepoListView.swift:283`
512- Create: `gitbayTests/SessionClientTests.swift`
513
514**Interfaces:**
515- Consumes: nothing.
516- Produces:
517 - `func client(for account: Account) -> GitbayClient?`
518 - `var willRemoveAccount: ((Account) async -> Void)?`
519 - `func remove(_ account: Account) async` — note this becomes `async`
520
521- [ ] **Step 1: Write the failing test**
522
523`gitbayTests/SessionClientTests.swift`. `gitbayTests/SessionStoreTests.swift:42-52` already has a `makeSession` fixture using `MemoryTokenStore` and a stubbed session — it is `private`, so copy its shape into this file rather than trying to share it:
524
525```swift
526import Foundation
527import Testing
528@testable import gitbay
529
530private let whoami = """
531 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
532 """
533
534@MainActor
535private func makeSession() -> (SessionStore, StubProtocol.Box) {
536 let box = StubProtocol.box()
537 let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
538 let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in
539 GitbayClient(instance: instance, token: token, session: box.session())
540 }
541 return (session, box)
542}
543
544/// Signs two accounts in, on different instances.
545@MainActor
546private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) {
547 let (session, box) = makeSession()
548 box.enqueue(.init(status: 200, json: whoami))
549 try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
550 box.enqueue(.init(status: 200, json: whoami))
551 try await session.signIn(instanceURL: "https://dev.local", token: "t2")
552 return (session, box)
553}
554
555@Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws {
556 let (session, _) = try await twoAccounts()
557 let inactive = try #require(session.accounts.first { $0.id != session.current?.id })
558 #expect(session.client(for: inactive) != nil)
559 // Vending does not change which account is active.
560 #expect(session.current?.id != inactive.id)
561}
562
563@Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws {
564 let (session, _) = try await twoAccounts()
565 let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x")
566 #expect(session.client(for: stranger) == nil)
567}
568
569// The hook runs before the token is discarded: deregistering a device
570// needs the credential remove() is about to delete.
571@Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws {
572 let (session, _) = try await twoAccounts()
573 let victim = try #require(session.accounts.first)
574 var sawTokenDuringHook: Bool?
575 session.willRemoveAccount = { account in
576 sawTokenDuringHook = session.client(for: account) != nil
577 }
578 await session.remove(victim)
579 #expect(sawTokenDuringHook == true)
580 #expect(session.client(for: victim) == nil)
581}
582```
583
584`MemoryTokenStore` is defined privately in `SessionStoreTests.swift`; copy it too, or promote it to a shared test helper file — either is fine, but do not leave two diverging copies.
585
586- [ ] **Step 2: Run it and confirm it fails**
587
588Expected: FAIL — `value of type 'SessionStore' has no member 'client(for:)'`.
589
590- [ ] **Step 3: Implement**
591
592In `SessionStore`:
593
594```swift
595 /// A client for any signed-in account, active or not. Push
596 /// registration talks to every account on the device, and `store`
597 /// and `makeClient` are private. Vending does not change `current`.
598 func client(for account: Account) -> GitbayClient? {
599 guard let token = store.token(for: account.id) else { return nil }
600 return makeClient(account.instance, token)
601 }
602
603 /// Runs before an account is removed, while its token still exists.
604 /// `gitbayApp` points this at push deregistration; this type knows
605 /// nothing about push beyond that something wants to run first.
606 var willRemoveAccount: ((Account) async -> Void)?
607```
608
609and make `remove` async, awaiting the hook first:
610
611```swift
612 func remove(_ account: Account) async {
613 await willRemoveAccount?(account)
614 store.deleteToken(for: account.id)
615 // ... the rest unchanged ...
616 }
617```
618
619- [ ] **Step 4: Update the one call site**
620
621`gitbay/Views/Repos/RepoListView.swift:283` becomes:
622
623```swift
624 Task { await session.remove(current) }
625```
626
627- [ ] **Step 5: Confirm it passes**
628
629Run the whole `gitbayTests` suite — `remove`'s signature changed and other tests may call it.
630Expected: PASS.
631
632- [ ] **Step 6: Commit**
633
634```bash
635git add gitbay/Auth/SessionStore.swift gitbay/Views/Repos/RepoListView.swift gitbayTests/SessionClientTests.swift
636git -c commit.gpgsign=true commit -m "auth: vend clients per account, announce removals
637
638Push registration talks to every signed-in account, not just the
639active one, and deregistration needs the token remove() discards.
640
641Ref krz/gitbay#89"
642```
643
644---
645
646### Task 5: PushRegistrar
647
648**Files:**
649- Create: `gitbay/Push/PushRegistrar.swift`
650- Create: `gitbayTests/PushRegistrarTests.swift`
651
652**Interfaces:**
653- Consumes: `SessionStore.client(for:)` and `accounts` from Task 4.
654- Produces:
655 - `@MainActor @Observable final class PushRegistrar`
656 - `init(session: SessionStore, defaults: UserDefaults = .standard)`
657 - `func deviceTokenArrived(_ data: Data) async`
658 - `func registerAll() async`
659 - `func deregister(_ account: Account) async`
660
661- [ ] **Step 1: Write the failing test**
662
663`gitbayTests/PushRegistrarTests.swift`. Use `StubProtocol` as the other client tests do:
664
665```swift
666@Test @MainActor func registrarRegistersEveryAccountOnce() async throws {
667 // session with two accounts; stub returns {"id":7,"status":"registered"}
668 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
669 await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef]))
670
671 let adds = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) }
672 #expect(adds.count == 2)
673 // The token is the lowercase hex of the raw bytes, on stdin, never argv.
674 #expect(stdin(of: adds[0]) == "deadbeef")
675 #expect(!argv(of: adds[0]).contains("deadbeef"))
676}
677
678@Test @MainActor func registrarDeregistersWithTheStoredID() async throws {
679 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
680 await registrar.deviceTokenArrived(Data([0x01]))
681 box.reset()
682
683 await registrar.deregister(try #require(session.accounts.first))
684 let removes = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) }
685 #expect(removes.count == 1)
686 #expect(argv(of: removes[0]).last == "7")
687}
688
689// Registration is a side channel: a failure leaves the account working
690// and is retried on the next launch, not surfaced.
691@Test @MainActor func registrarSurvivesAFailedRegistration() async throws {
692 // stub returns 500
693 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
694 await registrar.deviceTokenArrived(Data([0x01])) // must not throw
695}
696```
697
698Write `argv(of:)`, `stdin(of:)` and `scratchDefaults()` as private helpers in this file, modelled on `AccountTests.swift`'s `argvOf`. Use a `UserDefaults(suiteName:)` unique per test so the stored ids do not leak between parallel tests.
699
700- [ ] **Step 2: Run it and confirm it fails**
701
702Expected: FAIL — `cannot find 'PushRegistrar' in scope`.
703
704- [ ] **Step 3: Implement**
705
706`gitbay/Push/PushRegistrar.swift`:
707
708```swift
709import Foundation
710import Observation
711
712/// Registers this install's APNs token against every signed-in account,
713/// and deregisters one when its account goes away.
714///
715/// A device row is keyed on (user, token) server-side, so one install
716/// holds a row per account. Registering only the active account would
717/// silently stop notifications from the others the moment you switched.
718@Observable
719@MainActor
720final class PushRegistrar {
721
722 private let session: SessionStore
723 private let defaults: UserDefaults
724
725 /// The APNs token as lowercase hex, once iOS has handed it over.
726 private(set) var deviceToken: String?
727
728 init(session: SessionStore, defaults: UserDefaults = .standard) {
729 self.session = session
730 self.defaults = defaults
731 }
732
733 func deviceTokenArrived(_ data: Data) async {
734 deviceToken = data.map { String(format: "%02x", $0) }.joined()
735 await registerAll()
736 }
737
738 /// Registers every signed-in account. Apple rotates device tokens,
739 /// so this runs on launch too; `device add` upserts on the token, so
740 /// a repeat costs one call and changes nothing.
741 func registerAll() async {
742 guard let token = deviceToken else { return }
743 for account in session.accounts {
744 await register(account, token: token)
745 }
746 }
747
748 private func register(_ account: Account, token: String) async {
749 guard let client = session.client(for: account) else { return }
750 struct Registered: Decodable, Sendable { let id: Int64 }
751 do {
752 let out = try await client.run(
753 ["notifications", "device", "add", "--label", Self.deviceLabel],
754 stdin: token, as: Registered.self)
755 if let id = out?.id {
756 defaults.set(id, forKey: Self.idKey(account))
757 }
758 } catch {
759 // A side channel. The account still works and the next
760 // launch registers again.
761 }
762 }
763
764 /// Removes this device from one account, using the id that account
765 /// returned at registration. Must run while the account's token
766 /// still exists — SessionStore.willRemoveAccount is where it is
767 /// wired, and that hook is awaited before the token is discarded.
768 func deregister(_ account: Account) async {
769 let key = Self.idKey(account)
770 guard let id = defaults.object(forKey: key) as? Int64,
771 let client = session.client(for: account) else { return }
772 try? await client.run(["notifications", "device", "remove", String(id)])
773 defaults.removeObject(forKey: key)
774 }
775
776 private static func idKey(_ account: Account) -> String {
777 "pushDeviceID#\(account.id)"
778 }
779
780 /// Names the row in `notifications device list` on every surface.
781 private static var deviceLabel: String {
782 UIDevice.current.name
783 }
784}
785```
786
787`UIDevice` needs `import UIKit`. If `UIDevice.current.name` returns a generic string on this OS version — Apple redacted it for apps without a specific entitlement — use `ProcessInfo.processInfo.hostName` or a fixed `"iPhone"` rather than shipping an empty label, and say which in your report.
788
789- [ ] **Step 4: Confirm it passes**
790
791Run the three registrar tests.
792Expected: PASS.
793
794- [ ] **Step 5: Commit**
795
796```bash
797git add gitbay/Push/PushRegistrar.swift gitbayTests/PushRegistrarTests.swift
798git -c commit.gpgsign=true commit -m "push: register this device with every signed-in account
799
800Apple rotates tokens, so registration runs on launch as well as on
801arrival; device add upserts, so a repeat is free. Deregistration uses
802the id the server now returns rather than matching a truncated token.
803
804Ref krz/gitbay#89"
805```
806
807---
808
809### Task 6: Entitlement and capability
810
811**Files:**
812- Create: `gitbay/gitbay.entitlements`
813- Modify: `gitbay.xcodeproj/project.pbxproj` (build settings only)
814
815**Interfaces:**
816- Consumes: nothing.
817- Produces: an app that can call `registerForRemoteNotifications()` without iOS refusing.
818
819This is the one task that edits `project.pbxproj`. It adds a build setting, not a file reference — the synchronized group still handles files.
820
821- [ ] **Step 1: Create the entitlements file**
822
823`gitbay/gitbay.entitlements`:
824
825```xml
826<?xml version="1.0" encoding="UTF-8"?>
827<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
828<plist version="1.0">
829<dict>
830 <key>aps-environment</key>
831 <string>development</string>
832</dict>
833</plist>
834```
835
836`development` is correct for local builds; Xcode rewrites it to `production` when it exports for TestFlight and the App Store. Do not set `production` here — that would break debug builds against sandbox APNs.
837
838- [ ] **Step 2: Point the target at it**
839
840In `gitbay.xcodeproj/project.pbxproj`, add to **both** the Debug and Release build configurations of the `gitbay` app target (the ones that already carry `PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;`):
841
842```
843 CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements;
844```
845
846Add it beside `CODE_SIGN_STYLE`, keeping the file's tab indentation. Do not touch the test target's configurations.
847
848- [ ] **Step 3: Verify it builds and the entitlement is attached**
849
850```bash
851cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
852 -destination 'platform=iOS Simulator,name=iPhone 17' build 2>&1 | tail -5
853```
854
855Expected: `BUILD SUCCEEDED`. A malformed plist or a wrong path fails here with a code-signing error naming the entitlements file.
856
857- [ ] **Step 4: Commit**
858
859```bash
860git add gitbay/gitbay.entitlements gitbay.xcodeproj/project.pbxproj
861git -c commit.gpgsign=true commit -m "push: the Push Notifications entitlement
862
863aps-environment is development; Xcode rewrites it to production when
864it exports for TestFlight and the App Store.
865
866Ref krz/gitbay#89"
867```
868
869---
870
871### Task 7: The delegate and the router
872
873**Files:**
874- Create: `gitbay/Push/PushRouter.swift`
875- Create: `gitbay/Push/AppDelegate.swift`
876- Modify: `gitbay/gitbayApp.swift`
877
878**Interfaces:**
879- Consumes: `PushPayload`, `PushTarget`, `PushRegistrar`, `SessionStore`.
880- Produces:
881 - `@MainActor @Observable final class PushRouter { var pending: PushTarget? }`
882 - `final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate`
883 - `PushRegistrar` and `PushRouter` in the environment
884 - `func requestPushPermission() async -> Bool` on `PushRegistrar`
885
886- [ ] **Step 1: PushRouter**
887
888`gitbay/Push/PushRouter.swift`:
889
890```swift
891import Observation
892
893/// Carries a tapped notification's destination into the view tree.
894///
895/// Owned by `gitbayApp`, above `ContentView` — which is `.id(account.id)`
896/// and is therefore destroyed and rebuilt by the account switch a
897/// cross-account tap performs. A target held inside that subtree would
898/// go with it; held here, the rebuilt tree drains it on appear.
899@Observable
900@MainActor
901final class PushRouter {
902 var pending: PushTarget?
903}
904```
905
906- [ ] **Step 2: The delegate**
907
908`gitbay/Push/AppDelegate.swift`:
909
910```swift
911import UIKit
912import UserNotifications
913
914/// The only way to receive an APNs device token and a notification tap.
915/// SwiftUI has no equivalent, so the app keeps one delegate whose whole
916/// job is handing both to the push types.
917final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
918
919 @MainActor weak var registrar: PushRegistrar?
920 @MainActor weak var router: PushRouter?
921 @MainActor weak var session: SessionStore?
922
923 func application(
924 _ application: UIApplication,
925 didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil
926 ) -> Bool {
927 UNUserNotificationCenter.current().delegate = self
928 return true
929 }
930
931 func application(
932 _ application: UIApplication,
933 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
934 ) {
935 Task { @MainActor in await registrar?.deviceTokenArrived(deviceToken) }
936 }
937
938 func application(
939 _ application: UIApplication,
940 didFailToRegisterForRemoteNotificationsWithError error: any Error
941 ) {
942 // Nothing to do and nothing to say: push is a side channel, and
943 // the next launch tries again.
944 }
945
946 /// A notification arriving while the app is open still shows — you
947 /// may be reading a different repository when one lands.
948 func userNotificationCenter(
949 _ center: UNUserNotificationCenter,
950 willPresent notification: UNNotification
951 ) async -> UNNotificationPresentationOptions {
952 [.banner, .sound, .badge]
953 }
954
955 func userNotificationCenter(
956 _ center: UNUserNotificationCenter,
957 didReceive response: UNNotificationResponse
958 ) async {
959 await MainActor.run {
960 guard let session, let router,
961 let payload = PushPayload(userInfo: response.notification.request.content.userInfo),
962 let target = PushTarget(payload: payload, accounts: session.accounts)
963 else { return }
964 router.pending = target
965 }
966 }
967}
968```
969
970- [ ] **Step 3: Permission and registration entry point**
971
972Add to `PushRegistrar`:
973
974```swift
975 /// Asks iOS for permission and, on approval, registers for remote
976 /// notifications. Called from the notifications screen's toggle, so
977 /// the system prompt only appears because the user asked for it.
978 func requestPushPermission() async -> Bool {
979 let center = UNUserNotificationCenter.current()
980 let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
981 if granted {
982 UIApplication.shared.registerForRemoteNotifications()
983 }
984 return granted
985 }
986
987 /// Whether iOS has been asked, and what it said.
988 func authorizationStatus() async -> UNAuthorizationStatus {
989 await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
990 }
991
992 /// Re-registers on launch when permission is already granted.
993 func registerIfAlreadyAuthorized() async {
994 if await authorizationStatus() == .authorized {
995 UIApplication.shared.registerForRemoteNotifications()
996 }
997 }
998```
999
1000`import UserNotifications` at the top of that file.
1001
1002- [ ] **Step 4: Wire it in gitbayApp**
1003
1004`gitbay/gitbayApp.swift`:
1005
1006```swift
1007 @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
1008 @State private var session = SessionStore()
1009 @State private var router = PushRouter()
1010 @State private var registrar: PushRegistrar?
1011```
1012
1013and in the scene body, attach:
1014
1015```swift
1016 ContentView()
1017 .environment(session)
1018 .environment(router)
1019 .environment(registrar ?? PushRegistrar(session: session))
1020 .task {
1021 let registrar = registrar ?? PushRegistrar(session: session)
1022 self.registrar = registrar
1023 appDelegate.registrar = registrar
1024 appDelegate.router = router
1025 appDelegate.session = session
1026 session.willRemoveAccount = { [weak registrar] account in
1027 await registrar?.deregister(account)
1028 }
1029 await registrar.registerIfAlreadyAuthorized()
1030 }
1031```
1032
1033If the optional-`registrar` dance proves awkward against Swift 6 isolation, construct the registrar eagerly in an `init()` alongside `GitbayFonts.register()` instead, and say so in your report. The requirement is only that one registrar instance exists, the delegate and the session hook both point at it, and it outlives `ContentView`.
1034
1035- [ ] **Step 5: Verify it builds and the suite still passes**
1036
1037```bash
1038xcodebuild -project gitbay.xcodeproj -scheme gitbay \
1039 -destination 'platform=iOS Simulator,name=iPhone 17' test 2>&1 | tail -20
1040```
1041
1042Expected: `TEST SUCCEEDED`. There are no unit tests for the delegate — it needs a running app and a real APNs round trip, which the Simulator cannot do. Task 11 covers it in the live suite.
1043
1044- [ ] **Step 6: Commit**
1045
1046```bash
1047git add gitbay/Push/PushRouter.swift gitbay/Push/AppDelegate.swift gitbay/Push/PushRegistrar.swift gitbay/gitbayApp.swift
1048git -c commit.gpgsign=true commit -m "push: receive the device token and notification taps
1049
1050PushRouter is owned above ContentView because ContentView is keyed on
1051the account id, so a cross-account tap destroys anything held inside
1052it.
1053
1054Ref krz/gitbay#89"
1055```
1056
1057---
1058
1059### Task 8: Routing a tap to the screen
1060
1061**Files:**
1062- Modify: `gitbay/ContentView.swift:1-48`
1063
1064**Interfaces:**
1065- Consumes: `PushRouter.pending`, `SessionStore.activate(_:)`.
1066- Produces: nothing later tasks use.
1067
1068- [ ] **Step 1: Give the Dashboard tab a path and the TabView a selection**
1069
1070In `ContentView`:
1071
1072```swift
1073 @Environment(PushRouter.self) private var router
1074 @State private var selectedTab = Tabs.dashboard
1075 @State private var dashboardPath = NavigationPath()
1076
1077 enum Tabs { case dashboard, feed, repositories, explore, profile }
1078```
1079
1080Give each `Tab` a `value:` matching the case, bind `TabView(selection: $selectedTab)`, and bind the Dashboard stack: `NavigationStack(path: $dashboardPath)`. Leave the other four stacks exactly as they are — only the deep-link target needs a bound path.
1081
1082- [ ] **Step 2: Drain the pending target**
1083
1084On the `TabView`:
1085
1086```swift
1087 .task(id: router.pending) { await routePendingNotification() }
1088```
1089
1090and:
1091
1092```swift
1093 /// Routes a tapped notification, switching accounts first when it
1094 /// belongs to another one.
1095 ///
1096 /// A switch changes `account.id`, which rebuilds this whole view.
1097 /// That is why the target is left pending across it: the rebuilt
1098 /// tree runs this again and finds the account already active, so the
1099 /// second pass does the navigating. The drain is the mechanism, not
1100 /// a workaround for it.
1101 private func routePendingNotification() async {
1102 guard let target = router.pending else { return }
1103 if session.current?.id != target.accountID {
1104 guard let account = session.accounts.first(where: { $0.id == target.accountID }) else {
1105 router.pending = nil // signed out of it since the tap
1106 return
1107 }
1108 _ = session.activate(account)
1109 return // the rebuilt tree drains it
1110 }
1111 selectedTab = .dashboard
1112 switch target.destination {
1113 case .issue(let repo, let number):
1114 dashboardPath.append(IssueRoute.issue(repo: repo, number: number))
1115 case .mr(let repo, let number):
1116 dashboardPath.append(MRRoute.mr(repo: repo, number: number))
1117 case .build(let repo, let number):
1118 dashboardPath.append(BuildRoute.detail(repo: repo, number: number))
1119 }
1120 router.pending = nil
1121 }
1122```
1123
1124Check the three route case names against `RouteDestinations` in the same file — `BuildRoute.detail` in particular — and use whatever is actually there.
1125
1126- [ ] **Step 3: Verify it builds and the suite passes**
1127
1128Run the full test target as in Task 7 Step 5.
1129Expected: `TEST SUCCEEDED`.
1130
1131- [ ] **Step 4: Commit**
1132
1133```bash
1134git add gitbay/ContentView.swift
1135git -c commit.gpgsign=true commit -m "push: route a tap to the issue, merge request or build
1136
1137A tap for another account switches to it first; the rebuild that
1138causes is what delivers the route, since the pending target outlives
1139the subtree.
1140
1141Ref krz/gitbay#89"
1142```
1143
1144---
1145
1146### Task 9: The notifications screen
1147
1148**Files:**
1149- Modify: `gitbay/Views/Account/NotificationsView.swift`
1150- Modify: `gitbay/Account/NotificationsViewModel.swift`
1151
1152**Interfaces:**
1153- Consumes: `PushRegistrar.requestPushPermission()`, `authorizationStatus()`, `deregister(_:)`.
1154- Produces: nothing later tasks use.
1155
1156- [ ] **Step 1: Add the push state to the view model**
1157
1158In `NotificationsViewModel`, add the server-side preference and the device list, both read through the client the model already holds:
1159
1160```swift
1161 /// `notifications settings show` reports mail, watch and push.
1162 private(set) var pushEnabled = false
1163 private(set) var devices: [PushDevice] = []
1164
1165 nonisolated struct PushDevice: Decodable, Sendable, Identifiable {
1166 let id: Int64
1167 let label: String
1168 let token: String // truncated by the server; never the whole value
1169 let added: String
1170 }
1171
1172 nonisolated struct PushSettings: Decodable, Sendable {
1173 let mail: Bool
1174 let watch: Bool
1175 let push: Bool
1176 }
1177
1178 func loadPushSettings() async {
1179 guard let settings = try? await client.read(
1180 ["notifications", "settings", "show"], as: PushSettings.self) else { return }
1181 pushEnabled = settings.push
1182 devices = (try? await client.readList(
1183 ["notifications", "device", "list"], as: PushDevice.self)) ?? []
1184 }
1185
1186 func setPush(_ on: Bool) async {
1187 await perform(["notifications", "settings", "push", on ? "on" : "off"])
1188 await loadPushSettings()
1189 }
1190
1191 func removeDevice(_ id: Int64) async {
1192 await perform(["notifications", "device", "remove", String(id)])
1193 await loadPushSettings()
1194 }
1195```
1196
1197Check `read` and `readList`'s real signatures in `GitbayClient.swift` — this plan assumes an `as:` parameter matching `run`'s. Use whatever is there, and follow the file's existing `perform(_ argv:)` helper for the writes rather than adding a second way to dispatch.
1198
1199The server's `notifications settings show` emits `{"mail": bool, "watch": bool, "push": bool}`; `device list` emits rows of `{id, label, token, added}` with the token already truncated.
1200
1201- [ ] **Step 2: Add the section to the view**
1202
1203Above the existing list, a `Section("Push notifications")` with one toggle whose three states are:
1204
1205| iOS authorization | Toggle | On change |
1206|---|---|---|
1207| `.notDetermined` | off, enabled | `requestPushPermission()`; on approval `setPush(true)` |
1208| `.authorized` | mirrors `pushEnabled` | `setPush(newValue)` |
1209| `.denied` | off, **disabled** | n/a — show the text below |
1210
1211```swift
1212 @Environment(PushRegistrar.self) private var registrar
1213 @State private var authorization: UNAuthorizationStatus = .notDetermined
1214
1215 private var pushSection: some View {
1216 Section("Push notifications") {
1217 Toggle("Push notifications", isOn: Binding(
1218 get: { authorization == .authorized && model.pushEnabled },
1219 set: { on in Task { await setPush(on) } }
1220 ))
1221 .disabled(authorization == .denied)
1222
1223 if authorization == .denied {
1224 Text("Notifications are turned off for gitbay in Settings.")
1225 .font(.gbSans(.footnote))
1226 Button("Open Settings") {
1227 if let url = URL(string: UIApplication.openSettingsURLString) {
1228 UIApplication.shared.open(url)
1229 }
1230 }
1231 }
1232 }
1233 .task { authorization = await registrar.authorizationStatus() }
1234 }
1235
1236 private func setPush(_ on: Bool) async {
1237 if on, authorization == .notDetermined {
1238 let granted = await registrar.requestPushPermission()
1239 authorization = await registrar.authorizationStatus()
1240 guard granted else { return }
1241 }
1242 await model.setPush(on)
1243 }
1244```
1245
1246The `.denied` branch is the case the explicit toggle exists for: without it a single reflexive "Don't Allow" leaves push silent with nothing accounting for it. Match the file's existing font and spacing idiom rather than the `.gbSans` call above if it differs.
1247
1248Below the toggle, the devices with a swipe-to-delete or a trailing remove button, matching how the app presents other removable lists. No add control — only the app can mint an APNs token.
1249
1250- [ ] **Step 3: Verify**
1251
1252Build and run the suite.
1253Expected: `TEST SUCCEEDED`.
1254
1255Then run the app in the Simulator and open the notifications screen. The toggle should appear and, on tap, show the system permission prompt. The Simulator cannot complete registration — there is no APNs — so expect the toggle to return to off after granting. That is correct behaviour there and not a bug to chase.
1256
1257- [ ] **Step 4: Commit**
1258
1259```bash
1260git add gitbay/Views/Account/NotificationsView.swift gitbay/Account/NotificationsViewModel.swift
1261git -c commit.gpgsign=true commit -m "push: the toggle and device list on the notifications screen
1262
1263Permission is asked when the toggle is turned on, not on arrival: iOS
1264asks once, and a prompt sprung by navigation earns a reflexive
1265refusal that nothing afterwards can explain.
1266
1267Ref krz/gitbay#89"
1268```
1269
1270---
1271
1272### Task 10: Documentation
1273
1274**Files:**
1275- Modify: `DESIGN.org:245` (the gap table)
1276- Modify: `README.org` (the capability table)
1277- Modify: `~/git/krz/gitbay/.gitbay/wiki/Parity.org` (the ios column)
1278
1279- [ ] **Step 1: DESIGN.org**
1280
1281The gap table row reads "No push notifications | Poll on foreground, use background refresh. Mail notifications already exist server-side. | not planned; propose if the app makes the case". Replace the status with a pointer to the shipped feature, in the style the other done rows use.
1282
1283- [ ] **Step 2: README.org**
1284
1285The capability table has a Notifications row: "The inbox, unread or all, marked read singly or in bulk". Extend it to mention push and that a tap opens the item.
1286
1287- [ ] **Step 3: Parity.org, in the other repository**
1288
1289`~/git/krz/gitbay/.gitbay/wiki/Parity.org` has rows for the push device commands with `no` in the ios column. They become `yes`. That repository requires a branch and a merge request of its own — do not push to its main.
1290
1291- [ ] **Step 4: Commit**
1292
1293Two commits, one per repository. The gitbay-ios one:
1294
1295```bash
1296git add DESIGN.org README.org
1297git -c commit.gpgsign=true commit -m "docs: push notifications
1298
1299Closes krz/gitbay#89"
1300```
1301
1302---
1303
1304### Task 11: Live verification and the merge request
1305
1306**Files:** none.
1307
1308- [ ] **Step 1: Verify the branch**
1309
1310```bash
1311cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
1312 -destination 'platform=iOS Simulator,name=iPhone 17' test 2>&1 | tail -20
1313```
1314
1315Expected: `TEST SUCCEEDED`. Do not claim the branch is green without this output in front of you.
1316
1317- [ ] **Step 2: Live push test, on a real device**
1318
1319The Simulator cannot receive a push. On a device, with the app installed from Xcode:
1320
13211. Sign in to gitbay.org, open the notifications screen, turn the toggle on, grant permission.
13222. On the laptop, confirm the device registered: `gitbay notifications device list --json` should show it with a truncated token.
13233. From another account — or the web as a second user — comment on an issue in a repository the signed-in account watches.
13244. The notification should arrive within a few seconds, titled with the repository.
13255. Tap it. The app should open the issue.
13266. Check `gitbay dashboard --json` — the push queue should show zero failed. A climbing `failed` count means `key_id` or `team_id` is wrong, which is the one thing config validation cannot check.
1327
1328A debug build from Xcode carries a **sandbox** token, and gitbay.org is configured `production`. So this test will get `BadDeviceToken` and the device row will be **reaped**. Either point the app at a sandbox-configured instance for this test, or archive a TestFlight build, which carries a production token. Do not skip this step and do not report the feature working without it — every unit test in this plan passes without a single real push having been delivered.
1329
1330- [ ] **Step 3: Open the merge request**
1331
1332```bash
1333git push -u origin push
1334```
1335
1336```bash
1337gitbay mr create --source push --target main --title "Push notifications"
1338```
1339
1340Body via `--body` or `--file -`. State what landed, that it depends on the two server additions in Task 1, and what the live test showed. No attribution to any assistant or model.
1341
1342- [ ] **Step 4: Do not merge**
1343
1344Report back instead. The App Store resubmission, the privacy nutrition label update and the `aps-environment` production export are the human's to do.
1345
1346---
1347
1348## Notes for whoever executes this
1349
1350- **Task 1 is in a different repository** and must merge before Tasks 5 and 9 can work against a real server — `device add` returning the id is what deregistration depends on.
1351- **The Simulator cannot receive a push.** Every unit test here passes with the delivery path entirely unexercised. Task 11 Step 2 is the only thing that proves the feature works, and it needs a real device and an environment-matched build.
1352- **`project.pbxproj` is edited exactly once**, in Task 6, and only to add a build setting. If you find yourself adding file references there, stop — the synchronized group handles files, and a manual reference will conflict with it.
1353- **Check every test helper before using it.** This plan names `notifTestCtx`, `StubProtocol.box()`, `GitbayInstance(url:)` and `BuildRoute.detail` from reading the tree, but earlier work in this feature repeatedly found plan-named helpers that did not exist. Read first, then write.
docs/superpowers/specs/2026-09-20-push-notifications-design.md +5 −3
@@ -262,6 +262,8 @@ Two stages, in order:
262 `aps.badge` in the payload. One MR, folding into v1.32.1. 262 `aps.badge` in the payload. One MR, folding into v1.32.1.
2632. `krz/gitbay-ios`: everything above. 2632. `krz/gitbay-ios`: everything above.
264 264
265The implementation plan that follows this spec covers stage 2. Stage 1 265The implementation plan that follows this spec owns both stages: stage
266is small enough to land as an ordinary change against the shipped 2661 is its Task 1, in the other repository, and must merge before the
267server spec, which it amends. 267registration and settings work can run against a real server. A
268prerequisite tracked outside the plan is a prerequisite that gets
269forgotten.