Push notifications !117
23 files changed, +2741 −28
Layout: unified · split
README.org +1 −1
| @@ -17,7 +17,7 @@ not at a keyboard. | |||
| 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 | | 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 | | Issues | Lists and detail, comments, labels with the colours the web paints them, assignees and milestones, filtering by label, assignee, author and milestone | | 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 | | Search | Title-and-body search within a repository, file contents across one repository, and the whole instance for repositories, issues and merge requests | | 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 | | Repository actions | Fork, bookmark, watch and mute, pin, archive, visibility, topics, branch protection, and dependency checks with their status | | 21 | | Repository actions | Fork, bookmark, watch and mute, pin, archive, visibility, topics, branch protection, and dependency checks with their status | |
| 22 | | Builds | Build list, job picker, logs, and cancelling one that is queued or running | | 22 | | Builds | Build list, job picker, logs, and cancelling one that is queued or running | |
| 23 | | Releases and wiki | Releases list, detail, create, edit and delete. Wikis read from the same instance | | 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 | buildSettings = { | 411 | buildSettings = { |
| 412 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 412 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 413 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 413 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 414 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; | ||
| 414 | CODE_SIGN_STYLE = Automatic; | 415 | CODE_SIGN_STYLE = Automatic; |
| 415 | CURRENT_PROJECT_VERSION = 11; | 416 | CURRENT_PROJECT_VERSION = 11; |
| 416 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 417 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| @@ -446,6 +447,7 @@ | |||
| 446 | buildSettings = { | 447 | buildSettings = { |
| 447 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 448 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 448 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 449 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 450 | CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; | ||
| 449 | CODE_SIGN_STYLE = Automatic; | 451 | CODE_SIGN_STYLE = Automatic; |
| 450 | CURRENT_PROJECT_VERSION = 11; | 452 | CURRENT_PROJECT_VERSION = 11; |
| 451 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 453 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
gitbay/Account/NotificationsViewModel.swift +77 −11
| @@ -26,16 +26,23 @@ nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiabl | |||
| 26 | /// or the `repo` field. A shape the app cannot route (wrong section, | 26 | /// or the `repo` field. A shape the app cannot route (wrong section, |
| 27 | /// missing or non-numeric id, wrong number of components) yields | 27 | /// missing or non-numeric id, wrong number of components) yields |
| 28 | /// nil, which a row renders as untappable rather than crashing. | 28 | /// nil, which a row renders as untappable rather than crashing. |
| 29 | var destination: NotificationDestination? { | 29 | var destination: NotificationDestination? { notificationDestination(forPath: path) } |
| 30 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) | 30 | } |
| 31 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | 31 | |
| 32 | let repo = "\(parts[0])/\(parts[1])" | 32 | /// Where a notification's `path` points — a web-style route, |
| 33 | switch parts[2] { | 33 | /// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong |
| 34 | case "issues": return .issue(repo: repo, number: number) | 34 | /// section, missing or non-numeric id, wrong number of components) |
| 35 | case "mrs": return .mr(repo: repo, number: number) | 35 | /// yields nil. The merge request section is `mrs` on the wire, never |
| 36 | case "builds": return .build(repo: repo, number: number) | 36 | /// `merge_requests`. |
| 37 | default: return nil | 37 | nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { |
| 38 | } | 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 | @MainActor | 60 | @MainActor |
| 54 | final class NotificationsViewModel { | 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 | let list: PagedListModel<InboxNotification> | 79 | let list: PagedListModel<InboxNotification> |
| 57 | /// Off shows unread only, the default listing; on adds `--all`. A | 80 | /// Off shows unread only, the default listing; on adds `--all`. A |
| 58 | /// segmented control is quick to tap repeatedly, so a stale reload | 81 | /// segmented control is quick to tap repeatedly, so a stale reload |
| @@ -69,6 +92,10 @@ final class NotificationsViewModel { | |||
| 69 | 92 | ||
| 70 | private(set) var actionError: String? | 93 | private(set) var actionError: String? |
| 71 | private(set) var working = false | 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 | private let client: GitbayClient | 100 | private let client: GitbayClient |
| 74 | private var reloadTask: Task<Void, Never>? | 101 | private var reloadTask: Task<Void, Never>? |
| @@ -99,21 +126,60 @@ final class NotificationsViewModel { | |||
| 99 | await perform(["notifications", "read", "--all"]) | 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 | private func configureList() { | 153 | private func configureList() { |
| 103 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) | 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 | private func perform(_ argv: [String]) async { | 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 | working = true | 171 | working = true |
| 108 | actionError = nil | 172 | actionError = nil |
| 109 | defer { working = false } | 173 | defer { working = false } |
| 110 | do { | 174 | do { |
| 111 | try await client.run(argv) | 175 | try await client.run(argv) |
| 112 | await load() | 176 | return true |
| 113 | } catch let error as GitbayError { | 177 | } catch let error as GitbayError { |
| 114 | actionError = error.userFacingMessage | 178 | actionError = error.userFacingMessage |
| 179 | return false | ||
| 115 | } catch { | 180 | } catch { |
| 116 | actionError = GitbayError.transport(error).userFacingMessage | 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 | currentAccountID = account.id | 76 | currentAccountID = account.id |
| 77 | signedOutMessage = nil | 77 | signedOutMessage = nil |
| 78 | watchForRevocation(candidate, account: account) | 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 | /// Switch to another stored account. Returns false if its token is | 101 | /// Switch to another stored account. Returns false if its token is |
| 82 | /// gone from the Keychain, in which case the account is dropped too. | 102 | /// gone from the Keychain, in which case the account is dropped too. |
| 83 | @discardableResult | 103 | @discardableResult |
| 84 | func activate(_ account: Account) -> Bool { | 104 | func activate(_ account: Account) -> Bool { |
| 85 | guard let token = store.token(for: account.id) else { | 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 | return false | 110 | return false |
| 88 | } | 111 | } |
| 89 | activate(account, token: token) | 112 | activate(account, token: token) |
| @@ -112,7 +135,20 @@ final class SessionStore { | |||
| 112 | 135 | ||
| 113 | /// Forget an account: token out of the Keychain, account out of the | 136 | /// Forget an account: token out of the Keychain, account out of the |
| 114 | /// list, and if it was active, over to the next one or signed out. | 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 | store.deleteToken(for: account.id) | 152 | store.deleteToken(for: account.id) |
| 117 | accounts.removeAll { $0.id == account.id } | 153 | accounts.removeAll { $0.id == account.id } |
| 118 | try? store.saveAccounts(accounts) | 154 | try? store.saveAccounts(accounts) |
| @@ -126,8 +162,17 @@ final class SessionStore { | |||
| 126 | 162 | ||
| 127 | /// The server said 401: the token is expired or revoked. Sign the | 163 | /// The server said 401: the token is expired or revoked. Sign the |
| 128 | /// account out cleanly and say why — never crash, never loop. | 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 | func expire(_ account: Account) { | 174 | func expire(_ account: Account) { |
| 130 | remove(account) | 175 | removeAccountState(account) |
| 131 | if current == nil { | 176 | if current == nil { |
| 132 | signedOutMessage = | 177 | signedOutMessage = |
| 133 | "The token for \(account.label) is no longer valid. Mint a new one and sign in again." | 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 | struct ContentView: View { | 3 | struct ContentView: View { |
| 4 | 4 | ||
| 5 | @Environment(SessionStore.self) private var session | 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 | var body: some View { | 22 | var body: some View { |
| 8 | if let client = session.client, let account = session.current { | 23 | if let client = session.client, let account = session.current { |
| 9 | TabView { | 24 | TabView(selection: $selectedTab) { |
| 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { | 25 | Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) { |
| 11 | NavigationStack { | 26 | NavigationStack(path: $dashboardPath) { |
| 12 | DashboardView(client: client) | 27 | DashboardView(client: client) |
| 13 | .navigationDestinations(client: client) | 28 | .navigationDestinations(client: client) |
| 14 | } | 29 | } |
| 15 | } | 30 | } |
| 16 | Tab("Feed", systemImage: "bolt") { | 31 | Tab("Feed", systemImage: "bolt", value: Tabs.feed) { |
| 17 | NavigationStack { | 32 | NavigationStack { |
| 18 | FeedView(client: client) | 33 | FeedView(client: client) |
| 19 | .navigationDestinations(client: client) | 34 | .navigationDestinations(client: client) |
| 20 | } | 35 | } |
| 21 | } | 36 | } |
| 22 | Tab("Repositories", systemImage: "books.vertical") { | 37 | Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { |
| 23 | NavigationStack { | 38 | NavigationStack { |
| 24 | RepoListView(client: client) | 39 | RepoListView(client: client) |
| 25 | .navigationDestinations(client: client) | 40 | .navigationDestinations(client: client) |
| 26 | } | 41 | } |
| 27 | } | 42 | } |
| 28 | Tab("Explore", systemImage: "safari") { | 43 | Tab("Explore", systemImage: "safari", value: Tabs.explore) { |
| 29 | NavigationStack { | 44 | NavigationStack { |
| 30 | ExploreView(client: client) | 45 | ExploreView(client: client) |
| 31 | .navigationDestinations(client: client) | 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 | NavigationStack { | 50 | NavigationStack { |
| 36 | ProfileView(client: client, name: account.username, | 51 | ProfileView(client: client, name: account.username, |
| 37 | title: "My Profile") | 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 | } else { | 62 | } else { |
| 45 | NavigationStack { | 63 | NavigationStack { |
| 46 | SignInView() | 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 | #Preview { | 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 | ContentView() | 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 | components.path = "" | 40 | components.path = "" |
| 41 | components.query = nil | 41 | components.query = nil |
| 42 | components.fragment = nil | 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 | guard let url = components.url else { throw InvalidURL.notAURL(trimmed) } | 49 | guard let url = components.url else { throw InvalidURL.notAURL(trimmed) } |
| 44 | self.baseURL = url | 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 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | ||
| 3 | import UserNotifications | ||
| 2 | 4 | ||
| 3 | /// The notification inbox: `notifications list [--all]`, paginated, with | 5 | /// The notification inbox: `notifications list [--all]`, paginated, with |
| 4 | /// a switch between the default unread-only listing and everything. | 6 | /// a switch between the default unread-only listing and everything. |
| 5 | struct NotificationsView: View { | 7 | struct NotificationsView: View { |
| 6 | 8 | ||
| 7 | @State private var model: NotificationsViewModel | 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 | init(client: GitbayClient) { | 14 | init(client: GitbayClient) { |
| 10 | _model = State(initialValue: NotificationsViewModel(client: client)) | 15 | _model = State(initialValue: NotificationsViewModel(client: client)) |
| @@ -12,6 +17,8 @@ struct NotificationsView: View { | |||
| 12 | 17 | ||
| 13 | var body: some View { | 18 | var body: some View { |
| 14 | List { | 19 | List { |
| 20 | pushSection | ||
| 21 | |||
| 15 | Picker("Filter", selection: Bindable(model).showAll) { | 22 | Picker("Filter", selection: Bindable(model).showAll) { |
| 16 | Text("Unread").tag(false) | 23 | Text("Unread").tag(false) |
| 17 | Text("All").tag(true) | 24 | Text("All").tag(true) |
| @@ -49,7 +56,81 @@ struct NotificationsView: View { | |||
| 49 | } | 56 | } |
| 50 | } | 57 | } |
| 51 | .task { await model.load() } | 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 | NavigationLink("Add Account", value: RepoRoute.addAccount) | 280 | NavigationLink("Add Account", value: RepoRoute.addAccount) |
| 281 | if let current = session.current { | 281 | if let current = session.current { |
| 282 | Button("Sign Out", role: .destructive) { | 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 | @main | 3 | @main |
| 4 | struct gitbayApp: App { | 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 | init() { | 11 | init() { |
| 9 | GitbayFonts.register() | 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 | var body: some Scene { | 22 | var body: some Scene { |
| 13 | WindowGroup { | 23 | WindowGroup { |
| 14 | ContentView() | 24 | ContentView() |
| 15 | .environment(session) | 25 | .environment(session) |
| 26 | .environment(router) | ||
| 27 | .environment(registrar) | ||
| 16 | .font(.gbSans(.body)) | 28 | .font(.gbSans(.body)) |
| 17 | .tint(.gbAccent) | 29 | .tint(.gbAccent) |
| 18 | // The screenshot checkpoint forces appearance per run; | 30 | // The screenshot checkpoint forces appearance per run; |
| 19 | // simulator clones ignore the base device's setting. | 31 | // simulator clones ignore the base device's setting. |
| 20 | .preferredColorScheme(Self.forcedColorScheme) | 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 | // One POST, no reload after a refusal. | 215 | // One POST, no reload after a refusal. |
| 216 | #expect(stub.seen.count == before + 1) | 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 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") | 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") |
| 132 | let active = try #require(session.current) | 132 | let active = try #require(session.current) |
| 133 | 133 | ||
| 134 | session.remove(active) | 134 | await session.remove(active) |
| 135 | 135 | ||
| 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") | 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") |
| 137 | #expect(session.accounts.count == 1) | 137 | #expect(session.accounts.count == 1) |