docs/superpowers/plans/2026-09-20-push-notifications.md

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

1370 lines · 53607 bytes

   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.