Push notifications !117

merged merged by cmc on 2026-09-21 00:22 UTC · krz/gitbay-ios:push into main

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