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