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