Push notifications !117
23 files changed, +2741 −28
Layout: unified · split
README.org +1 −1
| @@ -17,7 +17,7 @@ not at a keyboard. | ||
| 17 | 17 | | Merge requests | Lists and detail with a unified diff, comments and review threads, review verdicts and requests, draft and ready, retarget, milestones, and the stack a merge request sits in | |
| 18 | 18 | | Issues | Lists and detail, comments, labels with the colours the web paints them, assignees and milestones, filtering by label, assignee, author and milestone | |
| 19 | 19 | | Search | Title-and-body search within a repository, file contents across one repository, and the whole instance for repositories, issues and merge requests | |
| 20 | | Notifications | The inbox, unread or all, marked read singly or in bulk | | |
| 20 | | Notifications | The inbox, unread or all, marked read singly or in bulk. Push registers this device against every signed-in account; a tap opens the issue, merge request or build it names | | |
| 21 | 21 | | Repository actions | Fork, bookmark, watch and mute, pin, archive, visibility, topics, branch protection, and dependency checks with their status | |
| 22 | 22 | | Builds | Build list, job picker, logs, and cancelling one that is queued or running | |
| 23 | 23 | | Releases and wiki | Releases list, detail, create, edit and delete. Wikis read from the same instance | |
docs/superpowers/plans/2026-09-20-push-notifications.md added +1370
| @@ -0,0 +1,1370 @@ | ||
| 1 | # Push notifications Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** The iOS app receives APNs alerts for activity on every account signed in on the device, and a tap opens the issue, merge request or build it names. | |
| 6 | ||
| 7 | **Architecture:** An `AppDelegate` adaptor receives the device token and taps. `PushRegistrar` registers that token against every signed-in account using transient clients. `PushRouter`, owned above the account-keyed view tree, carries a tapped notification's destination across the account switch it may require; `ContentView` drains it and routes into the Dashboard tab's navigation path. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, `UserNotifications`. iOS 18.0 minimum, built with the iOS 26.5 SDK. | |
| 10 | ||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-20-push-notifications-design.md` | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - **Two repositories.** Task 1 lands in `~/git/krz/gitbay` (Go). Tasks 2-11 land in `~/git/krz/gitbay-ios` (Swift). Check which one you are in before editing. | |
| 16 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. This is absolute. | |
| 17 | - **Commits must be signed.** Use `git -c commit.gpgsign=true commit`. Both repositories refuse unsigned commits. | |
| 18 | - **Never push to `main`. Never merge.** Commit locally only; the branches already exist (`push` in gitbay-ios, and Task 1 creates its own in gitbay). | |
| 19 | - **Commit messages reference the issue:** `Ref krz/gitbay#89` in gitbay-ios, `Ref #89` in gitbay. | |
| 20 | - **Swift 6, default `MainActor` isolation.** Types are main-actor isolated unless marked `nonisolated`. Pure data types (`PushPayload`, `PushTarget`) are `nonisolated` and `Sendable`; observable state (`PushRouter`, `PushRegistrar`) is `@MainActor`. | |
| 21 | - **The Xcode project uses `PBXFileSystemSynchronizedRootGroup`.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically — do NOT edit `project.pbxproj` to add file references. Build settings are a different matter and Task 6 does edit them. | |
| 22 | - **Merge requests are `mrs` on the wire**, never `merge_requests`. The path a notification carries is `<owner>/<repo>/<section>/<n>`. | |
| 23 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | |
| 24 | - **`-only-testing:` silently passes with zero tests.** Task 2 found this: for a free `@Test` function outside a suite type, `xcodebuild -only-testing:gitbayTests/someTest test` reports `** TEST SUCCEEDED **` while `xcresulttool` shows `totalTestCount: 0`. A RED step still fails honestly, because a missing type is a compile error — but every GREEN step under that filter is meaningless. **Run the whole test target** and read the count: | |
| 25 | ||
| 26 | ```bash | |
| 27 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 28 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -30 | |
| 29 | ``` | |
| 30 | ||
| 31 | Wherever a task below says `-only-testing:`, use this instead. The suite is small enough that the cost is seconds. | |
| 32 | ||
| 33 | --- | |
| 34 | ||
| 35 | ### Task 1: Server prerequisites | |
| 36 | ||
| 37 | **Repository: `~/git/krz/gitbay`** (Go, not Swift). | |
| 38 | ||
| 39 | **Files:** | |
| 40 | - Modify: `internal/control/notifications.go` — `runNotificationsDeviceAdd` | |
| 41 | - Modify: `internal/store/push.go` — `QueuedPush`, `DuePush` | |
| 42 | - Modify: `internal/push/apns.go` — `Send` | |
| 43 | - Modify: `internal/push/push.go` — the drainer's `Send` call | |
| 44 | - Modify: `docs/specs/2026-09-20-ios-push-notifications-design.md` | |
| 45 | - Modify: `CHANGELOG.org` | |
| 46 | - Test: `internal/control/notifications_test.go`, `internal/store/push_test.go`, `internal/push/apns_test.go`, `e2e/push_test.go` | |
| 47 | ||
| 48 | **Interfaces:** | |
| 49 | - Consumes: nothing. | |
| 50 | - Produces: `notifications device add` returns `{"id": <n>, "status": "registered"}`; the APNs payload gains `aps.badge`. | |
| 51 | ||
| 52 | - [ ] **Step 1: Branch** | |
| 53 | ||
| 54 | ```bash | |
| 55 | cd ~/git/krz/gitbay && git checkout main && git pull --ff-only && git checkout -b push-device-id-badge | |
| 56 | ``` | |
| 57 | ||
| 58 | - [ ] **Step 2: Write the failing test for the returned id** | |
| 59 | ||
| 60 | Append to `internal/control/notifications_test.go`: | |
| 61 | ||
| 62 | ```go | |
| 63 | // device add returns the row id. Without it a client that wants to | |
| 64 | // deregister has to list devices and match its own token against the | |
| 65 | // truncated display value, which is identity by rendered string. | |
| 66 | func TestNotificationsDeviceAddReturnsTheID(t *testing.T) { | |
| 67 | c := notifTestCtx(t, "alice") | |
| 68 | c.Stdin = strings.NewReader("DEVTOKEN\n") | |
| 69 | var out bytes.Buffer | |
| 70 | c.Stdout, c.JSON = &out, true | |
| 71 | if code := runNotificationsDeviceAdd(c, nil); code != 0 { | |
| 72 | t.Fatalf("exit %d", code) | |
| 73 | } | |
| 74 | devices, _ := c.Store.PushDevices(c.User.ID) | |
| 75 | if len(devices) != 1 { | |
| 76 | t.Fatalf("want one device, got %d", len(devices)) | |
| 77 | } | |
| 78 | want := fmt.Sprintf(`"id":%d`, devices[0].ID) | |
| 79 | if !strings.Contains(out.String(), want) { | |
| 80 | t.Fatalf("output %s does not carry %s", out.String(), want) | |
| 81 | } | |
| 82 | } | |
| 83 | ``` | |
| 84 | ||
| 85 | 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. | |
| 86 | ||
| 87 | - [ ] **Step 3: Run it and confirm it fails** | |
| 88 | ||
| 89 | Run: `go test ./internal/control/ -run TestNotificationsDeviceAddReturnsTheID -v` | |
| 90 | Expected: FAIL — the output carries `"status":"registered"` and no id. | |
| 91 | ||
| 92 | - [ ] **Step 4: Return the id** | |
| 93 | ||
| 94 | In `runNotificationsDeviceAdd`, `AddPushDevice` already returns the id — capture it instead of discarding it, and emit it: | |
| 95 | ||
| 96 | ```go | |
| 97 | id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")) | |
| 98 | if err != nil { | |
| 99 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 100 | } | |
| 101 | return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) { | |
| 102 | fmt.Fprintf(w, "device %d registered\n", id) | |
| 103 | }) | |
| 104 | ``` | |
| 105 | ||
| 106 | - [ ] **Step 5: Confirm it passes** | |
| 107 | ||
| 108 | Run: `go test ./internal/control/ -run TestNotifications -v` | |
| 109 | Expected: PASS, including the pre-existing notification tests. | |
| 110 | ||
| 111 | - [ ] **Step 6: Write the failing test for the badge** | |
| 112 | ||
| 113 | Append to `internal/store/push_test.go`: | |
| 114 | ||
| 115 | ```go | |
| 116 | // The queue row carries the recipient's unread count, so the alert can | |
| 117 | // badge the app icon. Counted at send rather than at enqueue: an inbox | |
| 118 | // cleared in the seconds before delivery is reflected. | |
| 119 | func TestDuePushCarriesTheUnreadCount(t *testing.T) { | |
| 120 | s := pushFixture(t) | |
| 121 | uid, err := s.CreateUser("alice", false) | |
| 122 | if err != nil { | |
| 123 | t.Fatal(err) | |
| 124 | } | |
| 125 | repoID, err := s.CreateRepo("user", uid, "app", "public") | |
| 126 | if err != nil { | |
| 127 | t.Fatal(err) | |
| 128 | } | |
| 129 | if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil { | |
| 130 | t.Fatal(err) | |
| 131 | } | |
| 132 | // Two unread inbox rows, then a queued push. | |
| 133 | for i := 0; i < 2; i++ { | |
| 134 | if err := s.AddNotice(uid, repoID, "issue", "bob", "opened issue #1", "alice/app/issues/1"); err != nil { | |
| 135 | t.Fatal(err) | |
| 136 | } | |
| 137 | } | |
| 138 | if err := s.EnqueuePush(uid, "alice/app", "bob opened issue #1", "alice/app/issues/1"); err != nil { | |
| 139 | t.Fatal(err) | |
| 140 | } | |
| 141 | due, err := s.DuePush(20) | |
| 142 | if err != nil { | |
| 143 | t.Fatal(err) | |
| 144 | } | |
| 145 | if len(due) != 1 { | |
| 146 | t.Fatalf("want one queued push, got %d", len(due)) | |
| 147 | } | |
| 148 | if due[0].Badge != 2 { | |
| 149 | t.Fatalf("Badge = %d, want 2", due[0].Badge) | |
| 150 | } | |
| 151 | } | |
| 152 | ``` | |
| 153 | ||
| 154 | Check `CreateRepo` and `AddNotice`'s real signatures before relying on them. | |
| 155 | ||
| 156 | - [ ] **Step 7: Run it and confirm it fails** | |
| 157 | ||
| 158 | Run: `go test ./internal/store/ -run TestDuePushCarriesTheUnreadCount -v` | |
| 159 | Expected: FAIL — `QueuedPush has no field or method Badge`. | |
| 160 | ||
| 161 | - [ ] **Step 8: Carry the count** | |
| 162 | ||
| 163 | Add to `QueuedPush` in `internal/store/push.go`: | |
| 164 | ||
| 165 | ```go | |
| 166 | // Badge is the recipient's unread inbox count, for the alert's badge. | |
| 167 | // Counted here rather than at enqueue so a cleared inbox is reflected. | |
| 168 | Badge int | |
| 169 | ``` | |
| 170 | ||
| 171 | In `DuePush`, add the subquery to the SELECT and the scan target: | |
| 172 | ||
| 173 | ```sql | |
| 174 | SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts, | |
| 175 | (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL) | |
| 176 | ``` | |
| 177 | ||
| 178 | ```go | |
| 179 | if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil { | |
| 180 | ``` | |
| 181 | ||
| 182 | - [ ] **Step 9: Send it** | |
| 183 | ||
| 184 | In `internal/push/apns.go`, add `badge int` to `Send`'s parameters after `user`, and put it in the `aps` dictionary: | |
| 185 | ||
| 186 | ```go | |
| 187 | "aps": map[string]any{ | |
| 188 | "alert": map[string]string{"title": title, "body": body}, | |
| 189 | "sound": "default", | |
| 190 | "badge": badge, | |
| 191 | "thread-id": title, | |
| 192 | }, | |
| 193 | ``` | |
| 194 | ||
| 195 | In `internal/push/push.go`, pass it: `d.Cl.Send(ctx, q.Token, q.Username, q.Badge, q.Title, q.Body, q.Path)`. | |
| 196 | ||
| 197 | 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`. | |
| 198 | ||
| 199 | - [ ] **Step 10: Verify** | |
| 200 | ||
| 201 | Run: `go build ./... && go vet ./... && go test ./internal/... ./cmd/...` | |
| 202 | Expected: all pass. `go vet` is what catches stale test callers after the signature change. | |
| 203 | ||
| 204 | 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`. | |
| 205 | ||
| 206 | - [ ] **Step 11: Docs** | |
| 207 | ||
| 208 | 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. | |
| 209 | ||
| 210 | - [ ] **Step 12: Commit and open the MR** | |
| 211 | ||
| 212 | ```bash | |
| 213 | git add -u && git -c commit.gpgsign=true commit -m "push: return the device id and badge the alert | |
| 214 | ||
| 215 | device add returned only a status, so a client that wants to | |
| 216 | deregister had to list devices and match its own token against the | |
| 217 | truncated display value. It now returns the id AddPushDevice already | |
| 218 | had. | |
| 219 | ||
| 220 | The alert carries the recipient's unread count, counted in DuePush at | |
| 221 | send rather than at enqueue so a cleared inbox is reflected. | |
| 222 | ||
| 223 | Ref #89" | |
| 224 | ``` | |
| 225 | ||
| 226 | ```bash | |
| 227 | git push -u origin push-device-id-badge | |
| 228 | ``` | |
| 229 | ||
| 230 | 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. | |
| 231 | ||
| 232 | --- | |
| 233 | ||
| 234 | ### Task 2: PushPayload | |
| 235 | ||
| 236 | **Repository: `~/git/krz/gitbay-ios`** for this and every task below. Branch `push` already exists. | |
| 237 | ||
| 238 | **Files:** | |
| 239 | - Create: `gitbay/Push/PushPayload.swift` | |
| 240 | - Create: `gitbayTests/PushPayloadTests.swift` | |
| 241 | ||
| 242 | **Interfaces:** | |
| 243 | - Consumes: nothing. | |
| 244 | - Produces: | |
| 245 | - `nonisolated struct PushPayload: Equatable, Sendable { let instance: URL; let user: String; let path: String; let badge: Int }` | |
| 246 | - `init?(userInfo: [AnyHashable: Any])` | |
| 247 | ||
| 248 | - [ ] **Step 1: Write the failing test** | |
| 249 | ||
| 250 | `gitbayTests/PushPayloadTests.swift`: | |
| 251 | ||
| 252 | ```swift | |
| 253 | import Foundation | |
| 254 | import Testing | |
| 255 | @testable import gitbay | |
| 256 | ||
| 257 | @Test func payloadDecodesTheAccountAndPath() throws { | |
| 258 | let userInfo: [AnyHashable: Any] = [ | |
| 259 | "aps": ["alert": ["title": "krz/gitbay", "body": "cmc opened issue #12"], "badge": 3], | |
| 260 | "instance": "https://gitbay.org", | |
| 261 | "user": "cmc", | |
| 262 | "path": "krz/gitbay/issues/12", | |
| 263 | ] | |
| 264 | let payload = try #require(PushPayload(userInfo: userInfo)) | |
| 265 | #expect(payload.instance == URL(string: "https://gitbay.org")) | |
| 266 | #expect(payload.user == "cmc") | |
| 267 | #expect(payload.path == "krz/gitbay/issues/12") | |
| 268 | #expect(payload.badge == 3) | |
| 269 | } | |
| 270 | ||
| 271 | @Test func payloadTreatsAMissingBadgeAsZero() throws { | |
| 272 | let userInfo: [AnyHashable: Any] = [ | |
| 273 | "aps": ["alert": ["title": "t", "body": "b"]], | |
| 274 | "instance": "https://gitbay.org", "user": "cmc", "path": "a/b/issues/1", | |
| 275 | ] | |
| 276 | let payload = try #require(PushPayload(userInfo: userInfo)) | |
| 277 | #expect(payload.badge == 0) | |
| 278 | } | |
| 279 | ||
| 280 | // A payload from a server that predates the account fields, or one | |
| 281 | // mangled in transit, is dropped rather than guessed at. | |
| 282 | @Test func payloadRejectsAnIncompleteNotification() { | |
| 283 | #expect(PushPayload(userInfo: ["aps": ["alert": "x"], "path": "a/b/issues/1"]) == nil) | |
| 284 | #expect(PushPayload(userInfo: ["instance": "https://gitbay.org", "user": "cmc"]) == nil) | |
| 285 | #expect(PushPayload(userInfo: ["instance": "not a url at all", "user": "cmc", "path": "a/b/issues/1"]) == nil) | |
| 286 | } | |
| 287 | ``` | |
| 288 | ||
| 289 | - [ ] **Step 2: Run it and confirm it fails** | |
| 290 | ||
| 291 | ```bash | |
| 292 | cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 293 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' \ | |
| 294 | -only-testing:gitbayTests/payloadDecodesTheAccountAndPath test 2>&1 | tail -20 | |
| 295 | ``` | |
| 296 | ||
| 297 | Expected: FAIL — `cannot find 'PushPayload' in scope`. | |
| 298 | ||
| 299 | - [ ] **Step 3: Write the implementation** | |
| 300 | ||
| 301 | `gitbay/Push/PushPayload.swift`: | |
| 302 | ||
| 303 | ```swift | |
| 304 | import Foundation | |
| 305 | ||
| 306 | /// A push notification as the app reads it. | |
| 307 | /// | |
| 308 | /// `instance` and `user` name the account the notice belongs to. One | |
| 309 | /// device token is one install, and an install registers against every | |
| 310 | /// account signed in on it, so `path` alone is ambiguous — two | |
| 311 | /// instances can hold the same `owner/name`. | |
| 312 | nonisolated struct PushPayload: Equatable, Sendable { | |
| 313 | let instance: URL | |
| 314 | let user: String | |
| 315 | let path: String | |
| 316 | /// The recipient's unread count, for the app icon badge. Absent on | |
| 317 | /// a notification from a server that predates it. | |
| 318 | let badge: Int | |
| 319 | ||
| 320 | init?(userInfo: [AnyHashable: Any]) { | |
| 321 | guard let raw = userInfo["instance"] as? String, | |
| 322 | let instance = URL(string: raw), instance.scheme != nil, | |
| 323 | let user = userInfo["user"] as? String, !user.isEmpty, | |
| 324 | let path = userInfo["path"] as? String, !path.isEmpty | |
| 325 | else { return nil } | |
| 326 | self.instance = instance | |
| 327 | self.user = user | |
| 328 | self.path = path | |
| 329 | self.badge = (userInfo["aps"] as? [AnyHashable: Any])?["badge"] as? Int ?? 0 | |
| 330 | } | |
| 331 | } | |
| 332 | ``` | |
| 333 | ||
| 334 | - [ ] **Step 4: Confirm it passes** | |
| 335 | ||
| 336 | Run the three tests by name as in Step 2. | |
| 337 | Expected: PASS. | |
| 338 | ||
| 339 | - [ ] **Step 5: Commit** | |
| 340 | ||
| 341 | ```bash | |
| 342 | git add gitbay/Push/PushPayload.swift gitbayTests/PushPayloadTests.swift | |
| 343 | git -c commit.gpgsign=true commit -m "push: decode a notification payload | |
| 344 | ||
| 345 | instance and user name the account a notice belongs to; path alone is | |
| 346 | ambiguous when one install is registered against several. | |
| 347 | ||
| 348 | Ref krz/gitbay#89" | |
| 349 | ``` | |
| 350 | ||
| 351 | --- | |
| 352 | ||
| 353 | ### Task 3: Route parsing shared with the inbox, and PushTarget | |
| 354 | ||
| 355 | **Files:** | |
| 356 | - Modify: `gitbay/Account/NotificationsViewModel.swift:29-40` (`InboxNotification.destination`) | |
| 357 | - Create: `gitbay/Push/PushTarget.swift` | |
| 358 | - Create: `gitbayTests/PushTargetTests.swift` | |
| 359 | ||
| 360 | **Interfaces:** | |
| 361 | - Consumes: `PushPayload` from Task 2; `NotificationDestination` (existing, cases `.issue`/`.mr`/`.build`, each `(repo: String, number: Int64)`). | |
| 362 | - Produces: | |
| 363 | - `nonisolated func notificationDestination(forPath path: String) -> NotificationDestination?` | |
| 364 | - `nonisolated struct PushTarget: Equatable, Sendable { let accountID: String; let destination: NotificationDestination }` | |
| 365 | - `init?(payload: PushPayload, accounts: [Account])` | |
| 366 | ||
| 367 | - [ ] **Step 1: Extract the existing path parsing** | |
| 368 | ||
| 369 | `InboxNotification.destination` already parses the path. Move its body to a free function in the same file so both callers share one parser, and have the property call it: | |
| 370 | ||
| 371 | ```swift | |
| 372 | /// Where a notification's `path` points — a web-style route, | |
| 373 | /// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong | |
| 374 | /// section, missing or non-numeric id, wrong number of components) | |
| 375 | /// yields nil. The merge request section is `mrs` on the wire, never | |
| 376 | /// `merge_requests`. | |
| 377 | nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { | |
| 378 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) | |
| 379 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | |
| 380 | let repo = "\(parts[0])/\(parts[1])" | |
| 381 | switch parts[2] { | |
| 382 | case "issues": return .issue(repo: repo, number: number) | |
| 383 | case "mrs": return .mr(repo: repo, number: number) | |
| 384 | case "builds": return .build(repo: repo, number: number) | |
| 385 | default: return nil | |
| 386 | } | |
| 387 | } | |
| 388 | ``` | |
| 389 | ||
| 390 | and in `InboxNotification`: | |
| 391 | ||
| 392 | ```swift | |
| 393 | var destination: NotificationDestination? { notificationDestination(forPath: path) } | |
| 394 | ``` | |
| 395 | ||
| 396 | Keep the existing doc comment on the property. | |
| 397 | ||
| 398 | - [ ] **Step 2: Write the failing test** | |
| 399 | ||
| 400 | `gitbayTests/PushTargetTests.swift`: | |
| 401 | ||
| 402 | ```swift | |
| 403 | import Foundation | |
| 404 | import Testing | |
| 405 | @testable import gitbay | |
| 406 | ||
| 407 | private func account(_ host: String, _ user: String) throws -> Account { | |
| 408 | Account(instance: try GitbayInstance(url: "https://\(host)"), username: user) | |
| 409 | } | |
| 410 | ||
| 411 | private func payload(_ instance: String, _ user: String, _ path: String) throws -> PushPayload { | |
| 412 | try #require(PushPayload(userInfo: [ | |
| 413 | "aps": ["alert": ["title": "t", "body": "b"]], | |
| 414 | "instance": instance, "user": user, "path": path, | |
| 415 | ])) | |
| 416 | } | |
| 417 | ||
| 418 | @Test func targetResolvesToTheMatchingAccount() throws { | |
| 419 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | |
| 420 | let target = try #require(PushTarget( | |
| 421 | payload: try payload("https://dev.local", "cmc", "krz/gitbay/mrs/7"), | |
| 422 | accounts: accounts)) | |
| 423 | #expect(target.accountID == accounts[1].id) | |
| 424 | #expect(target.destination == .mr(repo: "krz/gitbay", number: 7)) | |
| 425 | } | |
| 426 | ||
| 427 | // Same username on two instances is two accounts; the instance decides. | |
| 428 | @Test func targetDistinguishesTheSameUserOnTwoInstances() throws { | |
| 429 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | |
| 430 | let target = try #require(PushTarget( | |
| 431 | payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"), | |
| 432 | accounts: accounts)) | |
| 433 | #expect(target.accountID == accounts[0].id) | |
| 434 | } | |
| 435 | ||
| 436 | @Test func targetIsNilForAnAccountSignedOut() throws { | |
| 437 | let accounts = [try account("gitbay.org", "cmc")] | |
| 438 | #expect(PushTarget(payload: try payload("https://other.example", "cmc", "a/b/issues/1"), | |
| 439 | accounts: accounts) == nil) | |
| 440 | } | |
| 441 | ||
| 442 | // A repository root, or a section the app has no screen for, resolves | |
| 443 | // to no target: the tap opens the app without navigating. | |
| 444 | @Test func targetIsNilForAnUnroutablePath() throws { | |
| 445 | let accounts = [try account("gitbay.org", "cmc")] | |
| 446 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay"), | |
| 447 | accounts: accounts) == nil) | |
| 448 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay/wiki/Home"), | |
| 449 | accounts: accounts) == nil) | |
| 450 | } | |
| 451 | ``` | |
| 452 | ||
| 453 | Check `GitbayInstance`'s real initializer before relying on `init(url:)`. | |
| 454 | ||
| 455 | - [ ] **Step 3: Run it and confirm it fails** | |
| 456 | ||
| 457 | Expected: FAIL — `cannot find 'PushTarget' in scope`. | |
| 458 | ||
| 459 | - [ ] **Step 4: Write the implementation** | |
| 460 | ||
| 461 | `gitbay/Push/PushTarget.swift`: | |
| 462 | ||
| 463 | ```swift | |
| 464 | import Foundation | |
| 465 | ||
| 466 | /// A tapped notification resolved against the accounts signed in on | |
| 467 | /// this device: which account it belongs to, and the screen it opens. | |
| 468 | nonisolated struct PushTarget: Equatable, Sendable { | |
| 469 | let accountID: String | |
| 470 | let destination: NotificationDestination | |
| 471 | ||
| 472 | /// Nil when no signed-in account matches — you signed out of it — | |
| 473 | /// or when the path names nothing the app can route to. | |
| 474 | init?(payload: PushPayload, accounts: [Account]) { | |
| 475 | guard let destination = notificationDestination(forPath: payload.path) else { return nil } | |
| 476 | let match = accounts.first { | |
| 477 | $0.username == payload.user && $0.instance.baseURL == payload.instance | |
| 478 | } | |
| 479 | guard let match else { return nil } | |
| 480 | self.accountID = match.id | |
| 481 | self.destination = destination | |
| 482 | } | |
| 483 | } | |
| 484 | ``` | |
| 485 | ||
| 486 | 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. | |
| 487 | ||
| 488 | - [ ] **Step 5: Confirm it passes** | |
| 489 | ||
| 490 | Run all four `PushTarget` tests plus the existing notification tests, since Step 1 touched shared parsing: | |
| 491 | ||
| 492 | ```bash | |
| 493 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 494 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' \ | |
| 495 | -only-testing:gitbayTests test 2>&1 | tail -20 | |
| 496 | ``` | |
| 497 | ||
| 498 | Expected: PASS, with no regression in the inbox notification tests. | |
| 499 | ||
| 500 | - [ ] **Step 6: Commit** | |
| 501 | ||
| 502 | ```bash | |
| 503 | git add gitbay/Push/PushTarget.swift gitbayTests/PushTargetTests.swift gitbay/Account/NotificationsViewModel.swift | |
| 504 | git -c commit.gpgsign=true commit -m "push: resolve a payload to an account and a screen | |
| 505 | ||
| 506 | The path parsing the inbox already did becomes a free function both | |
| 507 | callers share rather than a second copy. | |
| 508 | ||
| 509 | Ref krz/gitbay#89" | |
| 510 | ``` | |
| 511 | ||
| 512 | --- | |
| 513 | ||
| 514 | ### Task 4: SessionStore vends clients and announces removals | |
| 515 | ||
| 516 | **Files:** | |
| 517 | - Modify: `gitbay/Auth/SessionStore.swift` | |
| 518 | - Modify: `gitbay/Views/Repos/RepoListView.swift:283` | |
| 519 | - Create: `gitbayTests/SessionClientTests.swift` | |
| 520 | ||
| 521 | **Interfaces:** | |
| 522 | - Consumes: nothing. | |
| 523 | - Produces: | |
| 524 | - `func client(for account: Account) -> GitbayClient?` | |
| 525 | - `var willRemoveAccount: ((Account) async -> Void)?` | |
| 526 | - `func remove(_ account: Account) async` — note this becomes `async` | |
| 527 | ||
| 528 | - [ ] **Step 1: Write the failing test** | |
| 529 | ||
| 530 | `gitbayTests/SessionClientTests.swift`. `gitbayTests/SessionStoreTests.swift:42-52` already has a `makeSession` fixture using `MemoryTokenStore` and a stubbed session — it is `private`, so copy its shape into this file rather than trying to share it: | |
| 531 | ||
| 532 | ```swift | |
| 533 | import Foundation | |
| 534 | import Testing | |
| 535 | @testable import gitbay | |
| 536 | ||
| 537 | private let whoami = """ | |
| 538 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 539 | """ | |
| 540 | ||
| 541 | @MainActor | |
| 542 | private func makeSession() -> (SessionStore, StubProtocol.Box) { | |
| 543 | let box = StubProtocol.box() | |
| 544 | let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! | |
| 545 | let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in | |
| 546 | GitbayClient(instance: instance, token: token, session: box.session()) | |
| 547 | } | |
| 548 | return (session, box) | |
| 549 | } | |
| 550 | ||
| 551 | /// Signs two accounts in, on different instances. | |
| 552 | @MainActor | |
| 553 | private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { | |
| 554 | let (session, box) = makeSession() | |
| 555 | box.enqueue(.init(status: 200, json: whoami)) | |
| 556 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | |
| 557 | box.enqueue(.init(status: 200, json: whoami)) | |
| 558 | try await session.signIn(instanceURL: "https://dev.local", token: "t2") | |
| 559 | return (session, box) | |
| 560 | } | |
| 561 | ||
| 562 | @Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws { | |
| 563 | let (session, _) = try await twoAccounts() | |
| 564 | let inactive = try #require(session.accounts.first { $0.id != session.current?.id }) | |
| 565 | #expect(session.client(for: inactive) != nil) | |
| 566 | // Vending does not change which account is active. | |
| 567 | #expect(session.current?.id != inactive.id) | |
| 568 | } | |
| 569 | ||
| 570 | @Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws { | |
| 571 | let (session, _) = try await twoAccounts() | |
| 572 | let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x") | |
| 573 | #expect(session.client(for: stranger) == nil) | |
| 574 | } | |
| 575 | ||
| 576 | // The hook runs before the token is discarded: deregistering a device | |
| 577 | // needs the credential remove() is about to delete. | |
| 578 | @Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws { | |
| 579 | let (session, _) = try await twoAccounts() | |
| 580 | let victim = try #require(session.accounts.first) | |
| 581 | var sawTokenDuringHook: Bool? | |
| 582 | // [weak session] because this closure is stored ON session; the | |
| 583 | // production wiring in gitbayApp captures the registrar weakly for | |
| 584 | // the same reason. | |
| 585 | session.willRemoveAccount = { [weak session] account in | |
| 586 | sawTokenDuringHook = session?.client(for: account) != nil | |
| 587 | } | |
| 588 | await session.remove(victim) | |
| 589 | #expect(sawTokenDuringHook == true) | |
| 590 | #expect(session.client(for: victim) == nil) | |
| 591 | } | |
| 592 | ``` | |
| 593 | ||
| 594 | `MemoryTokenStore` is defined privately in `SessionStoreTests.swift`; copy it too, or promote it to a shared test helper file — either is fine, but do not leave two diverging copies. | |
| 595 | ||
| 596 | - [ ] **Step 2: Run it and confirm it fails** | |
| 597 | ||
| 598 | Expected: FAIL — `value of type 'SessionStore' has no member 'client(for:)'`. | |
| 599 | ||
| 600 | - [ ] **Step 3: Implement** | |
| 601 | ||
| 602 | In `SessionStore`: | |
| 603 | ||
| 604 | ```swift | |
| 605 | /// A client for any signed-in account, active or not. Push | |
| 606 | /// registration talks to every account on the device, and `store` | |
| 607 | /// and `makeClient` are private. Vending does not change `current`. | |
| 608 | func client(for account: Account) -> GitbayClient? { | |
| 609 | guard let token = store.token(for: account.id) else { return nil } | |
| 610 | return makeClient(account.instance, token) | |
| 611 | } | |
| 612 | ||
| 613 | /// Runs before an account is removed, while its token still exists. | |
| 614 | /// `gitbayApp` points this at push deregistration; this type knows | |
| 615 | /// nothing about push beyond that something wants to run first. | |
| 616 | var willRemoveAccount: ((Account) async -> Void)? | |
| 617 | ``` | |
| 618 | ||
| 619 | and make `remove` async, awaiting the hook first: | |
| 620 | ||
| 621 | ```swift | |
| 622 | func remove(_ account: Account) async { | |
| 623 | await willRemoveAccount?(account) | |
| 624 | store.deleteToken(for: account.id) | |
| 625 | // ... the rest unchanged ... | |
| 626 | } | |
| 627 | ``` | |
| 628 | ||
| 629 | - [ ] **Step 4: Update the one call site** | |
| 630 | ||
| 631 | `gitbay/Views/Repos/RepoListView.swift:283` becomes: | |
| 632 | ||
| 633 | ```swift | |
| 634 | Task { await session.remove(current) } | |
| 635 | ``` | |
| 636 | ||
| 637 | - [ ] **Step 5: Confirm it passes** | |
| 638 | ||
| 639 | Run the whole `gitbayTests` suite — `remove`'s signature changed and other tests may call it. | |
| 640 | Expected: PASS. | |
| 641 | ||
| 642 | - [ ] **Step 6: Commit** | |
| 643 | ||
| 644 | ```bash | |
| 645 | git add gitbay/Auth/SessionStore.swift gitbay/Views/Repos/RepoListView.swift gitbayTests/SessionClientTests.swift | |
| 646 | git -c commit.gpgsign=true commit -m "auth: vend clients per account, announce removals | |
| 647 | ||
| 648 | Push registration talks to every signed-in account, not just the | |
| 649 | active one, and deregistration needs the token remove() discards. | |
| 650 | ||
| 651 | Ref krz/gitbay#89" | |
| 652 | ``` | |
| 653 | ||
| 654 | --- | |
| 655 | ||
| 656 | ### Task 5: PushRegistrar | |
| 657 | ||
| 658 | **Files:** | |
| 659 | - Create: `gitbay/Push/PushRegistrar.swift` | |
| 660 | - Create: `gitbayTests/PushRegistrarTests.swift` | |
| 661 | ||
| 662 | **Interfaces:** | |
| 663 | - Consumes: `SessionStore.client(for:)` and `accounts` from Task 4. | |
| 664 | - Produces: | |
| 665 | - `@MainActor @Observable final class PushRegistrar` | |
| 666 | - `init(session: SessionStore, defaults: UserDefaults = .standard)` | |
| 667 | - `func deviceTokenArrived(_ data: Data) async` | |
| 668 | - `func registerAll() async` | |
| 669 | - `func deregister(_ account: Account) async` | |
| 670 | ||
| 671 | - [ ] **Step 1: Write the failing test** | |
| 672 | ||
| 673 | `gitbayTests/PushRegistrarTests.swift`. Use `StubProtocol` as the other client tests do: | |
| 674 | ||
| 675 | ```swift | |
| 676 | @Test @MainActor func registrarRegistersEveryAccountOnce() async throws { | |
| 677 | // session with two accounts; stub returns {"id":7,"status":"registered"} | |
| 678 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 679 | await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef])) | |
| 680 | ||
| 681 | let adds = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) } | |
| 682 | #expect(adds.count == 2) | |
| 683 | // The token is the lowercase hex of the raw bytes, on stdin, never argv. | |
| 684 | #expect(stdin(of: adds[0]) == "deadbeef") | |
| 685 | #expect(!argv(of: adds[0]).contains("deadbeef")) | |
| 686 | } | |
| 687 | ||
| 688 | @Test @MainActor func registrarDeregistersWithTheStoredID() async throws { | |
| 689 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 690 | await registrar.deviceTokenArrived(Data([0x01])) | |
| 691 | box.reset() | |
| 692 | ||
| 693 | await registrar.deregister(try #require(session.accounts.first)) | |
| 694 | let removes = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) } | |
| 695 | #expect(removes.count == 1) | |
| 696 | #expect(argv(of: removes[0]).last == "7") | |
| 697 | } | |
| 698 | ||
| 699 | // Registration is a side channel: a failure leaves the account working | |
| 700 | // and is retried on the next launch, not surfaced. | |
| 701 | @Test @MainActor func registrarSurvivesAFailedRegistration() async throws { | |
| 702 | // stub returns 500 | |
| 703 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 704 | await registrar.deviceTokenArrived(Data([0x01])) // must not throw | |
| 705 | } | |
| 706 | ``` | |
| 707 | ||
| 708 | 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. | |
| 709 | ||
| 710 | - [ ] **Step 2: Run it and confirm it fails** | |
| 711 | ||
| 712 | Expected: FAIL — `cannot find 'PushRegistrar' in scope`. | |
| 713 | ||
| 714 | - [ ] **Step 3: Implement** | |
| 715 | ||
| 716 | `gitbay/Push/PushRegistrar.swift`: | |
| 717 | ||
| 718 | ```swift | |
| 719 | import Foundation | |
| 720 | import Observation | |
| 721 | ||
| 722 | /// Registers this install's APNs token against every signed-in account, | |
| 723 | /// and deregisters one when its account goes away. | |
| 724 | /// | |
| 725 | /// A device row is keyed on (user, token) server-side, so one install | |
| 726 | /// holds a row per account. Registering only the active account would | |
| 727 | /// silently stop notifications from the others the moment you switched. | |
| 728 | @Observable | |
| 729 | @MainActor | |
| 730 | final class PushRegistrar { | |
| 731 | ||
| 732 | private let session: SessionStore | |
| 733 | private let defaults: UserDefaults | |
| 734 | ||
| 735 | /// The APNs token as lowercase hex, once iOS has handed it over. | |
| 736 | private(set) var deviceToken: String? | |
| 737 | ||
| 738 | init(session: SessionStore, defaults: UserDefaults = .standard) { | |
| 739 | self.session = session | |
| 740 | self.defaults = defaults | |
| 741 | } | |
| 742 | ||
| 743 | func deviceTokenArrived(_ data: Data) async { | |
| 744 | deviceToken = data.map { String(format: "%02x", $0) }.joined() | |
| 745 | await registerAll() | |
| 746 | } | |
| 747 | ||
| 748 | /// Registers every signed-in account. Apple rotates device tokens, | |
| 749 | /// so this runs on launch too; `device add` upserts on the token, so | |
| 750 | /// a repeat costs one call and changes nothing. | |
| 751 | func registerAll() async { | |
| 752 | guard let token = deviceToken else { return } | |
| 753 | for account in session.accounts { | |
| 754 | await register(account, token: token) | |
| 755 | } | |
| 756 | } | |
| 757 | ||
| 758 | private func register(_ account: Account, token: String) async { | |
| 759 | guard let client = session.client(for: account) else { return } | |
| 760 | struct Registered: Decodable, Sendable { let id: Int64 } | |
| 761 | do { | |
| 762 | let out = try await client.run( | |
| 763 | ["notifications", "device", "add", "--label", Self.deviceLabel], | |
| 764 | stdin: token, as: Registered.self) | |
| 765 | if let id = out?.id { | |
| 766 | // Stored as Int, which UserDefaults handles natively — | |
| 767 | // it has no Int64 overload, so an Int64 goes in as a | |
| 768 | // boxed NSNumber and comes back out through a | |
| 769 | // conditional bridge. Int is 64-bit on every device | |
| 770 | // this ships to. | |
| 771 | defaults.set(Int(id), forKey: Self.idKey(account)) | |
| 772 | } | |
| 773 | } catch { | |
| 774 | // A side channel. The account still works and the next | |
| 775 | // launch registers again. | |
| 776 | } | |
| 777 | } | |
| 778 | ||
| 779 | /// Removes this device from one account, using the id that account | |
| 780 | /// returned at registration. Must run while the account's token | |
| 781 | /// still exists — SessionStore.willRemoveAccount is where it is | |
| 782 | /// wired, and that hook is awaited before the token is discarded. | |
| 783 | func deregister(_ account: Account) async { | |
| 784 | let key = Self.idKey(account) | |
| 785 | // object(forKey:) rather than integer(forKey:), which cannot | |
| 786 | // tell a stored 0 from an absent key. | |
| 787 | guard let id = defaults.object(forKey: key) as? Int, | |
| 788 | let client = session.client(for: account) else { return } | |
| 789 | try? await client.run(["notifications", "device", "remove", String(id)]) | |
| 790 | defaults.removeObject(forKey: key) | |
| 791 | } | |
| 792 | ||
| 793 | private static func idKey(_ account: Account) -> String { | |
| 794 | "pushDeviceID#\(account.id)" | |
| 795 | } | |
| 796 | ||
| 797 | /// Names the row in `notifications device list` on every surface. | |
| 798 | private static var deviceLabel: String { | |
| 799 | UIDevice.current.name | |
| 800 | } | |
| 801 | } | |
| 802 | ``` | |
| 803 | ||
| 804 | `UIDevice` needs `import UIKit`. If `UIDevice.current.name` returns a generic string on this OS version — Apple redacted it for apps without a specific entitlement — use `ProcessInfo.processInfo.hostName` or a fixed `"iPhone"` rather than shipping an empty label, and say which in your report. | |
| 805 | ||
| 806 | - [ ] **Step 4: Confirm it passes** | |
| 807 | ||
| 808 | Run the three registrar tests. | |
| 809 | Expected: PASS. | |
| 810 | ||
| 811 | - [ ] **Step 5: Commit** | |
| 812 | ||
| 813 | ```bash | |
| 814 | git add gitbay/Push/PushRegistrar.swift gitbayTests/PushRegistrarTests.swift | |
| 815 | git -c commit.gpgsign=true commit -m "push: register this device with every signed-in account | |
| 816 | ||
| 817 | Apple rotates tokens, so registration runs on launch as well as on | |
| 818 | arrival; device add upserts, so a repeat is free. Deregistration uses | |
| 819 | the id the server now returns rather than matching a truncated token. | |
| 820 | ||
| 821 | Ref krz/gitbay#89" | |
| 822 | ``` | |
| 823 | ||
| 824 | --- | |
| 825 | ||
| 826 | ### Task 6: Entitlement and capability | |
| 827 | ||
| 828 | **Files:** | |
| 829 | - Create: `gitbay/gitbay.entitlements` | |
| 830 | - Modify: `gitbay.xcodeproj/project.pbxproj` (build settings only) | |
| 831 | ||
| 832 | **Interfaces:** | |
| 833 | - Consumes: nothing. | |
| 834 | - Produces: an app that can call `registerForRemoteNotifications()` without iOS refusing. | |
| 835 | ||
| 836 | This is the one task that edits `project.pbxproj`. It adds a build setting, not a file reference — the synchronized group still handles files. | |
| 837 | ||
| 838 | - [ ] **Step 1: Create the entitlements file** | |
| 839 | ||
| 840 | `gitbay/gitbay.entitlements`: | |
| 841 | ||
| 842 | ```xml | |
| 843 | <?xml version="1.0" encoding="UTF-8"?> | |
| 844 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 845 | <plist version="1.0"> | |
| 846 | <dict> | |
| 847 | <key>aps-environment</key> | |
| 848 | <string>development</string> | |
| 849 | </dict> | |
| 850 | </plist> | |
| 851 | ``` | |
| 852 | ||
| 853 | `development` is correct for local builds; Xcode rewrites it to `production` when it exports for TestFlight and the App Store. Do not set `production` here — that would break debug builds against sandbox APNs. | |
| 854 | ||
| 855 | - [ ] **Step 2: Point the target at it** | |
| 856 | ||
| 857 | 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;`): | |
| 858 | ||
| 859 | ``` | |
| 860 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; | |
| 861 | ``` | |
| 862 | ||
| 863 | Add it beside `CODE_SIGN_STYLE`, keeping the file's tab indentation. Do not touch the test target's configurations. | |
| 864 | ||
| 865 | - [ ] **Step 3: Verify it builds and the entitlement is attached** | |
| 866 | ||
| 867 | ```bash | |
| 868 | cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 869 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' build 2>&1 | tail -5 | |
| 870 | ``` | |
| 871 | ||
| 872 | Expected: `BUILD SUCCEEDED`. A malformed plist or a wrong path fails here with a code-signing error naming the entitlements file. | |
| 873 | ||
| 874 | - [ ] **Step 4: Commit** | |
| 875 | ||
| 876 | ```bash | |
| 877 | git add gitbay/gitbay.entitlements gitbay.xcodeproj/project.pbxproj | |
| 878 | git -c commit.gpgsign=true commit -m "push: the Push Notifications entitlement | |
| 879 | ||
| 880 | aps-environment is development; Xcode rewrites it to production when | |
| 881 | it exports for TestFlight and the App Store. | |
| 882 | ||
| 883 | Ref krz/gitbay#89" | |
| 884 | ``` | |
| 885 | ||
| 886 | --- | |
| 887 | ||
| 888 | ### Task 7: The delegate and the router | |
| 889 | ||
| 890 | **Files:** | |
| 891 | - Create: `gitbay/Push/PushRouter.swift` | |
| 892 | - Create: `gitbay/Push/AppDelegate.swift` | |
| 893 | - Modify: `gitbay/gitbayApp.swift` | |
| 894 | ||
| 895 | **Interfaces:** | |
| 896 | - Consumes: `PushPayload`, `PushTarget`, `PushRegistrar`, `SessionStore`. | |
| 897 | - Produces: | |
| 898 | - `@MainActor @Observable final class PushRouter { var pending: PushTarget? }` | |
| 899 | - `final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate` | |
| 900 | - `PushRegistrar` and `PushRouter` in the environment | |
| 901 | - `func requestPushPermission() async -> Bool` on `PushRegistrar` | |
| 902 | ||
| 903 | - [ ] **Step 1: PushRouter** | |
| 904 | ||
| 905 | `gitbay/Push/PushRouter.swift`: | |
| 906 | ||
| 907 | ```swift | |
| 908 | import Observation | |
| 909 | ||
| 910 | /// Carries a tapped notification's destination into the view tree. | |
| 911 | /// | |
| 912 | /// Owned by `gitbayApp`, above `ContentView` — which is `.id(account.id)` | |
| 913 | /// and is therefore destroyed and rebuilt by the account switch a | |
| 914 | /// cross-account tap performs. A target held inside that subtree would | |
| 915 | /// go with it; held here, the rebuilt tree drains it on appear. | |
| 916 | @Observable | |
| 917 | @MainActor | |
| 918 | final class PushRouter { | |
| 919 | var pending: PushTarget? | |
| 920 | } | |
| 921 | ``` | |
| 922 | ||
| 923 | - [ ] **Step 2: The delegate** | |
| 924 | ||
| 925 | `gitbay/Push/AppDelegate.swift`: | |
| 926 | ||
| 927 | ```swift | |
| 928 | import UIKit | |
| 929 | import UserNotifications | |
| 930 | ||
| 931 | /// The only way to receive an APNs device token and a notification tap. | |
| 932 | /// SwiftUI has no equivalent, so the app keeps one delegate whose whole | |
| 933 | /// job is handing both to the push types. | |
| 934 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { | |
| 935 | ||
| 936 | @MainActor weak var registrar: PushRegistrar? | |
| 937 | @MainActor weak var router: PushRouter? | |
| 938 | @MainActor weak var session: SessionStore? | |
| 939 | ||
| 940 | func application( | |
| 941 | _ application: UIApplication, | |
| 942 | didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil | |
| 943 | ) -> Bool { | |
| 944 | UNUserNotificationCenter.current().delegate = self | |
| 945 | return true | |
| 946 | } | |
| 947 | ||
| 948 | func application( | |
| 949 | _ application: UIApplication, | |
| 950 | didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data | |
| 951 | ) { | |
| 952 | Task { @MainActor in await registrar?.deviceTokenArrived(deviceToken) } | |
| 953 | } | |
| 954 | ||
| 955 | func application( | |
| 956 | _ application: UIApplication, | |
| 957 | didFailToRegisterForRemoteNotificationsWithError error: any Error | |
| 958 | ) { | |
| 959 | // Nothing to do and nothing to say: push is a side channel, and | |
| 960 | // the next launch tries again. | |
| 961 | } | |
| 962 | ||
| 963 | /// A notification arriving while the app is open still shows — you | |
| 964 | /// may be reading a different repository when one lands. | |
| 965 | func userNotificationCenter( | |
| 966 | _ center: UNUserNotificationCenter, | |
| 967 | willPresent notification: UNNotification | |
| 968 | ) async -> UNNotificationPresentationOptions { | |
| 969 | [.banner, .sound, .badge] | |
| 970 | } | |
| 971 | ||
| 972 | func userNotificationCenter( | |
| 973 | _ center: UNUserNotificationCenter, | |
| 974 | didReceive response: UNNotificationResponse | |
| 975 | ) async { | |
| 976 | await MainActor.run { | |
| 977 | guard let session, let router, | |
| 978 | let payload = PushPayload(userInfo: response.notification.request.content.userInfo), | |
| 979 | let target = PushTarget(payload: payload, accounts: session.accounts) | |
| 980 | else { return } | |
| 981 | router.pending = target | |
| 982 | } | |
| 983 | } | |
| 984 | } | |
| 985 | ``` | |
| 986 | ||
| 987 | - [ ] **Step 3: Permission and registration entry point** | |
| 988 | ||
| 989 | Add to `PushRegistrar`: | |
| 990 | ||
| 991 | ```swift | |
| 992 | /// Asks iOS for permission and, on approval, registers for remote | |
| 993 | /// notifications. Called from the notifications screen's toggle, so | |
| 994 | /// the system prompt only appears because the user asked for it. | |
| 995 | func requestPushPermission() async -> Bool { | |
| 996 | let center = UNUserNotificationCenter.current() | |
| 997 | let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false | |
| 998 | if granted { | |
| 999 | UIApplication.shared.registerForRemoteNotifications() | |
| 1000 | } | |
| 1001 | return granted | |
| 1002 | } | |
| 1003 | ||
| 1004 | /// Whether iOS has been asked, and what it said. | |
| 1005 | func authorizationStatus() async -> UNAuthorizationStatus { | |
| 1006 | await UNUserNotificationCenter.current().notificationSettings().authorizationStatus | |
| 1007 | } | |
| 1008 | ||
| 1009 | /// Re-registers on launch when permission is already granted. | |
| 1010 | func registerIfAlreadyAuthorized() async { | |
| 1011 | if await authorizationStatus() == .authorized { | |
| 1012 | UIApplication.shared.registerForRemoteNotifications() | |
| 1013 | } | |
| 1014 | } | |
| 1015 | ``` | |
| 1016 | ||
| 1017 | `import UserNotifications` at the top of that file. | |
| 1018 | ||
| 1019 | - [ ] **Step 4: Wire it in gitbayApp** | |
| 1020 | ||
| 1021 | `gitbay/gitbayApp.swift`: | |
| 1022 | ||
| 1023 | ```swift | |
| 1024 | @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate | |
| 1025 | @State private var session = SessionStore() | |
| 1026 | @State private var router = PushRouter() | |
| 1027 | @State private var registrar: PushRegistrar? | |
| 1028 | ``` | |
| 1029 | ||
| 1030 | and in the scene body, attach: | |
| 1031 | ||
| 1032 | ```swift | |
| 1033 | ContentView() | |
| 1034 | .environment(session) | |
| 1035 | .environment(router) | |
| 1036 | .environment(registrar ?? PushRegistrar(session: session)) | |
| 1037 | .task { | |
| 1038 | let registrar = registrar ?? PushRegistrar(session: session) | |
| 1039 | self.registrar = registrar | |
| 1040 | appDelegate.registrar = registrar | |
| 1041 | appDelegate.router = router | |
| 1042 | appDelegate.session = session | |
| 1043 | session.willRemoveAccount = { [weak registrar] account in | |
| 1044 | await registrar?.deregister(account) | |
| 1045 | } | |
| 1046 | await registrar.registerIfAlreadyAuthorized() | |
| 1047 | } | |
| 1048 | ``` | |
| 1049 | ||
| 1050 | 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`. | |
| 1051 | ||
| 1052 | - [ ] **Step 5: Verify it builds and the suite still passes** | |
| 1053 | ||
| 1054 | ```bash | |
| 1055 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 1056 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -20 | |
| 1057 | ``` | |
| 1058 | ||
| 1059 | 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. | |
| 1060 | ||
| 1061 | - [ ] **Step 6: Commit** | |
| 1062 | ||
| 1063 | ```bash | |
| 1064 | git add gitbay/Push/PushRouter.swift gitbay/Push/AppDelegate.swift gitbay/Push/PushRegistrar.swift gitbay/gitbayApp.swift | |
| 1065 | git -c commit.gpgsign=true commit -m "push: receive the device token and notification taps | |
| 1066 | ||
| 1067 | PushRouter is owned above ContentView because ContentView is keyed on | |
| 1068 | the account id, so a cross-account tap destroys anything held inside | |
| 1069 | it. | |
| 1070 | ||
| 1071 | Ref krz/gitbay#89" | |
| 1072 | ``` | |
| 1073 | ||
| 1074 | --- | |
| 1075 | ||
| 1076 | ### Task 8: Routing a tap to the screen | |
| 1077 | ||
| 1078 | **Files:** | |
| 1079 | - Modify: `gitbay/ContentView.swift:1-48` | |
| 1080 | ||
| 1081 | **Interfaces:** | |
| 1082 | - Consumes: `PushRouter.pending`, `SessionStore.activate(_:)`. | |
| 1083 | - Produces: nothing later tasks use. | |
| 1084 | ||
| 1085 | - [ ] **Step 1: Give the Dashboard tab a path and the TabView a selection** | |
| 1086 | ||
| 1087 | In `ContentView`: | |
| 1088 | ||
| 1089 | ```swift | |
| 1090 | @Environment(PushRouter.self) private var router | |
| 1091 | @State private var selectedTab = Tabs.dashboard | |
| 1092 | @State private var dashboardPath = NavigationPath() | |
| 1093 | ||
| 1094 | enum Tabs { case dashboard, feed, repositories, explore, profile } | |
| 1095 | ``` | |
| 1096 | ||
| 1097 | 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. | |
| 1098 | ||
| 1099 | - [ ] **Step 2: Drain the pending target** | |
| 1100 | ||
| 1101 | On the `TabView`: | |
| 1102 | ||
| 1103 | ```swift | |
| 1104 | .task(id: router.pending) { await routePendingNotification() } | |
| 1105 | ``` | |
| 1106 | ||
| 1107 | and: | |
| 1108 | ||
| 1109 | ```swift | |
| 1110 | /// Routes a tapped notification, switching accounts first when it | |
| 1111 | /// belongs to another one. | |
| 1112 | /// | |
| 1113 | /// A switch changes `account.id`, which rebuilds this whole view. | |
| 1114 | /// That is why the target is left pending across it: the rebuilt | |
| 1115 | /// tree runs this again and finds the account already active, so the | |
| 1116 | /// second pass does the navigating. The drain is the mechanism, not | |
| 1117 | /// a workaround for it. | |
| 1118 | private func routePendingNotification() async { | |
| 1119 | guard let target = router.pending else { return } | |
| 1120 | if session.current?.id != target.accountID { | |
| 1121 | guard let account = session.accounts.first(where: { $0.id == target.accountID }) else { | |
| 1122 | router.pending = nil // signed out of it since the tap | |
| 1123 | return | |
| 1124 | } | |
| 1125 | _ = session.activate(account) | |
| 1126 | return // the rebuilt tree drains it | |
| 1127 | } | |
| 1128 | selectedTab = .dashboard | |
| 1129 | switch target.destination { | |
| 1130 | case .issue(let repo, let number): | |
| 1131 | dashboardPath.append(IssueRoute.issue(repo: repo, number: number)) | |
| 1132 | case .mr(let repo, let number): | |
| 1133 | dashboardPath.append(MRRoute.mr(repo: repo, number: number)) | |
| 1134 | case .build(let repo, let number): | |
| 1135 | dashboardPath.append(BuildRoute.detail(repo: repo, number: number)) | |
| 1136 | } | |
| 1137 | router.pending = nil | |
| 1138 | } | |
| 1139 | ``` | |
| 1140 | ||
| 1141 | Check the three route case names against `RouteDestinations` in the same file — `BuildRoute.detail` in particular — and use whatever is actually there. | |
| 1142 | ||
| 1143 | - [ ] **Step 3: Verify it builds and the suite passes** | |
| 1144 | ||
| 1145 | Run the full test target as in Task 7 Step 5. | |
| 1146 | Expected: `TEST SUCCEEDED`. | |
| 1147 | ||
| 1148 | - [ ] **Step 4: Commit** | |
| 1149 | ||
| 1150 | ```bash | |
| 1151 | git add gitbay/ContentView.swift | |
| 1152 | git -c commit.gpgsign=true commit -m "push: route a tap to the issue, merge request or build | |
| 1153 | ||
| 1154 | A tap for another account switches to it first; the rebuild that | |
| 1155 | causes is what delivers the route, since the pending target outlives | |
| 1156 | the subtree. | |
| 1157 | ||
| 1158 | Ref krz/gitbay#89" | |
| 1159 | ``` | |
| 1160 | ||
| 1161 | --- | |
| 1162 | ||
| 1163 | ### Task 9: The notifications screen | |
| 1164 | ||
| 1165 | **Files:** | |
| 1166 | - Modify: `gitbay/Views/Account/NotificationsView.swift` | |
| 1167 | - Modify: `gitbay/Account/NotificationsViewModel.swift` | |
| 1168 | ||
| 1169 | **Interfaces:** | |
| 1170 | - Consumes: `PushRegistrar.requestPushPermission()`, `authorizationStatus()`, `deregister(_:)`. | |
| 1171 | - Produces: nothing later tasks use. | |
| 1172 | ||
| 1173 | - [ ] **Step 1: Add the push state to the view model** | |
| 1174 | ||
| 1175 | In `NotificationsViewModel`, add the server-side preference and the device list, both read through the client the model already holds: | |
| 1176 | ||
| 1177 | ```swift | |
| 1178 | /// `notifications settings show` reports mail, watch and push. | |
| 1179 | private(set) var pushEnabled = false | |
| 1180 | private(set) var devices: [PushDevice] = [] | |
| 1181 | ||
| 1182 | nonisolated struct PushDevice: Decodable, Sendable, Identifiable { | |
| 1183 | let id: Int64 | |
| 1184 | let label: String | |
| 1185 | let token: String // truncated by the server; never the whole value | |
| 1186 | let added: String | |
| 1187 | } | |
| 1188 | ||
| 1189 | nonisolated struct PushSettings: Decodable, Sendable { | |
| 1190 | let mail: Bool | |
| 1191 | let watch: Bool | |
| 1192 | let push: Bool | |
| 1193 | } | |
| 1194 | ||
| 1195 | func loadPushSettings() async { | |
| 1196 | guard let settings = try? await client.read( | |
| 1197 | ["notifications", "settings", "show"], as: PushSettings.self) else { return } | |
| 1198 | pushEnabled = settings.push | |
| 1199 | devices = (try? await client.readList( | |
| 1200 | ["notifications", "device", "list"], as: PushDevice.self)) ?? [] | |
| 1201 | } | |
| 1202 | ||
| 1203 | func setPush(_ on: Bool) async { | |
| 1204 | await perform(["notifications", "settings", "push", on ? "on" : "off"]) | |
| 1205 | await loadPushSettings() | |
| 1206 | } | |
| 1207 | ||
| 1208 | func removeDevice(_ id: Int64) async { | |
| 1209 | await perform(["notifications", "device", "remove", String(id)]) | |
| 1210 | await loadPushSettings() | |
| 1211 | } | |
| 1212 | ``` | |
| 1213 | ||
| 1214 | 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. | |
| 1215 | ||
| 1216 | 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. | |
| 1217 | ||
| 1218 | - [ ] **Step 2: Add the section to the view** | |
| 1219 | ||
| 1220 | Above the existing list, a `Section("Push notifications")` with one toggle whose three states are: | |
| 1221 | ||
| 1222 | | iOS authorization | Toggle | On change | | |
| 1223 | |---|---|---| | |
| 1224 | | `.notDetermined` | off, enabled | `requestPushPermission()`; on approval `setPush(true)` | | |
| 1225 | | `.authorized` | mirrors `pushEnabled` | `setPush(newValue)` | | |
| 1226 | | `.denied` | off, **disabled** | n/a — show the text below | | |
| 1227 | ||
| 1228 | ```swift | |
| 1229 | @Environment(PushRegistrar.self) private var registrar | |
| 1230 | @State private var authorization: UNAuthorizationStatus = .notDetermined | |
| 1231 | ||
| 1232 | private var pushSection: some View { | |
| 1233 | Section("Push notifications") { | |
| 1234 | Toggle("Push notifications", isOn: Binding( | |
| 1235 | get: { authorization == .authorized && model.pushEnabled }, | |
| 1236 | set: { on in Task { await setPush(on) } } | |
| 1237 | )) | |
| 1238 | .disabled(authorization == .denied) | |
| 1239 | ||
| 1240 | if authorization == .denied { | |
| 1241 | Text("Notifications are turned off for gitbay in Settings.") | |
| 1242 | .font(.gbSans(.footnote)) | |
| 1243 | Button("Open Settings") { | |
| 1244 | if let url = URL(string: UIApplication.openSettingsURLString) { | |
| 1245 | UIApplication.shared.open(url) | |
| 1246 | } | |
| 1247 | } | |
| 1248 | } | |
| 1249 | } | |
| 1250 | .task { authorization = await registrar.authorizationStatus() } | |
| 1251 | } | |
| 1252 | ||
| 1253 | private func setPush(_ on: Bool) async { | |
| 1254 | if on, authorization == .notDetermined { | |
| 1255 | let granted = await registrar.requestPushPermission() | |
| 1256 | authorization = await registrar.authorizationStatus() | |
| 1257 | guard granted else { return } | |
| 1258 | } | |
| 1259 | await model.setPush(on) | |
| 1260 | } | |
| 1261 | ``` | |
| 1262 | ||
| 1263 | 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. | |
| 1264 | ||
| 1265 | 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. | |
| 1266 | ||
| 1267 | - [ ] **Step 3: Verify** | |
| 1268 | ||
| 1269 | Build and run the suite. | |
| 1270 | Expected: `TEST SUCCEEDED`. | |
| 1271 | ||
| 1272 | 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. | |
| 1273 | ||
| 1274 | - [ ] **Step 4: Commit** | |
| 1275 | ||
| 1276 | ```bash | |
| 1277 | git add gitbay/Views/Account/NotificationsView.swift gitbay/Account/NotificationsViewModel.swift | |
| 1278 | git -c commit.gpgsign=true commit -m "push: the toggle and device list on the notifications screen | |
| 1279 | ||
| 1280 | Permission is asked when the toggle is turned on, not on arrival: iOS | |
| 1281 | asks once, and a prompt sprung by navigation earns a reflexive | |
| 1282 | refusal that nothing afterwards can explain. | |
| 1283 | ||
| 1284 | Ref krz/gitbay#89" | |
| 1285 | ``` | |
| 1286 | ||
| 1287 | --- | |
| 1288 | ||
| 1289 | ### Task 10: Documentation | |
| 1290 | ||
| 1291 | **Files:** | |
| 1292 | - Modify: `DESIGN.org:245` (the gap table) | |
| 1293 | - Modify: `README.org` (the capability table) | |
| 1294 | - Modify: `~/git/krz/gitbay/.gitbay/wiki/Parity.org` (the ios column) | |
| 1295 | ||
| 1296 | - [ ] **Step 1: DESIGN.org** | |
| 1297 | ||
| 1298 | 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. | |
| 1299 | ||
| 1300 | - [ ] **Step 2: README.org** | |
| 1301 | ||
| 1302 | 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. | |
| 1303 | ||
| 1304 | - [ ] **Step 3: Parity.org, in the other repository** | |
| 1305 | ||
| 1306 | `~/git/krz/gitbay/.gitbay/wiki/Parity.org` has rows for the push device commands with `no` in the ios column. They become `yes`. That repository requires a branch and a merge request of its own — do not push to its main. | |
| 1307 | ||
| 1308 | - [ ] **Step 4: Commit** | |
| 1309 | ||
| 1310 | Two commits, one per repository. The gitbay-ios one: | |
| 1311 | ||
| 1312 | ```bash | |
| 1313 | git add DESIGN.org README.org | |
| 1314 | git -c commit.gpgsign=true commit -m "docs: push notifications | |
| 1315 | ||
| 1316 | Closes krz/gitbay#89" | |
| 1317 | ``` | |
| 1318 | ||
| 1319 | --- | |
| 1320 | ||
| 1321 | ### Task 11: Live verification and the merge request | |
| 1322 | ||
| 1323 | **Files:** none. | |
| 1324 | ||
| 1325 | - [ ] **Step 1: Verify the branch** | |
| 1326 | ||
| 1327 | ```bash | |
| 1328 | cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 1329 | -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -20 | |
| 1330 | ``` | |
| 1331 | ||
| 1332 | Expected: `TEST SUCCEEDED`. Do not claim the branch is green without this output in front of you. | |
| 1333 | ||
| 1334 | - [ ] **Step 2: Live push test, on a real device** | |
| 1335 | ||
| 1336 | The Simulator cannot receive a push. On a device, with the app installed from Xcode: | |
| 1337 | ||
| 1338 | 1. Sign in to gitbay.org, open the notifications screen, turn the toggle on, grant permission. | |
| 1339 | 2. On the laptop, confirm the device registered: `gitbay notifications device list --json` should show it with a truncated token. | |
| 1340 | 3. From another account — or the web as a second user — comment on an issue in a repository the signed-in account watches. | |
| 1341 | 4. The notification should arrive within a few seconds, titled with the repository. | |
| 1342 | 5. Tap it. The app should open the issue. | |
| 1343 | 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. | |
| 1344 | ||
| 1345 | 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. | |
| 1346 | ||
| 1347 | - [ ] **Step 3: Open the merge request** | |
| 1348 | ||
| 1349 | ```bash | |
| 1350 | git push -u origin push | |
| 1351 | ``` | |
| 1352 | ||
| 1353 | ```bash | |
| 1354 | gitbay mr create --source push --target main --title "Push notifications" | |
| 1355 | ``` | |
| 1356 | ||
| 1357 | 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. | |
| 1358 | ||
| 1359 | - [ ] **Step 4: Do not merge** | |
| 1360 | ||
| 1361 | Report back instead. The App Store resubmission, the privacy nutrition label update and the `aps-environment` production export are the human's to do. | |
| 1362 | ||
| 1363 | --- | |
| 1364 | ||
| 1365 | ## Notes for whoever executes this | |
| 1366 | ||
| 1367 | - **Task 1 is in a different repository** and must merge before Tasks 5 and 9 can work against a real server — `device add` returning the id is what deregistration depends on. | |
| 1368 | - **The Simulator cannot receive a push.** Every unit test here passes with the delivery path entirely unexercised. Task 11 Step 2 is the only thing that proves the feature works, and it needs a real device and an environment-matched build. | |
| 1369 | - **`project.pbxproj` is edited exactly once**, in Task 6, and only to add a build setting. If you find yourself adding file references there, stop — the synchronized group handles files, and a manual reference will conflict with it. | |
| 1370 | - **Check every test helper before using it.** This plan names `notifTestCtx`, `StubProtocol.box()`, `GitbayInstance(url:)` and `BuildRoute.detail` from reading the tree, but earlier work in this feature repeatedly found plan-named helpers that did not exist. Read first, then write. | |
docs/superpowers/specs/2026-09-20-push-notifications-design.md added +269
| @@ -0,0 +1,269 @@ | ||
| 1 | # Push notifications | |
| 2 | ||
| 3 | Closes krz/gitbay#89 for the client. The app receives APNs alerts for | |
| 4 | activity on every account signed in on the device, and a tap opens the | |
| 5 | issue, merge request or build it names. | |
| 6 | ||
| 7 | The server half shipped in gitbay v1.32.0 and is running on gitbay.org. | |
| 8 | Its design is `docs/specs/2026-09-20-ios-push-notifications-design.md` | |
| 9 | in `krz/gitbay`; this spec is the other half and does not restate it. | |
| 10 | ||
| 11 | ## Problem | |
| 12 | ||
| 13 | `DESIGN.org` records push as a known gap: "No push notifications — poll | |
| 14 | on foreground, use background refresh. Not planned; propose if the app | |
| 15 | makes the case." The server now delivers, so the app is what is left. | |
| 16 | ||
| 17 | Background refresh was never going to close it. `BGAppRefreshTask` runs | |
| 18 | when the system feels like it, routinely fifteen minutes to hours after | |
| 19 | the event. A failed build is exactly the notice that is worthless late. | |
| 20 | ||
| 21 | ## Decision | |
| 22 | ||
| 23 | An `AppDelegate` adaptor receives the device token and notification | |
| 24 | taps. A `PushRegistrar` registers that token against every signed-in | |
| 25 | account. A `PushRouter`, owned above the account-keyed view tree, | |
| 26 | carries a tapped notification's destination across the account switch | |
| 27 | it may require. `ContentView` drains it and routes. | |
| 28 | ||
| 29 | Decisions taken on the way, with the alternatives rejected: | |
| 30 | ||
| 31 | - **Every signed-in account, not just the active one.** The server | |
| 32 | keys a device row on (user, token), so one install can hold a row per | |
| 33 | account. Registering only the active account would silently stop | |
| 34 | notifications from the others the moment you switched — surprising | |
| 35 | for the feature whose whole job is reaching you when the app is | |
| 36 | closed. The cost is that the app must talk to accounts that are not | |
| 37 | active, which it never does today. | |
| 38 | - **A tap goes to the item, not to the inbox.** Opening the | |
| 39 | notifications list would need no routing machinery and cost one more | |
| 40 | tap. It is also not what a notification is for. | |
| 41 | - **A cross-account tap switches accounts silently.** Honouring the tap | |
| 42 | requires being in that account's context. A confirmation on every | |
| 43 | such tap would wear thin, and the switch is visible anyway — the | |
| 44 | tab bar resets. What is lost is unsaved state in the account being | |
| 45 | left, in practice a scroll position or an unsent `ComposeSheet` | |
| 46 | draft. | |
| 47 | - **Permission is asked when you turn push on**, from an explicit | |
| 48 | toggle, not on first sight of the notifications screen. iOS asks | |
| 49 | once. A prompt sprung by mere navigation earns a reflexive "Don't | |
| 50 | Allow", after which push is permanently silent with nothing in the | |
| 51 | app explaining why. A toggle the user deliberately taps both raises | |
| 52 | the grant rate and gives the denied state somewhere to live. | |
| 53 | - **The badge comes from the server.** Badging from | |
| 54 | `dashboard.unread` on launch would need no server change and be close | |
| 55 | to useless: the count would refresh only when the app opens, reading | |
| 56 | zero at exactly the moment it should be climbing. | |
| 57 | - **No background modes.** These are alerts. Silent pushes, background | |
| 58 | refresh and Live Activities are all out of scope and none of them is | |
| 59 | needed to close the gap. | |
| 60 | ||
| 61 | ## Server prerequisite | |
| 62 | ||
| 63 | Two additions to `krz/gitbay`, landing before any Swift is written. | |
| 64 | Both fold into the unreleased v1.32.1. | |
| 65 | ||
| 66 | **`notifications device add` returns the device id.** It currently | |
| 67 | returns `{"status": "registered"}`, so an app that wants to deregister | |
| 68 | must call `device list` and match its own token against the *truncated* | |
| 69 | display value. That works — eight hex characters is four billion — but | |
| 70 | it is identity by rendered string. `AddPushDevice` already returns the | |
| 71 | id; the command just has to emit it. | |
| 72 | ||
| 73 | **The payload carries `aps.badge`.** `DuePush` gains a correlated | |
| 74 | subquery counting the recipient's unread inbox rows, the same way it | |
| 75 | now carries the username: | |
| 76 | ||
| 77 | ```sql | |
| 78 | (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL) | |
| 79 | ``` | |
| 80 | ||
| 81 | Counting at send rather than at enqueue costs nothing extra and is | |
| 82 | fresher: an inbox cleared in the seconds before delivery is reflected. | |
| 83 | No migration. | |
| 84 | ||
| 85 | The payload's `instance` and `user` fields landed already (gitbay | |
| 86 | 66304b6) and are deployed. | |
| 87 | ||
| 88 | ## Components | |
| 89 | ||
| 90 | Each is small, has one job, and is testable without a view tree. | |
| 91 | ||
| 92 | | Unit | Does | Depends on | | |
| 93 | |---|---|---| | |
| 94 | | `PushPayload` | Decodes APNs `userInfo` into `instance`, `user`, `path`, `badge` | nothing | | |
| 95 | | `PushTarget` | `(accountID, route)`; resolves a payload against known accounts | `SessionStore.accounts` | | |
| 96 | | `PushRouter` | `@Observable`, holds `pending: PushTarget?` | nothing | | |
| 97 | | `PushRegistrar` | Holds the APNs token; registers and deregisters per account | `SessionStore.client(for:)` | | |
| 98 | | `AppDelegate` | Receives the token and taps; `UNUserNotificationCenterDelegate` | the four above | | |
| 99 | ||
| 100 | `PushTarget` builds its route from the payload's `path` using the | |
| 101 | parsing `InboxNotification.destination` already does — four | |
| 102 | slash-separated segments, the last an integer, mapping to `IssueRoute`, | |
| 103 | `MRRoute` or `BuildRoute`. That parsing moves to a free function both | |
| 104 | call rather than being duplicated; a repository root or an unknown kind | |
| 105 | yields no route, and such a tap opens the app without navigating. | |
| 106 | ||
| 107 | `PushRouter` is owned by `gitbayApp` beside `SessionStore`. That | |
| 108 | placement is the design: `ContentView` is `.id(account.id)`, so | |
| 109 | anything held inside it is destroyed by the account switch a | |
| 110 | cross-account tap performs. | |
| 111 | ||
| 112 | ## Registration | |
| 113 | ||
| 114 | `PushRegistrar` needs a client for accounts that are not active. | |
| 115 | `SessionStore` has the parts, but `store` and `makeClient` are both | |
| 116 | `private`, so it grows one method: | |
| 117 | ||
| 118 | ```swift | |
| 119 | func client(for account: Account) -> GitbayClient? | |
| 120 | ``` | |
| 121 | ||
| 122 | It returns nil for an account with no stored token. The active-client | |
| 123 | model is untouched: this vends a transient client and does not change | |
| 124 | `current` or `client`. | |
| 125 | ||
| 126 | It registers on three events: | |
| 127 | ||
| 128 | - the APNs device token arriving, which is the only time the token is | |
| 129 | known; | |
| 130 | - an account signing in, which adds one that has no row yet; | |
| 131 | - launch, when permission is already granted. Apple rotates device | |
| 132 | tokens, so re-registering on launch is ordinary practice, and | |
| 133 | `device add` upserts on token, so a repeat costs one call and | |
| 134 | changes nothing. | |
| 135 | ||
| 136 | Each registration stores the returned device id against the account. | |
| 137 | ||
| 138 | Deregistration has an ordering constraint: `notifications device | |
| 139 | remove <id>` needs that account's token, which `SessionStore.remove` | |
| 140 | discards. There is exactly one sign-out call site today | |
| 141 | (`RepoListView.swift:283`), so a caller could do it first — but a | |
| 142 | future second call site would silently skip it, and the failure is | |
| 143 | invisible: the server keeps pushing for an account no longer signed | |
| 144 | in, and those taps resolve to nothing. | |
| 145 | ||
| 146 | So `SessionStore` gains a hook rather than the obligation: | |
| 147 | ||
| 148 | ```swift | |
| 149 | var willRemoveAccount: ((Account) async -> Void)? | |
| 150 | ``` | |
| 151 | ||
| 152 | `remove` awaits it before discarding anything, which makes `remove` | |
| 153 | `async`; its one call site wraps the call in a `Task`. Awaiting rather | |
| 154 | than firing and forgetting is the point — `remove`'s first act is | |
| 155 | `store.deleteToken(for:)`, and a concurrent hook would race the | |
| 156 | credential it needs out from under itself. | |
| 157 | ||
| 158 | `gitbayApp` points the hook at the registrar's deregistration at | |
| 159 | construction, in the same place the two objects are already wired | |
| 160 | together. `SessionStore` stays ignorant of push — it knows only that | |
| 161 | something wants to run first. | |
| 162 | ||
| 163 | A deregistration that fails still removes the account. Blocking | |
| 164 | sign-out on a network call to the instance you are leaving would be | |
| 165 | the wrong trade; the stale device row is recoverable from the web | |
| 166 | settings page, and the account is gone either way. | |
| 167 | ||
| 168 | A registration that fails is not retried and not surfaced. Push is a | |
| 169 | side channel; the account still works, and the next launch registers | |
| 170 | again. | |
| 171 | ||
| 172 | ## Routing | |
| 173 | ||
| 174 | A tap decodes the payload and resolves it against | |
| 175 | `SessionStore.accounts`, matching `instance` against `Account.instance` | |
| 176 | and `user` against `Account.username` — together exactly what | |
| 177 | `Account.id` is built from. | |
| 178 | ||
| 179 | - **Same account.** The subtree is live. Select the Dashboard tab, | |
| 180 | append the route to its path, clear `pending`. | |
| 181 | - **Different account.** `session.activate()` first. That changes | |
| 182 | `account.id` and rebuilds the `TabView`. `pending` survives, because | |
| 183 | `PushRouter` sits above it. The new subtree drains it on appear. | |
| 184 | - **No matching account.** You signed out of it. Nothing sensible to | |
| 185 | show, so the app opens without navigating. With deregistration | |
| 186 | working this is transient rather than a standing condition. | |
| 187 | ||
| 188 | The drain is the mechanism, not a workaround: a rebuilt tree appearing | |
| 189 | with a target still pending is the signal to route, and the same code | |
| 190 | path serves both cases. | |
| 191 | ||
| 192 | Only the Dashboard tab gains a bound `NavigationPath`. It is first, and | |
| 193 | it is where your own work already lives. The other four stacks are | |
| 194 | unchanged. `TabView` gains a selection binding. That is the entire | |
| 195 | change to `ContentView`. | |
| 196 | ||
| 197 | A notification arriving while the app is in the foreground shows a | |
| 198 | banner with sound rather than being suppressed — you may be reading a | |
| 199 | different repository when one lands. | |
| 200 | ||
| 201 | ## The notifications screen | |
| 202 | ||
| 203 | `NotificationsView` gains a section above the list with one **Push | |
| 204 | notifications** toggle, representing two independent states: | |
| 205 | ||
| 206 | | State | Toggle | Behaviour | | |
| 207 | |---|---|---| | |
| 208 | | Not yet asked | off | Turning on asks iOS; on approval registers the device and sets `notifications settings push on` | | |
| 209 | | Granted, server on | on | Turning off calls `notifications settings push off`; the OS permission is left alone | | |
| 210 | | Denied in iOS | off, disabled | One line saying notifications are off in Settings, and a button opening them | | |
| 211 | ||
| 212 | The denied row is what the explicit-toggle decision buys. Without it a | |
| 213 | single reflexive "Don't Allow" leaves push silent forever with nothing | |
| 214 | accounting for it. | |
| 215 | ||
| 216 | Below the toggle, this account's registered devices with a remove | |
| 217 | action — the same list the web settings page shows, tokens truncated | |
| 218 | the same way. Removal dispatches `notifications device remove`. | |
| 219 | ||
| 220 | There is no add-a-device control, on any surface. Only the app can mint | |
| 221 | an APNs token, and it does so itself. | |
| 222 | ||
| 223 | ## Project and submission | |
| 224 | ||
| 225 | - The **Push Notifications** capability on `org.gitbay.gitbay`, already | |
| 226 | enabled in the developer portal, needs its matching entitlement in | |
| 227 | the Xcode project. | |
| 228 | - The **privacy nutrition label** gains the device token under | |
| 229 | Identifiers: not linked to the user, not used for tracking. | |
| 230 | - An App Store resubmission. If Apple asks what push is for, it is | |
| 231 | activity on repositories the account follows. | |
| 232 | ||
| 233 | `DESIGN.org`'s "No push notifications" gap row is rewritten to point at | |
| 234 | the feature. The Parity page in `krz/gitbay`'s wiki has three `no`s in | |
| 235 | its ios column for the device commands; they become `yes`. | |
| 236 | ||
| 237 | ## Testing | |
| 238 | ||
| 239 | Unit tests in the repository's existing Swift Testing style: | |
| 240 | ||
| 241 | - a payload decodes to the right `PushPayload`, including a missing | |
| 242 | `badge`; | |
| 243 | - a payload for a signed-in account resolves to that account's id and | |
| 244 | the right route; | |
| 245 | - a payload for an account signed out resolves to nil; | |
| 246 | - a payload whose path is a repository root, or names an unknown kind, | |
| 247 | resolves to an account but no route; | |
| 248 | - `PushRegistrar` against a stubbed store and client calls `device add` | |
| 249 | once per account, and on sign-out calls `device remove` with the id | |
| 250 | it stored for that account and no other. | |
| 251 | ||
| 252 | Not unit tested: the delegate callbacks and the navigation drain, both | |
| 253 | of which need a running app and a real APNs round trip. Those go in the | |
| 254 | live smoke suite (`smoke-account.sh` and the live suite steps), run | |
| 255 | against a device. The Simulator cannot receive a real push. | |
| 256 | ||
| 257 | ## Scope | |
| 258 | ||
| 259 | Two stages, in order: | |
| 260 | ||
| 261 | 1. `krz/gitbay`: the device id in `device add`'s response, and | |
| 262 | `aps.badge` in the payload. One MR, folding into v1.32.1. | |
| 263 | 2. `krz/gitbay-ios`: everything above. | |
| 264 | ||
| 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. | |
gitbay.xcodeproj/project.pbxproj +2
| @@ -411,6 +411,7 @@ | ||
| 411 | 411 | buildSettings = { |
| 412 | 412 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 413 | 413 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 414 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; | |
| 414 | 415 | CODE_SIGN_STYLE = Automatic; |
| 415 | 416 | CURRENT_PROJECT_VERSION = 11; |
| 416 | 417 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| @@ -446,6 +447,7 @@ | ||
| 446 | 447 | buildSettings = { |
| 447 | 448 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 448 | 449 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 450 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; | |
| 449 | 451 | CODE_SIGN_STYLE = Automatic; |
| 450 | 452 | CURRENT_PROJECT_VERSION = 11; |
| 451 | 453 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
gitbay/Account/NotificationsViewModel.swift +77 −11
| @@ -26,16 +26,23 @@ nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiabl | ||
| 26 | 26 | /// or the `repo` field. A shape the app cannot route (wrong section, |
| 27 | 27 | /// missing or non-numeric id, wrong number of components) yields |
| 28 | 28 | /// nil, which a row renders as untappable rather than crashing. |
| 29 | var destination: NotificationDestination? { | |
| 30 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) | |
| 31 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | |
| 32 | let repo = "\(parts[0])/\(parts[1])" | |
| 33 | switch parts[2] { | |
| 34 | case "issues": return .issue(repo: repo, number: number) | |
| 35 | case "mrs": return .mr(repo: repo, number: number) | |
| 36 | case "builds": return .build(repo: repo, number: number) | |
| 37 | default: return nil | |
| 38 | } | |
| 29 | var destination: NotificationDestination? { notificationDestination(forPath: path) } | |
| 30 | } | |
| 31 | ||
| 32 | /// Where a notification's `path` points — a web-style route, | |
| 33 | /// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong | |
| 34 | /// section, missing or non-numeric id, wrong number of components) | |
| 35 | /// yields nil. The merge request section is `mrs` on the wire, never | |
| 36 | /// `merge_requests`. | |
| 37 | nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { | |
| 38 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) | |
| 39 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | |
| 40 | let repo = "\(parts[0])/\(parts[1])" | |
| 41 | switch parts[2] { | |
| 42 | case "issues": return .issue(repo: repo, number: number) | |
| 43 | case "mrs": return .mr(repo: repo, number: number) | |
| 44 | case "builds": return .build(repo: repo, number: number) | |
| 45 | default: return nil | |
| 39 | 46 | } |
| 40 | 47 | } |
| 41 | 48 | |
| @@ -53,6 +60,22 @@ nonisolated enum NotificationDestination: Hashable, Sendable { | ||
| 53 | 60 | @MainActor |
| 54 | 61 | final class NotificationsViewModel { |
| 55 | 62 | |
| 63 | /// One row of `notifications device list`. `token` is already | |
| 64 | /// truncated by the server — never the whole value. | |
| 65 | nonisolated struct PushDevice: Decodable, Sendable, Identifiable { | |
| 66 | let id: Int64 | |
| 67 | let label: String | |
| 68 | let token: String | |
| 69 | let added: String | |
| 70 | } | |
| 71 | ||
| 72 | /// `notifications settings show` reports mail, watch and push. | |
| 73 | nonisolated struct PushSettings: Decodable, Sendable { | |
| 74 | let mail: Bool | |
| 75 | let watch: Bool | |
| 76 | let push: Bool | |
| 77 | } | |
| 78 | ||
| 56 | 79 | let list: PagedListModel<InboxNotification> |
| 57 | 80 | /// Off shows unread only, the default listing; on adds `--all`. A |
| 58 | 81 | /// segmented control is quick to tap repeatedly, so a stale reload |
| @@ -69,6 +92,10 @@ final class NotificationsViewModel { | ||
| 69 | 92 | |
| 70 | 93 | private(set) var actionError: String? |
| 71 | 94 | private(set) var working = false |
| 95 | /// The server-side push preference; both this and iOS authorization | |
| 96 | /// must be on for push to arrive. | |
| 97 | private(set) var pushEnabled = false | |
| 98 | private(set) var devices: [PushDevice] = [] | |
| 72 | 99 | |
| 73 | 100 | private let client: GitbayClient |
| 74 | 101 | private var reloadTask: Task<Void, Never>? |
| @@ -99,21 +126,60 @@ final class NotificationsViewModel { | ||
| 99 | 126 | await perform(["notifications", "read", "--all"]) |
| 100 | 127 | } |
| 101 | 128 | |
| 129 | func loadPushSettings() async { | |
| 130 | guard let settings = try? await client.read( | |
| 131 | ["notifications", "settings", "show"], as: PushSettings.self) else { return } | |
| 132 | pushEnabled = settings.push | |
| 133 | devices = (try? await client.readList( | |
| 134 | ["notifications", "device", "list"], of: PushDevice.self)) ?? [] | |
| 135 | } | |
| 136 | ||
| 137 | /// Unlike `markRead`/`markAllRead`, this does not reload the inbox — | |
| 138 | /// only its own state, so the list on screen does not blank out | |
| 139 | /// behind a spinner for an unrelated fetch. | |
| 140 | func setPush(_ on: Bool) async { | |
| 141 | guard !working else { return } | |
| 142 | await performWrite(["notifications", "settings", "push", on ? "on" : "off"]) | |
| 143 | await loadPushSettings() | |
| 144 | } | |
| 145 | ||
| 146 | /// See `setPush` — no inbox reload here either. | |
| 147 | func removeDevice(_ id: Int64) async { | |
| 148 | guard !working else { return } | |
| 149 | await performWrite(["notifications", "device", "remove", String(id)]) | |
| 150 | await loadPushSettings() | |
| 151 | } | |
| 152 | ||
| 102 | 153 | private func configureList() { |
| 103 | 154 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) |
| 104 | 155 | } |
| 105 | 156 | |
| 157 | /// `markRead`/`markAllRead`'s write: the shared error handling, plus | |
| 158 | /// the inbox reload that makes sense for them but not for the push | |
| 159 | /// settings below. | |
| 106 | 160 | private func perform(_ argv: [String]) async { |
| 161 | if await performWrite(argv) { | |
| 162 | await load() | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | /// The write and its error handling alone, shared by every caller in | |
| 167 | /// this file. Returns whether it succeeded, so a caller can decide | |
| 168 | /// what to reload. | |
| 169 | @discardableResult | |
| 170 | private func performWrite(_ argv: [String]) async -> Bool { | |
| 107 | 171 | working = true |
| 108 | 172 | actionError = nil |
| 109 | 173 | defer { working = false } |
| 110 | 174 | do { |
| 111 | 175 | try await client.run(argv) |
| 112 | await load() | |
| 176 | return true | |
| 113 | 177 | } catch let error as GitbayError { |
| 114 | 178 | actionError = error.userFacingMessage |
| 179 | return false | |
| 115 | 180 | } catch { |
| 116 | 181 | actionError = GitbayError.transport(error).userFacingMessage |
| 182 | return false | |
| 117 | 183 | } |
| 118 | 184 | } |
| 119 | 185 | } |
gitbay/Auth/SessionStore.swift +48 −3
| @@ -76,14 +76,37 @@ final class SessionStore { | ||
| 76 | 76 | currentAccountID = account.id |
| 77 | 77 | signedOutMessage = nil |
| 78 | 78 | watchForRevocation(candidate, account: account) |
| 79 | await didAddAccount?(account) | |
| 79 | 80 | } |
| 80 | 81 | |
| 82 | /// A client for any signed-in account, active or not. Push | |
| 83 | /// registration talks to every account on the device, and `store` | |
| 84 | /// and `makeClient` are private. Vending does not change `current`. | |
| 85 | func client(for account: Account) -> GitbayClient? { | |
| 86 | guard let token = store.token(for: account.id) else { return nil } | |
| 87 | return makeClient(account.instance, token) | |
| 88 | } | |
| 89 | ||
| 90 | /// Runs before an account is removed, while its token still exists. | |
| 91 | /// `gitbayApp` points this at push deregistration; this type knows | |
| 92 | /// nothing about push beyond that something wants to run first. | |
| 93 | var willRemoveAccount: ((Account) async -> Void)? | |
| 94 | ||
| 95 | /// Runs after an account signs in, with its token stored. The | |
| 96 | /// counterpart to `willRemoveAccount`: an account added while the | |
| 97 | /// app runs has no push device row until something registers it, | |
| 98 | /// and nothing else would until the next cold launch. | |
| 99 | var didAddAccount: ((Account) async -> Void)? | |
| 100 | ||
| 81 | 101 | /// Switch to another stored account. Returns false if its token is |
| 82 | 102 | /// gone from the Keychain, in which case the account is dropped too. |
| 83 | 103 | @discardableResult |
| 84 | 104 | func activate(_ account: Account) -> Bool { |
| 85 | 105 | guard let token = store.token(for: account.id) else { |
| 86 | remove(account) | |
| 106 | // Bypasses willRemoveAccount deliberately: store.token(for:) | |
| 107 | // just returned nil, so there is no credential left to | |
| 108 | // deregister a device with. | |
| 109 | removeAccountState(account) | |
| 87 | 110 | return false |
| 88 | 111 | } |
| 89 | 112 | activate(account, token: token) |
| @@ -112,7 +135,20 @@ final class SessionStore { | ||
| 112 | 135 | |
| 113 | 136 | /// Forget an account: token out of the Keychain, account out of the |
| 114 | 137 | /// list, and if it was active, over to the next one or signed out. |
| 115 | func remove(_ account: Account) { | |
| 138 | func remove(_ account: Account) async { | |
| 139 | await willRemoveAccount?(account) | |
| 140 | removeAccountState(account) | |
| 141 | } | |
| 142 | ||
| 143 | /// The state change behind `remove`, minus the hook. `remove` is the | |
| 144 | /// only path that awaits `willRemoveAccount` — this does NOT, on | |
| 145 | /// purpose, and every other caller of this method is an intentional | |
| 146 | /// exemption from that hook, commented at the call site with why a | |
| 147 | /// deregistration attempt there could not succeed anyway. Do not | |
| 148 | /// call this from a new site without asking the same question: does | |
| 149 | /// the account's token still work here? If it might, route through | |
| 150 | /// `remove` instead so the hook gets a chance to run. | |
| 151 | private func removeAccountState(_ account: Account) { | |
| 116 | 152 | store.deleteToken(for: account.id) |
| 117 | 153 | accounts.removeAll { $0.id == account.id } |
| 118 | 154 | try? store.saveAccounts(accounts) |
| @@ -126,8 +162,17 @@ final class SessionStore { | ||
| 126 | 162 | |
| 127 | 163 | /// The server said 401: the token is expired or revoked. Sign the |
| 128 | 164 | /// account out cleanly and say why — never crash, never loop. |
| 165 | /// | |
| 166 | /// Bypasses willRemoveAccount deliberately: expire only runs after | |
| 167 | /// the server has already rejected this token with a 401 (via | |
| 168 | /// watchForRevocation or handle's requiresReauthentication check), | |
| 169 | /// so a deregistration call carrying that same token would be | |
| 170 | /// rejected the same way. The device row this account's push | |
| 171 | /// registration left on the server survives this and is not | |
| 172 | /// cleaned up here — the user clears it from the web settings page, | |
| 173 | /// same as any other stale device. | |
| 129 | 174 | func expire(_ account: Account) { |
| 130 | remove(account) | |
| 175 | removeAccountState(account) | |
| 131 | 176 | if current == nil { |
| 132 | 177 | signedOutMessage = |
| 133 | 178 | "The token for \(account.label) is no longer valid. Mint a new one and sign in again." |
gitbay/ContentView.swift +92 −9
| @@ -3,35 +3,50 @@ import SwiftUI | ||
| 3 | 3 | struct ContentView: View { |
| 4 | 4 | |
| 5 | 5 | @Environment(SessionStore.self) private var session |
| 6 | @Environment(PushRouter.self) private var router | |
| 7 | ||
| 8 | enum Tabs { case dashboard, feed, repositories, explore, profile } | |
| 9 | ||
| 10 | @State private var selectedTab = Tabs.dashboard | |
| 11 | @State private var dashboardPath = NavigationPath() | |
| 12 | ||
| 13 | /// Which account `dashboardPath`'s routes were resolved against. | |
| 14 | /// | |
| 15 | /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so | |
| 16 | /// this view's own state outlives an account switch while the stack | |
| 17 | /// it feeds is rebuilt. A path carried across the switch would be | |
| 18 | /// re-pushed against the new account's client — someone else's | |
| 19 | /// issue number, usually a 404. Empty while signed out. | |
| 20 | @State private var pathAccount = "" | |
| 6 | 21 | |
| 7 | 22 | var body: some View { |
| 8 | 23 | if let client = session.client, let account = session.current { |
| 9 | TabView { | |
| 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { | |
| 11 | NavigationStack { | |
| 24 | TabView(selection: $selectedTab) { | |
| 25 | Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) { | |
| 26 | NavigationStack(path: $dashboardPath) { | |
| 12 | 27 | DashboardView(client: client) |
| 13 | 28 | .navigationDestinations(client: client) |
| 14 | 29 | } |
| 15 | 30 | } |
| 16 | Tab("Feed", systemImage: "bolt") { | |
| 31 | Tab("Feed", systemImage: "bolt", value: Tabs.feed) { | |
| 17 | 32 | NavigationStack { |
| 18 | 33 | FeedView(client: client) |
| 19 | 34 | .navigationDestinations(client: client) |
| 20 | 35 | } |
| 21 | 36 | } |
| 22 | Tab("Repositories", systemImage: "books.vertical") { | |
| 37 | Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { | |
| 23 | 38 | NavigationStack { |
| 24 | 39 | RepoListView(client: client) |
| 25 | 40 | .navigationDestinations(client: client) |
| 26 | 41 | } |
| 27 | 42 | } |
| 28 | Tab("Explore", systemImage: "safari") { | |
| 43 | Tab("Explore", systemImage: "safari", value: Tabs.explore) { | |
| 29 | 44 | NavigationStack { |
| 30 | 45 | ExploreView(client: client) |
| 31 | 46 | .navigationDestinations(client: client) |
| 32 | 47 | } |
| 33 | 48 | } |
| 34 | Tab("My Profile", systemImage: "person.crop.circle") { | |
| 49 | Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) { | |
| 35 | 50 | NavigationStack { |
| 36 | 51 | ProfileView(client: client, name: account.username, |
| 37 | 52 | title: "My Profile") |
| @@ -40,12 +55,75 @@ struct ContentView: View { | ||
| 40 | 55 | } |
| 41 | 56 | } |
| 42 | 57 | } |
| 43 | .id(account.id) // fresh screens on account switch | |
| 58 | .id(account.id) // a fresh tab bar and stacks per account | |
| 59 | .task(id: DrainKey(account: account.id, target: router.pending)) { | |
| 60 | await routePendingNotification(account) | |
| 61 | } | |
| 44 | 62 | } else { |
| 45 | 63 | NavigationStack { |
| 46 | 64 | SignInView() |
| 47 | 65 | } |
| 66 | // Signing out leaves the stale path behind in this view's | |
| 67 | // state; disowning it makes the next sign-in clear it. | |
| 68 | .task { pathAccount = "" } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /// What makes the drain run again. | |
| 73 | /// | |
| 74 | /// The account is in here because the cross-account pass leaves | |
| 75 | /// `pending` set on purpose, so the target alone does not change | |
| 76 | /// across the switch it performs. Keying on both means the drain | |
| 77 | /// does not depend on whether `.id()` propagates its identity | |
| 78 | /// outward through the `.task` modifier attached after it. | |
| 79 | nonisolated private struct DrainKey: Equatable, Sendable { | |
| 80 | let account: String | |
| 81 | let target: PushTarget? | |
| 82 | } | |
| 83 | ||
| 84 | /// Resets the navigation state an account switch invalidated, then | |
| 85 | /// routes a tapped notification, switching accounts first when it | |
| 86 | /// belongs to another one. | |
| 87 | /// | |
| 88 | /// A switch changes `account.id`, which rebuilds the tab bar. That | |
| 89 | /// is why the target is left pending across it: this runs again | |
| 90 | /// against the new account and finds it already active, so the | |
| 91 | /// second pass does the navigating. The drain is the mechanism, not | |
| 92 | /// a workaround for it — and it is also where the path is reset, | |
| 93 | /// because every switch comes through here whether a notification | |
| 94 | /// is pending or not. | |
| 95 | private func routePendingNotification(_ account: Account) async { | |
| 96 | if pathAccount != account.id { | |
| 97 | dashboardPath = NavigationPath() | |
| 98 | selectedTab = .dashboard | |
| 99 | pathAccount = account.id | |
| 100 | } | |
| 101 | guard let target = router.pending else { return } | |
| 102 | if account.id != target.accountID { | |
| 103 | guard let other = session.accounts.first(where: { $0.id == target.accountID }) else { | |
| 104 | router.pending = nil // signed out of it since the tap | |
| 105 | return | |
| 106 | } | |
| 107 | // A token gone from the Keychain drops the account and | |
| 108 | // leaves `current` alone, so nothing would rebuild and | |
| 109 | // drain; the target would outlive every later tap, which | |
| 110 | // cannot change the key either. | |
| 111 | guard session.activate(other) else { | |
| 112 | router.pending = nil | |
| 113 | return | |
| 114 | } | |
| 115 | return // the rebuilt tree drains it | |
| 116 | } | |
| 117 | selectedTab = .dashboard | |
| 118 | switch target.destination { | |
| 119 | case .issue(let repo, let number): | |
| 120 | dashboardPath.append(IssueRoute.issue(repo: repo, number: number)) | |
| 121 | case .mr(let repo, let number): | |
| 122 | dashboardPath.append(MRRoute.mr(repo: repo, number: number)) | |
| 123 | case .build(let repo, let number): | |
| 124 | dashboardPath.append(BuildRoute.detail(repo: repo, number: number)) | |
| 48 | 125 | } |
| 126 | router.pending = nil | |
| 49 | 127 | } |
| 50 | 128 | |
| 51 | 129 | } |
| @@ -204,6 +282,11 @@ extension View { | ||
| 204 | 282 | } |
| 205 | 283 | |
| 206 | 284 | #Preview { |
| 285 | // Every environment value the signed-in branch reads, so the | |
| 286 | // preview does not depend on the machine's Keychain being empty. | |
| 287 | let session = SessionStore() | |
| 207 | 288 | ContentView() |
| 208 | .environment(SessionStore()) | |
| 289 | .environment(session) | |
| 290 | .environment(PushRouter()) | |
| 291 | .environment(PushRegistrar(session: session)) | |
| 209 | 292 | } |
gitbay/Networking/GitbayInstance.swift +6
| @@ -40,6 +40,12 @@ nonisolated struct GitbayInstance: Sendable, Hashable, Codable { | ||
| 40 | 40 | components.path = "" |
| 41 | 41 | components.query = nil |
| 42 | 42 | components.fragment = nil |
| 43 | // An explicit default port is the same origin as no port at all — | |
| 44 | // URLComponents does not fold this on its own, and two instances | |
| 45 | // typed with and without it must normalise to one baseURL. | |
| 46 | if let port = components.port, (scheme == "https" && port == 443) || (scheme == "http" && port == 80) { | |
| 47 | components.port = nil | |
| 48 | } | |
| 43 | 49 | guard let url = components.url else { throw InvalidURL.notAURL(trimmed) } |
| 44 | 50 | self.baseURL = url |
| 45 | 51 | } |
gitbay/Push/AppDelegate.swift added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | import UIKit | |
| 2 | import UserNotifications | |
| 3 | ||
| 4 | /// The only way to receive an APNs device token and a notification tap. | |
| 5 | /// SwiftUI has no equivalent, so the app keeps one delegate whose whole | |
| 6 | /// job is handing both to the push types. | |
| 7 | /// | |
| 8 | /// The references are weak: `gitbayApp` holds all three in `@State` for | |
| 9 | /// the life of the app, so this delegate borrows them rather than | |
| 10 | /// keeping them alive. | |
| 11 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { | |
| 12 | ||
| 13 | weak var registrar: PushRegistrar? | |
| 14 | weak var router: PushRouter? | |
| 15 | weak var session: SessionStore? | |
| 16 | ||
| 17 | /// A tap that arrived before `gitbayApp` wired this delegate up. | |
| 18 | /// | |
| 19 | /// This is the common case, not an edge one: tapping a notification | |
| 20 | /// while the app is not running launches it, and iOS can deliver | |
| 21 | /// the tap before SwiftUI's root `.task` has run. Held as the raw | |
| 22 | /// `userInfo` rather than a resolved `PushTarget`, because resolving | |
| 23 | /// one needs `session.accounts` — precisely what is missing at that | |
| 24 | /// moment. | |
| 25 | /// | |
| 26 | /// One slot, newest wins. A queue would open several screens in | |
| 27 | /// sequence, which is not what a tap asks for. | |
| 28 | private var pendingUserInfo: [AnyHashable: Any]? | |
| 29 | ||
| 30 | func application( | |
| 31 | _ application: UIApplication, | |
| 32 | didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil | |
| 33 | ) -> Bool { | |
| 34 | UNUserNotificationCenter.current().delegate = self | |
| 35 | return true | |
| 36 | } | |
| 37 | ||
| 38 | func application( | |
| 39 | _ application: UIApplication, | |
| 40 | didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data | |
| 41 | ) { | |
| 42 | Task { await registrar?.deviceTokenArrived(deviceToken) } | |
| 43 | } | |
| 44 | ||
| 45 | func application( | |
| 46 | _ application: UIApplication, | |
| 47 | didFailToRegisterForRemoteNotificationsWithError error: any Error | |
| 48 | ) { | |
| 49 | // Nothing to do and nothing to say: push is a side channel, and | |
| 50 | // the next launch tries again. | |
| 51 | } | |
| 52 | ||
| 53 | /// A notification arriving while the app is open still shows — you | |
| 54 | /// may be reading a different repository when one lands. | |
| 55 | func userNotificationCenter( | |
| 56 | _ center: UNUserNotificationCenter, | |
| 57 | willPresent notification: UNNotification | |
| 58 | ) async -> UNNotificationPresentationOptions { | |
| 59 | [.banner, .sound, .badge] | |
| 60 | } | |
| 61 | ||
| 62 | func userNotificationCenter( | |
| 63 | _ center: UNUserNotificationCenter, | |
| 64 | didReceive response: UNNotificationResponse | |
| 65 | ) async { | |
| 66 | deliver(response.notification.request.content.userInfo) | |
| 67 | } | |
| 68 | ||
| 69 | /// Drains a tap that arrived before the wiring did. Called from | |
| 70 | /// `gitbayApp`'s root task, once `session` and `router` are set. | |
| 71 | func deliverPendingNotification() { | |
| 72 | guard let userInfo = pendingUserInfo else { return } | |
| 73 | // Cleared whatever the resolve decides: a payload for an | |
| 74 | // account the user has since signed out of resolves to no | |
| 75 | // target, and must not sit here waiting to fire later. | |
| 76 | pendingUserInfo = nil | |
| 77 | deliver(userInfo) | |
| 78 | } | |
| 79 | ||
| 80 | /// The one resolve-and-deliver path, so a buffered tap and a live | |
| 81 | /// one cannot diverge. Parks the payload if the wiring is not up | |
| 82 | /// yet; `deliverPendingNotification` comes back for it. | |
| 83 | private func deliver(_ userInfo: [AnyHashable: Any]) { | |
| 84 | guard let session, let router else { | |
| 85 | pendingUserInfo = userInfo | |
| 86 | return | |
| 87 | } | |
| 88 | guard let payload = PushPayload(userInfo: userInfo), | |
| 89 | let target = PushTarget(payload: payload, accounts: session.accounts) | |
| 90 | else { return } | |
| 91 | router.pending = target | |
| 92 | } | |
| 93 | } | |
gitbay/Push/PushPayload.swift added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A push notification as the app reads it. | |
| 4 | /// | |
| 5 | /// `instance` and `user` name the account the notice belongs to. One | |
| 6 | /// device token is one install, and an install registers against every | |
| 7 | /// account signed in on it, so `path` alone is ambiguous — two | |
| 8 | /// instances can hold the same `owner/name`. | |
| 9 | nonisolated struct PushPayload: Equatable, Sendable { | |
| 10 | let instance: URL | |
| 11 | let user: String | |
| 12 | let path: String | |
| 13 | /// The recipient's unread count, for the app icon badge. Absent on | |
| 14 | /// a notification from a server that predates it. | |
| 15 | let badge: Int | |
| 16 | ||
| 17 | init?(userInfo: [AnyHashable: Any]) { | |
| 18 | guard let raw = userInfo["instance"] as? String, | |
| 19 | let instance = URL(string: raw), instance.scheme != nil, | |
| 20 | let user = userInfo["user"] as? String, !user.isEmpty, | |
| 21 | let path = userInfo["path"] as? String, !path.isEmpty | |
| 22 | else { return nil } | |
| 23 | self.instance = instance | |
| 24 | self.user = user | |
| 25 | self.path = path | |
| 26 | self.badge = (userInfo["aps"] as? [AnyHashable: Any])?["badge"] as? Int ?? 0 | |
| 27 | } | |
| 28 | } | |
gitbay/Push/PushRegistrar.swift added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import UIKit | |
| 4 | import UserNotifications | |
| 5 | ||
| 6 | /// Registers this install's APNs token against every signed-in account, | |
| 7 | /// and deregisters one when its account goes away. | |
| 8 | /// | |
| 9 | /// A device row is keyed on (user, token) server-side, so one install | |
| 10 | /// holds a row per account. Registering only the active account would | |
| 11 | /// silently stop notifications from the others the moment you switched. | |
| 12 | @Observable | |
| 13 | @MainActor | |
| 14 | final class PushRegistrar { | |
| 15 | ||
| 16 | private let session: SessionStore | |
| 17 | private let defaults: UserDefaults | |
| 18 | ||
| 19 | /// The APNs token as lowercase hex, once iOS has handed it over. | |
| 20 | /// Private rather than `private(set)`: this object is in the | |
| 21 | /// environment, so a readable token would be one any view could | |
| 22 | /// put on a screen. | |
| 23 | private var deviceToken: String? | |
| 24 | ||
| 25 | init(session: SessionStore, defaults: UserDefaults = .standard) { | |
| 26 | self.session = session | |
| 27 | self.defaults = defaults | |
| 28 | } | |
| 29 | ||
| 30 | func deviceTokenArrived(_ data: Data) async { | |
| 31 | deviceToken = data.map { String(format: "%02x", $0) }.joined() | |
| 32 | await registerAll() | |
| 33 | } | |
| 34 | ||
| 35 | /// Registers every signed-in account. Apple rotates device tokens, | |
| 36 | /// so this runs on launch too; `device add` upserts on the token, so | |
| 37 | /// a repeat costs one call and changes nothing. | |
| 38 | func registerAll() async { | |
| 39 | guard let token = deviceToken else { return } | |
| 40 | for account in session.accounts { | |
| 41 | await register(account, token: token) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// Registers one account, for one that signs in while the app is | |
| 46 | /// running. Does nothing until APNs has handed over a token; the | |
| 47 | /// account is picked up by `registerAll` when it does. | |
| 48 | func register(_ account: Account) async { | |
| 49 | guard let token = deviceToken else { return } | |
| 50 | await register(account, token: token) | |
| 51 | } | |
| 52 | ||
| 53 | private func register(_ account: Account, token: String) async { | |
| 54 | guard let client = session.client(for: account) else { return } | |
| 55 | nonisolated struct Registered: Decodable, Sendable { let id: Int64 } | |
| 56 | do { | |
| 57 | let out = try await client.run( | |
| 58 | ["notifications", "device", "add", "--label", Self.deviceLabel], | |
| 59 | stdin: token, as: Registered.self) | |
| 60 | if let id = out?.id { | |
| 61 | // Stored as Int, which UserDefaults handles natively — | |
| 62 | // it has no Int64 overload, so an Int64 goes in as a | |
| 63 | // boxed NSNumber and comes back out through a | |
| 64 | // conditional bridge. Int is 64-bit on every device | |
| 65 | // this ships to. | |
| 66 | defaults.set(Int(id), forKey: Self.idKey(account)) | |
| 67 | } | |
| 68 | } catch { | |
| 69 | // A side channel. The account still works and the next | |
| 70 | // launch registers again. | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /// Removes this device from one account, using the id that account | |
| 75 | /// returned at registration. Must run while the account's token | |
| 76 | /// still exists — SessionStore.willRemoveAccount is where it is | |
| 77 | /// wired, and that hook is awaited before the token is discarded. | |
| 78 | func deregister(_ account: Account) async { | |
| 79 | let key = Self.idKey(account) | |
| 80 | // object(forKey:) rather than integer(forKey:), which cannot | |
| 81 | // tell a stored 0 from an absent key. | |
| 82 | guard let id = defaults.object(forKey: key) as? Int, | |
| 83 | let client = session.client(for: account) else { return } | |
| 84 | try? await client.run(["notifications", "device", "remove", String(id)]) | |
| 85 | defaults.removeObject(forKey: key) | |
| 86 | } | |
| 87 | ||
| 88 | /// Asks iOS for permission and, on approval, registers for remote | |
| 89 | /// notifications. Called from the notifications screen's toggle, so | |
| 90 | /// the system prompt only appears because the user asked for it. | |
| 91 | func requestPushPermission() async -> Bool { | |
| 92 | let center = UNUserNotificationCenter.current() | |
| 93 | let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false | |
| 94 | if granted { | |
| 95 | UIApplication.shared.registerForRemoteNotifications() | |
| 96 | } | |
| 97 | return granted | |
| 98 | } | |
| 99 | ||
| 100 | /// Whether iOS has been asked, and what it said. | |
| 101 | func authorizationStatus() async -> UNAuthorizationStatus { | |
| 102 | await UNUserNotificationCenter.current().notificationSettings().authorizationStatus | |
| 103 | } | |
| 104 | ||
| 105 | /// Asks iOS for a token when permission is already granted: on | |
| 106 | /// launch, and when the user grants it in Settings rather than | |
| 107 | /// through the toggle. Idempotent — `device add` upserts. | |
| 108 | func registerIfAlreadyAuthorized() async { | |
| 109 | if await authorizationStatus() == .authorized { | |
| 110 | UIApplication.shared.registerForRemoteNotifications() | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | private static func idKey(_ account: Account) -> String { | |
| 115 | "pushDeviceID#\(account.id)" | |
| 116 | } | |
| 117 | ||
| 118 | /// Names the row in `notifications device list` on every surface. | |
| 119 | /// | |
| 120 | /// Without the user-assigned-device-name entitlement, iOS already | |
| 121 | /// answers with a generic, non-empty model name ("iPhone") rather | |
| 122 | /// than the user's custom name — the `isEmpty` guard is defense in | |
| 123 | /// depth against a future OS returning nothing at all. | |
| 124 | private static var deviceLabel: String { | |
| 125 | let name = UIDevice.current.name | |
| 126 | return name.isEmpty ? "iPhone" : name | |
| 127 | } | |
| 128 | } | |
gitbay/Push/PushRouter.swift added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import Observation | |
| 2 | ||
| 3 | /// Carries a tapped notification's destination into the view tree. | |
| 4 | /// | |
| 5 | /// Owned by `gitbayApp`, above `ContentView` — which is `.id(account.id)` | |
| 6 | /// and is therefore destroyed and rebuilt by the account switch a | |
| 7 | /// cross-account tap performs. A target held inside that subtree would | |
| 8 | /// go with it; held here, the rebuilt tree drains it on appear. | |
| 9 | @Observable | |
| 10 | @MainActor | |
| 11 | final class PushRouter { | |
| 12 | var pending: PushTarget? | |
| 13 | } | |
gitbay/Push/PushTarget.swift added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A tapped notification resolved against the accounts signed in on | |
| 4 | /// this device: which account it belongs to, and the screen it opens. | |
| 5 | nonisolated struct PushTarget: Equatable, Sendable { | |
| 6 | let accountID: String | |
| 7 | let destination: NotificationDestination | |
| 8 | ||
| 9 | /// Nil when no signed-in account matches — you signed out of it — | |
| 10 | /// or when the path names nothing the app can route to. | |
| 11 | init?(payload: PushPayload, accounts: [Account]) { | |
| 12 | guard let destination = notificationDestination(forPath: payload.path) else { return nil } | |
| 13 | // Run the payload's instance through the same normalisation an | |
| 14 | // account's did (trailing slash, default port) rather than | |
| 15 | // comparing raw URLs, which a server's unnormalised config can | |
| 16 | // make unequal even when they name the same instance. | |
| 17 | guard let instance = try? GitbayInstance(url: payload.instance.absoluteString) else { return nil } | |
| 18 | // Both sides, not just the payload's: an account stored before | |
| 19 | // the constructor folded the default port still holds an | |
| 20 | // explicit ":443". Normalising is for the comparison only — | |
| 21 | // rewriting what is stored would change `Account.id` and orphan | |
| 22 | // the Keychain token it keys. | |
| 23 | let match = accounts.first { | |
| 24 | $0.username == payload.user | |
| 25 | && (try? GitbayInstance(url: $0.instance.baseURL.absoluteString))?.baseURL == instance.baseURL | |
| 26 | } | |
| 27 | guard let match else { return nil } | |
| 28 | self.accountID = match.id | |
| 29 | self.destination = destination | |
| 30 | } | |
| 31 | } | |
gitbay/Views/Account/NotificationsView.swift +82 −1
| @@ -1,10 +1,15 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import UIKit | |
| 3 | import UserNotifications | |
| 2 | 4 | |
| 3 | 5 | /// The notification inbox: `notifications list [--all]`, paginated, with |
| 4 | 6 | /// a switch between the default unread-only listing and everything. |
| 5 | 7 | struct NotificationsView: View { |
| 6 | 8 | |
| 7 | 9 | @State private var model: NotificationsViewModel |
| 10 | @Environment(PushRegistrar.self) private var registrar | |
| 11 | @Environment(\.scenePhase) private var scenePhase | |
| 12 | @State private var authorization: UNAuthorizationStatus = .notDetermined | |
| 8 | 13 | |
| 9 | 14 | init(client: GitbayClient) { |
| 10 | 15 | _model = State(initialValue: NotificationsViewModel(client: client)) |
| @@ -12,6 +17,8 @@ struct NotificationsView: View { | ||
| 12 | 17 | |
| 13 | 18 | var body: some View { |
| 14 | 19 | List { |
| 20 | pushSection | |
| 21 | ||
| 15 | 22 | Picker("Filter", selection: Bindable(model).showAll) { |
| 16 | 23 | Text("Unread").tag(false) |
| 17 | 24 | Text("All").tag(true) |
| @@ -49,7 +56,81 @@ struct NotificationsView: View { | ||
| 49 | 56 | } |
| 50 | 57 | } |
| 51 | 58 | .task { await model.load() } |
| 52 | .refreshable { await model.load() } | |
| 59 | .task { await model.loadPushSettings() } | |
| 60 | .task { authorization = await registrar.authorizationStatus() } | |
| 61 | // Settings is exactly where the denied row sends the user, and | |
| 62 | // returning from it is a scene activation, not a refresh — this | |
| 63 | // is what actually clears a stale "denied" once they flip the | |
| 64 | // switch there. | |
| 65 | .onChange(of: scenePhase) { _, newPhase in | |
| 66 | guard newPhase == .active else { return } | |
| 67 | Task { | |
| 68 | let status = await registrar.authorizationStatus() | |
| 69 | // Granting in Settings is the one route to .authorized | |
| 70 | // that never passes through the toggle, so it is also | |
| 71 | // the one route that never asks iOS for a device | |
| 72 | // token. Without this the toggle turns on, the server | |
| 73 | // preference is set, and no `device add` is ever sent. | |
| 74 | let justGranted = status == .authorized && authorization != .authorized | |
| 75 | authorization = status | |
| 76 | if justGranted { await registrar.registerIfAlreadyAuthorized() } | |
| 77 | } | |
| 78 | } | |
| 79 | .refreshable { | |
| 80 | await model.load() | |
| 81 | await model.loadPushSettings() | |
| 82 | authorization = await registrar.authorizationStatus() | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | /// Push has two independent switches — iOS authorization and the | |
| 87 | /// server preference — and the toggle must read false when either | |
| 88 | /// one is off. Permission is asked here, on the user turning the | |
| 89 | /// toggle on, not on the screen appearing: iOS asks once per | |
| 90 | /// install, and a prompt sprung by navigation earns a reflexive | |
| 91 | /// "Don't Allow" that nothing afterwards can explain. | |
| 92 | private var pushSection: some View { | |
| 93 | Section("Push notifications") { | |
| 94 | Toggle("Push notifications", isOn: Binding( | |
| 95 | get: { authorization == .authorized && model.pushEnabled }, | |
| 96 | set: { on in Task { await setPush(on) } } | |
| 97 | )) | |
| 98 | .disabled(authorization == .denied) | |
| 99 | ||
| 100 | if authorization == .denied { | |
| 101 | GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn) | |
| 102 | Button("Open Settings") { | |
| 103 | if let url = URL(string: UIApplication.openSettingsURLString) { | |
| 104 | UIApplication.shared.open(url) | |
| 105 | } | |
| 106 | } | |
| 107 | .font(.gbSans(.subheadline)) | |
| 108 | } | |
| 109 | ||
| 110 | ForEach(model.devices) { device in | |
| 111 | VStack(alignment: .leading, spacing: 2) { | |
| 112 | Text(device.label) | |
| 113 | .font(.gbSans(.subheadline)) | |
| 114 | Text(device.token) | |
| 115 | .font(.gbMono(.caption)) | |
| 116 | .foregroundStyle(.secondary) | |
| 117 | } | |
| 118 | .swipeActions { | |
| 119 | Button("Remove", role: .destructive) { | |
| 120 | Task { await model.removeDevice(device.id) } | |
| 121 | } | |
| 122 | } | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | private func setPush(_ on: Bool) async { | |
| 128 | if on, authorization == .notDetermined { | |
| 129 | let granted = await registrar.requestPushPermission() | |
| 130 | authorization = await registrar.authorizationStatus() | |
| 131 | guard granted else { return } | |
| 132 | } | |
| 133 | await model.setPush(on) | |
| 53 | 134 | } |
| 54 | 135 | } |
| 55 | 136 | |
gitbay/Views/Repos/RepoListView.swift +1 −1
| @@ -280,7 +280,7 @@ struct AccountMenu: ToolbarContent { | ||
| 280 | 280 | NavigationLink("Add Account", value: RepoRoute.addAccount) |
| 281 | 281 | if let current = session.current { |
| 282 | 282 | Button("Sign Out", role: .destructive) { |
| 283 | session.remove(current) | |
| 283 | Task { await session.remove(current) } | |
| 284 | 284 | } |
| 285 | 285 | } |
| 286 | 286 | } |
gitbay/gitbay.entitlements added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | <plist version="1.0"> | |
| 4 | <dict> | |
| 5 | <key>aps-environment</key> | |
| 6 | <string>development</string> | |
| 7 | </dict> | |
| 8 | </plist> | |
gitbay/gitbayApp.swift +31 −1
| @@ -3,21 +3,51 @@ import SwiftUI | ||
| 3 | 3 | @main |
| 4 | 4 | struct gitbayApp: App { |
| 5 | 5 | |
| 6 | @State private var session = SessionStore() | |
| 6 | @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate | |
| 7 | @State private var session: SessionStore | |
| 8 | @State private var router = PushRouter() | |
| 9 | @State private var registrar: PushRegistrar | |
| 7 | 10 | |
| 8 | 11 | init() { |
| 9 | 12 | GitbayFonts.register() |
| 13 | // The registrar takes the session at construction, so both are | |
| 14 | // built here rather than as property initialisers — one | |
| 15 | // registrar for the life of the app, which the delegate and | |
| 16 | // SessionStore's removal hook then both point at. | |
| 17 | let session = SessionStore() | |
| 18 | _session = State(wrappedValue: session) | |
| 19 | _registrar = State(wrappedValue: PushRegistrar(session: session)) | |
| 10 | 20 | } |
| 11 | 21 | |
| 12 | 22 | var body: some Scene { |
| 13 | 23 | WindowGroup { |
| 14 | 24 | ContentView() |
| 15 | 25 | .environment(session) |
| 26 | .environment(router) | |
| 27 | .environment(registrar) | |
| 16 | 28 | .font(.gbSans(.body)) |
| 17 | 29 | .tint(.gbAccent) |
| 18 | 30 | // The screenshot checkpoint forces appearance per run; |
| 19 | 31 | // simulator clones ignore the base device's setting. |
| 20 | 32 | .preferredColorScheme(Self.forcedColorScheme) |
| 33 | .task { | |
| 34 | let registrar = self.registrar | |
| 35 | appDelegate.registrar = registrar | |
| 36 | appDelegate.router = router | |
| 37 | appDelegate.session = session | |
| 38 | // Weak: the registrar holds the session, and this | |
| 39 | // closure is stored on the session. | |
| 40 | session.willRemoveAccount = { [weak registrar] account in | |
| 41 | await registrar?.deregister(account) | |
| 42 | } | |
| 43 | session.didAddAccount = { [weak registrar] account in | |
| 44 | await registrar?.register(account) | |
| 45 | } | |
| 46 | // A tap that launched the app reaches the delegate | |
| 47 | // before this runs; it parked the payload. | |
| 48 | appDelegate.deliverPendingNotification() | |
| 49 | await registrar.registerIfAlreadyAuthorized() | |
| 50 | } | |
| 21 | 51 | } |
| 22 | 52 | } |
| 23 | 53 | |
gitbayTests/NotificationTests.swift +105
| @@ -215,4 +215,109 @@ struct NotificationsViewModelTests { | ||
| 215 | 215 | // One POST, no reload after a refusal. |
| 216 | 216 | #expect(stub.seen.count == before + 1) |
| 217 | 217 | } |
| 218 | ||
| 219 | // MARK: - Push | |
| 220 | ||
| 221 | private let settingsPushOn = """ | |
| 222 | {"protocol_version":1,"data":{"mail":true,"watch":false,"push":true},"exit_code":0} | |
| 223 | """ | |
| 224 | ||
| 225 | private let noDevices = """ | |
| 226 | {"protocol_version":1,"data":[],"exit_code":0} | |
| 227 | """ | |
| 228 | ||
| 229 | @Test func loadingPushSettingsReadsThePreferenceAndTheDevices() async throws { | |
| 230 | let (client, stub) = try makeClient() | |
| 231 | stub.enqueue(.init(status: 200, json: settingsPushOn)) | |
| 232 | stub.enqueue(.init(status: 200, json: """ | |
| 233 | {"protocol_version":1,"data":[\ | |
| 234 | {"id":1,"label":"cmc's iPhone","token":"a1b2…","added":"2026-09-20T00:00:00Z"}\ | |
| 235 | ],"exit_code":0} | |
| 236 | """)) | |
| 237 | let model = NotificationsViewModel(client: client) | |
| 238 | await model.loadPushSettings() | |
| 239 | ||
| 240 | #expect(model.pushEnabled) | |
| 241 | #expect(model.devices.map(\.id) == [1]) | |
| 242 | // The token is whatever the server sent — this model never | |
| 243 | // requests or reconstructs the full value. | |
| 244 | #expect(model.devices.first?.token == "a1b2…") | |
| 245 | } | |
| 246 | ||
| 247 | /// `push` is absent on a server that predates it; the read fails to | |
| 248 | /// decode and the defaults stand rather than crashing. | |
| 249 | @Test func aServerWithoutPushLeavesTheDefaults() async throws { | |
| 250 | let (client, stub) = try makeClient() | |
| 251 | stub.enqueue(.init(status: 200, json: """ | |
| 252 | {"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0} | |
| 253 | """)) | |
| 254 | let model = NotificationsViewModel(client: client) | |
| 255 | await model.loadPushSettings() | |
| 256 | ||
| 257 | #expect(model.pushEnabled == false) | |
| 258 | #expect(model.devices.isEmpty) | |
| 259 | } | |
| 260 | ||
| 261 | /// Flipping the toggle must not touch the inbox: three requests | |
| 262 | /// total (the write, then the two reads `loadPushSettings` makes for | |
| 263 | /// its own state), never a fourth for `notifications list`. That | |
| 264 | /// count is what stops the notification list on screen from | |
| 265 | /// blanking out behind a spinner for an unrelated fetch. | |
| 266 | @Test func settingPushReloadsItsOwnStateOnlyNotTheInbox() async throws { | |
| 267 | let (client, stub) = try makeClient() | |
| 268 | let model = NotificationsViewModel(client: client) | |
| 269 | ||
| 270 | stub.enqueue(.init(status: 200, json: """ | |
| 271 | {"protocol_version":1,"exit_code":0} | |
| 272 | """)) | |
| 273 | stub.enqueue(.init(status: 200, json: settingsPushOn)) | |
| 274 | stub.enqueue(.init(status: 200, json: noDevices)) | |
| 275 | await model.setPush(true) | |
| 276 | ||
| 277 | #expect(stub.seen.count == 3) | |
| 278 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 279 | #expect(try argvOf(write) == ["notifications", "settings", "push", "on"]) | |
| 280 | #expect(model.pushEnabled) | |
| 281 | } | |
| 282 | ||
| 283 | /// See `settingPushReloadsItsOwnStateOnlyNotTheInbox` — the same | |
| 284 | /// reasoning applies to a device removal. | |
| 285 | @Test func removingADeviceSendsItsIdAndDoesNotReloadTheInbox() async throws { | |
| 286 | let (client, stub) = try makeClient() | |
| 287 | let model = NotificationsViewModel(client: client) | |
| 288 | ||
| 289 | stub.enqueue(.init(status: 200, json: """ | |
| 290 | {"protocol_version":1,"exit_code":0} | |
| 291 | """)) | |
| 292 | stub.enqueue(.init(status: 200, json: """ | |
| 293 | {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0} | |
| 294 | """)) | |
| 295 | stub.enqueue(.init(status: 200, json: noDevices)) | |
| 296 | await model.removeDevice(7) | |
| 297 | ||
| 298 | #expect(stub.seen.count == 3) | |
| 299 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 300 | #expect(try argvOf(write) == ["notifications", "device", "remove", "7"]) | |
| 301 | #expect(model.devices.isEmpty) | |
| 302 | } | |
| 303 | ||
| 304 | /// A fast double-tap must not fire the write twice — `working` guards | |
| 305 | /// it, the same pattern `markRead` already uses. | |
| 306 | @Test func aSecondSetPushWhileWorkingIsIgnored() async throws { | |
| 307 | let (client, stub) = try makeClient() | |
| 308 | let model = NotificationsViewModel(client: client) | |
| 309 | ||
| 310 | stub.enqueue(.init(status: 200, json: """ | |
| 311 | {"protocol_version":1,"exit_code":0} | |
| 312 | """)) | |
| 313 | stub.enqueue(.init(status: 200, json: settingsPushOn)) | |
| 314 | stub.enqueue(.init(status: 200, json: noDevices)) | |
| 315 | ||
| 316 | async let first: Void = model.setPush(true) | |
| 317 | await model.setPush(true) | |
| 318 | _ = await first | |
| 319 | ||
| 320 | // Only the first call's three requests; the guard drops the rest. | |
| 321 | #expect(stub.seen.count == 3) | |
| 322 | } | |
| 218 | 323 | } |
gitbayTests/PushPayloadTests.swift added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | @Test func payloadDecodesTheAccountAndPath() throws { | |
| 6 | let userInfo: [AnyHashable: Any] = [ | |
| 7 | "aps": ["alert": ["title": "krz/gitbay", "body": "cmc opened issue #12"], "badge": 3], | |
| 8 | "instance": "https://gitbay.org", | |
| 9 | "user": "cmc", | |
| 10 | "path": "krz/gitbay/issues/12", | |
| 11 | ] | |
| 12 | let payload = try #require(PushPayload(userInfo: userInfo)) | |
| 13 | #expect(payload.instance == URL(string: "https://gitbay.org")) | |
| 14 | #expect(payload.user == "cmc") | |
| 15 | #expect(payload.path == "krz/gitbay/issues/12") | |
| 16 | #expect(payload.badge == 3) | |
| 17 | } | |
| 18 | ||
| 19 | @Test func payloadTreatsAMissingBadgeAsZero() throws { | |
| 20 | let userInfo: [AnyHashable: Any] = [ | |
| 21 | "aps": ["alert": ["title": "t", "body": "b"]], | |
| 22 | "instance": "https://gitbay.org", "user": "cmc", "path": "a/b/issues/1", | |
| 23 | ] | |
| 24 | let payload = try #require(PushPayload(userInfo: userInfo)) | |
| 25 | #expect(payload.badge == 0) | |
| 26 | } | |
| 27 | ||
| 28 | // A payload from a server that predates the account fields, or one | |
| 29 | // mangled in transit, is dropped rather than guessed at. | |
| 30 | @Test func payloadRejectsAnIncompleteNotification() { | |
| 31 | #expect(PushPayload(userInfo: ["aps": ["alert": "x"], "path": "a/b/issues/1"]) == nil) | |
| 32 | #expect(PushPayload(userInfo: ["instance": "https://gitbay.org", "user": "cmc"]) == nil) | |
| 33 | #expect(PushPayload(userInfo: ["instance": "not a url at all", "user": "cmc", "path": "a/b/issues/1"]) == nil) | |
| 34 | } | |
gitbayTests/PushRegistrarTests.swift added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private let whoami = """ | |
| 6 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 7 | """ | |
| 8 | private let registeredJSON = """ | |
| 9 | {"protocol_version":1,"data":{"id":7,"status":"registered"},"exit_code":0} | |
| 10 | """ | |
| 11 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 12 | private let serverErrorJSON = #"{"protocol_version":1,"error":"boom","exit_code":1}"# | |
| 13 | ||
| 14 | @MainActor | |
| 15 | private func makeSession() -> (SessionStore, StubProtocol.Box) { | |
| 16 | let box = StubProtocol.box() | |
| 17 | let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! | |
| 18 | let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in | |
| 19 | GitbayClient(instance: instance, token: token, session: box.session()) | |
| 20 | } | |
| 21 | return (session, box) | |
| 22 | } | |
| 23 | ||
| 24 | /// Signs two accounts in, on different instances. | |
| 25 | @MainActor | |
| 26 | private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { | |
| 27 | let (session, box) = makeSession() | |
| 28 | box.enqueue(.init(status: 200, json: whoami)) | |
| 29 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | |
| 30 | box.enqueue(.init(status: 200, json: whoami)) | |
| 31 | try await session.signIn(instanceURL: "https://dev.local", token: "t2") | |
| 32 | return (session, box) | |
| 33 | } | |
| 34 | ||
| 35 | private func argv(of seen: StubProtocol.Seen) -> [String] { | |
| 36 | guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else { | |
| 37 | return [] | |
| 38 | } | |
| 39 | return body["argv"] as? [String] ?? [] | |
| 40 | } | |
| 41 | ||
| 42 | private func stdin(of seen: StubProtocol.Seen) -> String? { | |
| 43 | guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else { | |
| 44 | return nil | |
| 45 | } | |
| 46 | return body["stdin"] as? String | |
| 47 | } | |
| 48 | ||
| 49 | /// A UserDefaults suite unique to the call, so stored device ids never | |
| 50 | /// leak between parallel tests. | |
| 51 | private func scratchDefaults() -> UserDefaults { | |
| 52 | UserDefaults(suiteName: "test.\(UUID().uuidString)")! | |
| 53 | } | |
| 54 | ||
| 55 | @MainActor | |
| 56 | struct PushRegistrarTests { | |
| 57 | ||
| 58 | @Test func registrarRegistersEveryAccountOnce() async throws { | |
| 59 | let (session, box) = try await twoAccounts() | |
| 60 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 61 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 62 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 63 | ||
| 64 | await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef])) | |
| 65 | ||
| 66 | let adds = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) } | |
| 67 | #expect(adds.count == 2) | |
| 68 | // The token is the lowercase hex of the raw bytes, on stdin, never argv. | |
| 69 | #expect(stdin(of: adds[0]) == "deadbeef") | |
| 70 | #expect(!argv(of: adds[0]).contains("deadbeef")) | |
| 71 | } | |
| 72 | ||
| 73 | @Test func registrarDeregistersWithTheStoredID() async throws { | |
| 74 | let (session, box) = try await twoAccounts() | |
| 75 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 76 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 77 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 78 | await registrar.deviceTokenArrived(Data([0x01])) | |
| 79 | ||
| 80 | box.enqueue(.init(status: 200, json: okJSON)) | |
| 81 | await registrar.deregister(try #require(session.accounts.first)) | |
| 82 | ||
| 83 | let removes = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) } | |
| 84 | #expect(removes.count == 1) | |
| 85 | #expect(argv(of: removes[0]).last == "7") | |
| 86 | } | |
| 87 | ||
| 88 | // What SessionStore.didAddAccount calls: the account that just | |
| 89 | // signed in gets a row, and no other account is touched. | |
| 90 | @Test func registrarRegistersOneAccountOnDemand() async throws { | |
| 91 | let (session, box) = try await twoAccounts() | |
| 92 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 93 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 94 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 95 | await registrar.deviceTokenArrived(Data([0x01])) | |
| 96 | let before = box.seen.count | |
| 97 | ||
| 98 | box.enqueue(.init(status: 200, json: registeredJSON)) | |
| 99 | await registrar.register(try #require(session.accounts.last)) | |
| 100 | ||
| 101 | let adds = box.seen.dropFirst(before).filter { | |
| 102 | argv(of: $0).starts(with: ["notifications", "device", "add"]) | |
| 103 | } | |
| 104 | #expect(adds.count == 1) | |
| 105 | } | |
| 106 | ||
| 107 | // There is nothing to register with before APNs answers; the | |
| 108 | // token's arrival then registers every account, this one included. | |
| 109 | @Test func registrarRegistersNothingBeforeTheTokenArrives() async throws { | |
| 110 | let (session, box) = try await twoAccounts() | |
| 111 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 112 | let before = box.seen.count | |
| 113 | ||
| 114 | await registrar.register(try #require(session.accounts.first)) | |
| 115 | ||
| 116 | #expect(box.seen.count == before) | |
| 117 | } | |
| 118 | ||
| 119 | // Registration is a side channel: a failure leaves the account working | |
| 120 | // and is retried on the next launch, not surfaced. | |
| 121 | @Test func registrarSurvivesAFailedRegistration() async throws { | |
| 122 | let (session, box) = makeSession() | |
| 123 | box.enqueue(.init(status: 200, json: whoami)) | |
| 124 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | |
| 125 | // The transport retries a 5xx once before giving up. | |
| 126 | box.enqueue(.init(status: 500, json: serverErrorJSON)) | |
| 127 | box.enqueue(.init(status: 500, json: serverErrorJSON)) | |
| 128 | let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) | |
| 129 | ||
| 130 | await registrar.deviceTokenArrived(Data([0x01])) // must not throw | |
| 131 | } | |
| 132 | } | |
gitbayTests/PushTargetTests.swift added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func account(_ host: String, _ user: String) throws -> Account { | |
| 6 | Account(instance: try GitbayInstance(url: "https://\(host)"), username: user) | |
| 7 | } | |
| 8 | ||
| 9 | private func payload(_ instance: String, _ user: String, _ path: String) throws -> PushPayload { | |
| 10 | try #require(PushPayload(userInfo: [ | |
| 11 | "aps": ["alert": ["title": "t", "body": "b"]], | |
| 12 | "instance": instance, "user": user, "path": path, | |
| 13 | ])) | |
| 14 | } | |
| 15 | ||
| 16 | @Test func targetResolvesToTheMatchingAccount() throws { | |
| 17 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | |
| 18 | let target = try #require(PushTarget( | |
| 19 | payload: try payload("https://dev.local", "cmc", "krz/gitbay/mrs/7"), | |
| 20 | accounts: accounts)) | |
| 21 | #expect(target.accountID == accounts[1].id) | |
| 22 | #expect(target.destination == .mr(repo: "krz/gitbay", number: 7)) | |
| 23 | } | |
| 24 | ||
| 25 | // Same username on two instances is two accounts; the instance decides. | |
| 26 | @Test func targetDistinguishesTheSameUserOnTwoInstances() throws { | |
| 27 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | |
| 28 | let target = try #require(PushTarget( | |
| 29 | payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"), | |
| 30 | accounts: accounts)) | |
| 31 | #expect(target.accountID == accounts[0].id) | |
| 32 | } | |
| 33 | ||
| 34 | @Test func targetIsNilForAnAccountSignedOut() throws { | |
| 35 | let accounts = [try account("gitbay.org", "cmc")] | |
| 36 | #expect(PushTarget(payload: try payload("https://other.example", "cmc", "a/b/issues/1"), | |
| 37 | accounts: accounts) == nil) | |
| 38 | } | |
| 39 | ||
| 40 | // A repository root, or a section the app has no screen for, resolves | |
| 41 | // to no target: the tap opens the app without navigating. | |
| 42 | @Test func targetIsNilForAnUnroutablePath() throws { | |
| 43 | let accounts = [try account("gitbay.org", "cmc")] | |
| 44 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay"), | |
| 45 | accounts: accounts) == nil) | |
| 46 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay/wiki/Home"), | |
| 47 | accounts: accounts) == nil) | |
| 48 | } | |
| 49 | ||
| 50 | // A server's unnormalised site_url must not desync a payload from the | |
| 51 | // account it belongs to: both sides go through GitbayInstance's own | |
| 52 | // normalisation before comparing. | |
| 53 | @Test func targetMatchesAnInstanceWithATrailingSlash() throws { | |
| 54 | let accounts = [try account("gitbay.org", "cmc")] | |
| 55 | let target = try #require(PushTarget( | |
| 56 | payload: try payload("https://gitbay.org/", "cmc", "a/b/issues/1"), | |
| 57 | accounts: accounts)) | |
| 58 | #expect(target.accountID == accounts[0].id) | |
| 59 | } | |
| 60 | ||
| 61 | @Test func targetMatchesAnInstanceWithAnExplicitDefaultPort() throws { | |
| 62 | let accounts = [try account("gitbay.org", "cmc")] | |
| 63 | let target = try #require(PushTarget( | |
| 64 | payload: try payload("https://gitbay.org:443", "cmc", "a/b/issues/1"), | |
| 65 | accounts: accounts)) | |
| 66 | #expect(target.accountID == accounts[0].id) | |
| 67 | } | |
| 68 | ||
| 69 | // The other side of the same mismatch: an account stored before the | |
| 70 | // constructor folded the default port keeps ":443" in its baseURL, so | |
| 71 | // decoding is the only way to build one. Its id keeps the port — | |
| 72 | // rewriting that would orphan the Keychain token keyed by it. | |
| 73 | @Test func targetMatchesAnAccountStoredWithAnExplicitDefaultPort() throws { | |
| 74 | let legacy = try JSONDecoder().decode( | |
| 75 | GitbayInstance.self, from: Data(#"{"baseURL":"https://gitbay.org:443"}"#.utf8)) | |
| 76 | let accounts = [Account(instance: legacy, username: "cmc")] | |
| 77 | #expect(accounts[0].id.contains(":443")) | |
| 78 | ||
| 79 | let target = try #require(PushTarget( | |
| 80 | payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"), | |
| 81 | accounts: accounts)) | |
| 82 | #expect(target.accountID == accounts[0].id) | |
| 83 | } | |
gitbayTests/SessionClientTests.swift added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private let whoami = """ | |
| 6 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 7 | """ | |
| 8 | ||
| 9 | @MainActor | |
| 10 | private func makeSession() -> (SessionStore, StubProtocol.Box) { | |
| 11 | let box = StubProtocol.box() | |
| 12 | let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! | |
| 13 | let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in | |
| 14 | GitbayClient(instance: instance, token: token, session: box.session()) | |
| 15 | } | |
| 16 | return (session, box) | |
| 17 | } | |
| 18 | ||
| 19 | /// Signs two accounts in, on different instances. | |
| 20 | @MainActor | |
| 21 | private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { | |
| 22 | let (session, box) = makeSession() | |
| 23 | box.enqueue(.init(status: 200, json: whoami)) | |
| 24 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | |
| 25 | box.enqueue(.init(status: 200, json: whoami)) | |
| 26 | try await session.signIn(instanceURL: "https://dev.local", token: "t2") | |
| 27 | return (session, box) | |
| 28 | } | |
| 29 | ||
| 30 | @Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws { | |
| 31 | let (session, _) = try await twoAccounts() | |
| 32 | let inactive = try #require(session.accounts.first { $0.id != session.current?.id }) | |
| 33 | #expect(session.client(for: inactive) != nil) | |
| 34 | // Vending does not change which account is active. | |
| 35 | #expect(session.current?.id != inactive.id) | |
| 36 | } | |
| 37 | ||
| 38 | @Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws { | |
| 39 | let (session, _) = try await twoAccounts() | |
| 40 | let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x") | |
| 41 | #expect(session.client(for: stranger) == nil) | |
| 42 | } | |
| 43 | ||
| 44 | // The hook runs before the token is discarded: deregistering a device | |
| 45 | // needs the credential remove() is about to delete. | |
| 46 | @Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws { | |
| 47 | let (session, _) = try await twoAccounts() | |
| 48 | let victim = try #require(session.accounts.first) | |
| 49 | var sawTokenDuringHook: Bool? | |
| 50 | // [weak session] because this closure is stored ON session; the | |
| 51 | // production wiring in gitbayApp captures the registrar weakly for | |
| 52 | // the same reason. | |
| 53 | session.willRemoveAccount = { [weak session] account in | |
| 54 | sawTokenDuringHook = session?.client(for: account) != nil | |
| 55 | } | |
| 56 | await session.remove(victim) | |
| 57 | #expect(sawTokenDuringHook == true) | |
| 58 | #expect(session.client(for: victim) == nil) | |
| 59 | } | |
| 60 | ||
| 61 | // The counterpart to the removal hook: an account added while the app | |
| 62 | // runs has no push device row until something registers it, and | |
| 63 | // nothing else does before the next cold launch. | |
| 64 | @Test @MainActor func signInRunsTheAddHook() async throws { | |
| 65 | let (session, box) = makeSession() | |
| 66 | var added: [String] = [] | |
| 67 | session.didAddAccount = { added.append($0.id) } | |
| 68 | ||
| 69 | box.enqueue(.init(status: 200, json: whoami)) | |
| 70 | try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") | |
| 71 | ||
| 72 | #expect(added == [try #require(session.current).id]) | |
| 73 | } | |
| 74 | ||
| 75 | // A sign-in that stored nothing must not announce an account. | |
| 76 | @Test @MainActor func aFailedSignInDoesNotRunTheAddHook() async throws { | |
| 77 | let (session, box) = makeSession() | |
| 78 | var hookRan = false | |
| 79 | session.didAddAccount = { _ in hookRan = true } | |
| 80 | ||
| 81 | box.enqueue(.init(status: 401, json: #"{"protocol_version":1,"error":"nope","exit_code":4}"#)) | |
| 82 | await #expect(throws: (any Error).self) { | |
| 83 | try await session.signIn(instanceURL: "https://gitbay.org", token: "bad") | |
| 84 | } | |
| 85 | ||
| 86 | #expect(hookRan == false) | |
| 87 | #expect(session.accounts.isEmpty) | |
| 88 | } | |
| 89 | ||
| 90 | // expire is reached only after the server has already rejected the | |
| 91 | // account's token with a 401 (revocation, or handle's | |
| 92 | // requiresReauthentication path), so a deregistration call carrying | |
| 93 | // that token would fail the same way. It deliberately does not run | |
| 94 | // the hook; the stale device row is cleared from the web settings | |
| 95 | // page instead. See the comment on expire(_:) in SessionStore. | |
| 96 | @Test @MainActor func expireDoesNotRunTheRemovalHook() async throws { | |
| 97 | let (session, _) = try await twoAccounts() | |
| 98 | let account = try #require(session.current) | |
| 99 | var hookRan = false | |
| 100 | session.willRemoveAccount = { _ in hookRan = true } | |
| 101 | ||
| 102 | session.handle(GitbayError.unauthorized("invalid or expired token")) | |
| 103 | ||
| 104 | #expect(hookRan == false) | |
| 105 | #expect(session.accounts.contains(account) == false) | |
| 106 | } | |
gitbayTests/SessionStoreTests.swift +1 −1
| @@ -131,7 +131,7 @@ struct SessionStoreTests { | ||
| 131 | 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") |
| 132 | 132 | let active = try #require(session.current) |
| 133 | 133 | |
| 134 | session.remove(active) | |
| 134 | await session.remove(active) | |
| 135 | 135 | |
| 136 | 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") |
| 137 | 137 | #expect(session.accounts.count == 1) |