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 | 262 | `aps.badge` in the payload. One MR, folding into v1.32.1. |
| 263 | 263 | 2. `krz/gitbay-ios`: everything above. |
| 264 | 264 | |
| 265 | The implementation plan that follows this spec covers stage 2. Stage 1 | |
| 266 | is small enough to land as an ordinary change against the shipped | |
| 267 | server spec, which it amends. | |
| 265 | The implementation plan that follows this spec owns both stages: stage | |
| 266 | 1 is its Task 1, in the other repository, and must merge before the | |
| 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. | |