# Push notifications Implementation Plan > **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. **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. **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. **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. **Spec:** `docs/superpowers/specs/2026-09-20-push-notifications-design.md` ## Global Constraints - **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. - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. This is absolute. - **Commits must be signed.** Use `git -c commit.gpgsign=true commit`. Both repositories refuse unsigned commits. - **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). - **Commit messages reference the issue:** `Ref krz/gitbay#89` in gitbay-ios, `Ref #89` in gitbay. - **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`. - **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. - **Merge requests are `mrs` on the wire**, never `merge_requests`. The path a notification carries is `//
/`. - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. - **`-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: ```bash cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -30 ``` Wherever a task below says `-only-testing:`, use this instead. The suite is small enough that the cost is seconds. --- ### Task 1: Server prerequisites **Repository: `~/git/krz/gitbay`** (Go, not Swift). **Files:** - Modify: `internal/control/notifications.go` — `runNotificationsDeviceAdd` - Modify: `internal/store/push.go` — `QueuedPush`, `DuePush` - Modify: `internal/push/apns.go` — `Send` - Modify: `internal/push/push.go` — the drainer's `Send` call - Modify: `docs/specs/2026-09-20-ios-push-notifications-design.md` - Modify: `CHANGELOG.org` - Test: `internal/control/notifications_test.go`, `internal/store/push_test.go`, `internal/push/apns_test.go`, `e2e/push_test.go` **Interfaces:** - Consumes: nothing. - Produces: `notifications device add` returns `{"id": , "status": "registered"}`; the APNs payload gains `aps.badge`. - [ ] **Step 1: Branch** ```bash cd ~/git/krz/gitbay && git checkout main && git pull --ff-only && git checkout -b push-device-id-badge ``` - [ ] **Step 2: Write the failing test for the returned id** Append to `internal/control/notifications_test.go`: ```go // device add returns the row id. Without it a client that wants to // deregister has to list devices and match its own token against the // truncated display value, which is identity by rendered string. func TestNotificationsDeviceAddReturnsTheID(t *testing.T) { c := notifTestCtx(t, "alice") c.Stdin = strings.NewReader("DEVTOKEN\n") var out bytes.Buffer c.Stdout, c.JSON = &out, true if code := runNotificationsDeviceAdd(c, nil); code != 0 { t.Fatalf("exit %d", code) } devices, _ := c.Store.PushDevices(c.User.ID) if len(devices) != 1 { t.Fatalf("want one device, got %d", len(devices)) } want := fmt.Sprintf(`"id":%d`, devices[0].ID) if !strings.Contains(out.String(), want) { t.Fatalf("output %s does not carry %s", out.String(), want) } } ``` Check `notifTestCtx`'s real signature in that file before relying on it; earlier work in this feature found the plan's test helpers invented. If it takes different arguments, use what is there. - [ ] **Step 3: Run it and confirm it fails** Run: `go test ./internal/control/ -run TestNotificationsDeviceAddReturnsTheID -v` Expected: FAIL — the output carries `"status":"registered"` and no id. - [ ] **Step 4: Return the id** In `runNotificationsDeviceAdd`, `AddPushDevice` already returns the id — capture it instead of discarding it, and emit it: ```go id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) { fmt.Fprintf(w, "device %d registered\n", id) }) ``` - [ ] **Step 5: Confirm it passes** Run: `go test ./internal/control/ -run TestNotifications -v` Expected: PASS, including the pre-existing notification tests. - [ ] **Step 6: Write the failing test for the badge** Append to `internal/store/push_test.go`: ```go // The queue row carries the recipient's unread count, so the alert can // badge the app icon. Counted at send rather than at enqueue: an inbox // cleared in the seconds before delivery is reflected. func TestDuePushCarriesTheUnreadCount(t *testing.T) { s := pushFixture(t) uid, err := s.CreateUser("alice", false) if err != nil { t.Fatal(err) } repoID, err := s.CreateRepo("user", uid, "app", "public") if err != nil { t.Fatal(err) } if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil { t.Fatal(err) } // Two unread inbox rows, then a queued push. for i := 0; i < 2; i++ { if err := s.AddNotice(uid, repoID, "issue", "bob", "opened issue #1", "alice/app/issues/1"); err != nil { t.Fatal(err) } } if err := s.EnqueuePush(uid, "alice/app", "bob opened issue #1", "alice/app/issues/1"); err != nil { t.Fatal(err) } due, err := s.DuePush(20) if err != nil { t.Fatal(err) } if len(due) != 1 { t.Fatalf("want one queued push, got %d", len(due)) } if due[0].Badge != 2 { t.Fatalf("Badge = %d, want 2", due[0].Badge) } } ``` Check `CreateRepo` and `AddNotice`'s real signatures before relying on them. - [ ] **Step 7: Run it and confirm it fails** Run: `go test ./internal/store/ -run TestDuePushCarriesTheUnreadCount -v` Expected: FAIL — `QueuedPush has no field or method Badge`. - [ ] **Step 8: Carry the count** Add to `QueuedPush` in `internal/store/push.go`: ```go // Badge is the recipient's unread inbox count, for the alert's badge. // Counted here rather than at enqueue so a cleared inbox is reflected. Badge int ``` In `DuePush`, add the subquery to the SELECT and the scan target: ```sql SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts, (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL) ``` ```go if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil { ``` - [ ] **Step 9: Send it** In `internal/push/apns.go`, add `badge int` to `Send`'s parameters after `user`, and put it in the `aps` dictionary: ```go "aps": map[string]any{ "alert": map[string]string{"title": title, "body": body}, "sound": "default", "badge": badge, "thread-id": title, }, ``` In `internal/push/push.go`, pass it: `d.Cl.Send(ctx, q.Token, q.Username, q.Badge, q.Title, q.Body, q.Path)`. Add a payload assertion to `internal/push/apns_test.go`'s `TestSendNamesTheAccount`, and fix the other three `Send` call sites in that file — `go build` will not catch them, only `go vet`. - [ ] **Step 10: Verify** Run: `go build ./... && go vet ./... && go test ./internal/... ./cmd/...` Expected: all pass. `go vet` is what catches stale test callers after the signature change. Then add a badge assertion to `e2e/push_test.go` beside the existing `user` and `instance` checks, and run `go test ./e2e/ -run TestPush -v`. - [ ] **Step 11: Docs** Update the payload block and prose in `docs/specs/2026-09-20-ios-push-notifications-design.md` to include `aps.badge`, and note that `device add` returns the id. Add both to the existing unreleased `v1.32.1` entry in `CHANGELOG.org` — do not create a new version heading. - [ ] **Step 12: Commit and open the MR** ```bash git add -u && git -c commit.gpgsign=true commit -m "push: return the device id and badge the alert device add returned only a status, so a client that wants to deregister had to list devices and match its own token against the truncated display value. It now returns the id AddPushDevice already had. The alert carries the recipient's unread count, counted in DuePush at send rather than at enqueue so a cleared inbox is reflected. Ref #89" ``` ```bash git push -u origin push-device-id-badge ``` Open the MR with `gitbay mr create --source push-device-id-badge --target main --title "push: return the device id and badge the alert" --body "..."`. Do not merge — report back instead. --- ### Task 2: PushPayload **Repository: `~/git/krz/gitbay-ios`** for this and every task below. Branch `push` already exists. **Files:** - Create: `gitbay/Push/PushPayload.swift` - Create: `gitbayTests/PushPayloadTests.swift` **Interfaces:** - Consumes: nothing. - Produces: - `nonisolated struct PushPayload: Equatable, Sendable { let instance: URL; let user: String; let path: String; let badge: Int }` - `init?(userInfo: [AnyHashable: Any])` - [ ] **Step 1: Write the failing test** `gitbayTests/PushPayloadTests.swift`: ```swift import Foundation import Testing @testable import gitbay @Test func payloadDecodesTheAccountAndPath() throws { let userInfo: [AnyHashable: Any] = [ "aps": ["alert": ["title": "krz/gitbay", "body": "cmc opened issue #12"], "badge": 3], "instance": "https://gitbay.org", "user": "cmc", "path": "krz/gitbay/issues/12", ] let payload = try #require(PushPayload(userInfo: userInfo)) #expect(payload.instance == URL(string: "https://gitbay.org")) #expect(payload.user == "cmc") #expect(payload.path == "krz/gitbay/issues/12") #expect(payload.badge == 3) } @Test func payloadTreatsAMissingBadgeAsZero() throws { let userInfo: [AnyHashable: Any] = [ "aps": ["alert": ["title": "t", "body": "b"]], "instance": "https://gitbay.org", "user": "cmc", "path": "a/b/issues/1", ] let payload = try #require(PushPayload(userInfo: userInfo)) #expect(payload.badge == 0) } // A payload from a server that predates the account fields, or one // mangled in transit, is dropped rather than guessed at. @Test func payloadRejectsAnIncompleteNotification() { #expect(PushPayload(userInfo: ["aps": ["alert": "x"], "path": "a/b/issues/1"]) == nil) #expect(PushPayload(userInfo: ["instance": "https://gitbay.org", "user": "cmc"]) == nil) #expect(PushPayload(userInfo: ["instance": "not a url at all", "user": "cmc", "path": "a/b/issues/1"]) == nil) } ``` - [ ] **Step 2: Run it and confirm it fails** ```bash cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' \ -only-testing:gitbayTests/payloadDecodesTheAccountAndPath test 2>&1 | tail -20 ``` Expected: FAIL — `cannot find 'PushPayload' in scope`. - [ ] **Step 3: Write the implementation** `gitbay/Push/PushPayload.swift`: ```swift import Foundation /// A push notification as the app reads it. /// /// `instance` and `user` name the account the notice belongs to. One /// device token is one install, and an install registers against every /// account signed in on it, so `path` alone is ambiguous — two /// instances can hold the same `owner/name`. nonisolated struct PushPayload: Equatable, Sendable { let instance: URL let user: String let path: String /// The recipient's unread count, for the app icon badge. Absent on /// a notification from a server that predates it. let badge: Int init?(userInfo: [AnyHashable: Any]) { guard let raw = userInfo["instance"] as? String, let instance = URL(string: raw), instance.scheme != nil, let user = userInfo["user"] as? String, !user.isEmpty, let path = userInfo["path"] as? String, !path.isEmpty else { return nil } self.instance = instance self.user = user self.path = path self.badge = (userInfo["aps"] as? [AnyHashable: Any])?["badge"] as? Int ?? 0 } } ``` - [ ] **Step 4: Confirm it passes** Run the three tests by name as in Step 2. Expected: PASS. - [ ] **Step 5: Commit** ```bash git add gitbay/Push/PushPayload.swift gitbayTests/PushPayloadTests.swift git -c commit.gpgsign=true commit -m "push: decode a notification payload instance and user name the account a notice belongs to; path alone is ambiguous when one install is registered against several. Ref krz/gitbay#89" ``` --- ### Task 3: Route parsing shared with the inbox, and PushTarget **Files:** - Modify: `gitbay/Account/NotificationsViewModel.swift:29-40` (`InboxNotification.destination`) - Create: `gitbay/Push/PushTarget.swift` - Create: `gitbayTests/PushTargetTests.swift` **Interfaces:** - Consumes: `PushPayload` from Task 2; `NotificationDestination` (existing, cases `.issue`/`.mr`/`.build`, each `(repo: String, number: Int64)`). - Produces: - `nonisolated func notificationDestination(forPath path: String) -> NotificationDestination?` - `nonisolated struct PushTarget: Equatable, Sendable { let accountID: String; let destination: NotificationDestination }` - `init?(payload: PushPayload, accounts: [Account])` - [ ] **Step 1: Extract the existing path parsing** `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: ```swift /// Where a notification's `path` points — a web-style route, /// `//
/`. A shape the app cannot route (wrong /// section, missing or non-numeric id, wrong number of components) /// yields nil. The merge request section is `mrs` on the wire, never /// `merge_requests`. nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { let parts = path.split(separator: "/", omittingEmptySubsequences: false) guard parts.count == 4, let number = Int64(parts[3]) else { return nil } let repo = "\(parts[0])/\(parts[1])" switch parts[2] { case "issues": return .issue(repo: repo, number: number) case "mrs": return .mr(repo: repo, number: number) case "builds": return .build(repo: repo, number: number) default: return nil } } ``` and in `InboxNotification`: ```swift var destination: NotificationDestination? { notificationDestination(forPath: path) } ``` Keep the existing doc comment on the property. - [ ] **Step 2: Write the failing test** `gitbayTests/PushTargetTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func account(_ host: String, _ user: String) throws -> Account { Account(instance: try GitbayInstance(url: "https://\(host)"), username: user) } private func payload(_ instance: String, _ user: String, _ path: String) throws -> PushPayload { try #require(PushPayload(userInfo: [ "aps": ["alert": ["title": "t", "body": "b"]], "instance": instance, "user": user, "path": path, ])) } @Test func targetResolvesToTheMatchingAccount() throws { let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] let target = try #require(PushTarget( payload: try payload("https://dev.local", "cmc", "krz/gitbay/mrs/7"), accounts: accounts)) #expect(target.accountID == accounts[1].id) #expect(target.destination == .mr(repo: "krz/gitbay", number: 7)) } // Same username on two instances is two accounts; the instance decides. @Test func targetDistinguishesTheSameUserOnTwoInstances() throws { let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] let target = try #require(PushTarget( payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"), accounts: accounts)) #expect(target.accountID == accounts[0].id) } @Test func targetIsNilForAnAccountSignedOut() throws { let accounts = [try account("gitbay.org", "cmc")] #expect(PushTarget(payload: try payload("https://other.example", "cmc", "a/b/issues/1"), accounts: accounts) == nil) } // A repository root, or a section the app has no screen for, resolves // to no target: the tap opens the app without navigating. @Test func targetIsNilForAnUnroutablePath() throws { let accounts = [try account("gitbay.org", "cmc")] #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay"), accounts: accounts) == nil) #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay/wiki/Home"), accounts: accounts) == nil) } ``` Check `GitbayInstance`'s real initializer before relying on `init(url:)`. - [ ] **Step 3: Run it and confirm it fails** Expected: FAIL — `cannot find 'PushTarget' in scope`. - [ ] **Step 4: Write the implementation** `gitbay/Push/PushTarget.swift`: ```swift import Foundation /// A tapped notification resolved against the accounts signed in on /// this device: which account it belongs to, and the screen it opens. nonisolated struct PushTarget: Equatable, Sendable { let accountID: String let destination: NotificationDestination /// Nil when no signed-in account matches — you signed out of it — /// or when the path names nothing the app can route to. init?(payload: PushPayload, accounts: [Account]) { guard let destination = notificationDestination(forPath: payload.path) else { return nil } let match = accounts.first { $0.username == payload.user && $0.instance.baseURL == payload.instance } guard let match else { return nil } self.accountID = match.id self.destination = destination } } ``` Check how `GitbayInstance` exposes its URL — the spec assumes `baseURL`, matching `Account.id`'s construction. If comparing `URL`s directly proves brittle over a trailing slash, compare `absoluteString` normalised the same way `Account.id` does, and say so in your report. - [ ] **Step 5: Confirm it passes** Run all four `PushTarget` tests plus the existing notification tests, since Step 1 touched shared parsing: ```bash xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' \ -only-testing:gitbayTests test 2>&1 | tail -20 ``` Expected: PASS, with no regression in the inbox notification tests. - [ ] **Step 6: Commit** ```bash git add gitbay/Push/PushTarget.swift gitbayTests/PushTargetTests.swift gitbay/Account/NotificationsViewModel.swift git -c commit.gpgsign=true commit -m "push: resolve a payload to an account and a screen The path parsing the inbox already did becomes a free function both callers share rather than a second copy. Ref krz/gitbay#89" ``` --- ### Task 4: SessionStore vends clients and announces removals **Files:** - Modify: `gitbay/Auth/SessionStore.swift` - Modify: `gitbay/Views/Repos/RepoListView.swift:283` - Create: `gitbayTests/SessionClientTests.swift` **Interfaces:** - Consumes: nothing. - Produces: - `func client(for account: Account) -> GitbayClient?` - `var willRemoveAccount: ((Account) async -> Void)?` - `func remove(_ account: Account) async` — note this becomes `async` - [ ] **Step 1: Write the failing test** `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: ```swift import Foundation import Testing @testable import gitbay private let whoami = """ {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} """ @MainActor private func makeSession() -> (SessionStore, StubProtocol.Box) { let box = StubProtocol.box() let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in GitbayClient(instance: instance, token: token, session: box.session()) } return (session, box) } /// Signs two accounts in, on different instances. @MainActor private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { let (session, box) = makeSession() box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://dev.local", token: "t2") return (session, box) } @Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws { let (session, _) = try await twoAccounts() let inactive = try #require(session.accounts.first { $0.id != session.current?.id }) #expect(session.client(for: inactive) != nil) // Vending does not change which account is active. #expect(session.current?.id != inactive.id) } @Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws { let (session, _) = try await twoAccounts() let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x") #expect(session.client(for: stranger) == nil) } // The hook runs before the token is discarded: deregistering a device // needs the credential remove() is about to delete. @Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws { let (session, _) = try await twoAccounts() let victim = try #require(session.accounts.first) var sawTokenDuringHook: Bool? // [weak session] because this closure is stored ON session; the // production wiring in gitbayApp captures the registrar weakly for // the same reason. session.willRemoveAccount = { [weak session] account in sawTokenDuringHook = session?.client(for: account) != nil } await session.remove(victim) #expect(sawTokenDuringHook == true) #expect(session.client(for: victim) == nil) } ``` `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. - [ ] **Step 2: Run it and confirm it fails** Expected: FAIL — `value of type 'SessionStore' has no member 'client(for:)'`. - [ ] **Step 3: Implement** In `SessionStore`: ```swift /// A client for any signed-in account, active or not. Push /// registration talks to every account on the device, and `store` /// and `makeClient` are private. Vending does not change `current`. func client(for account: Account) -> GitbayClient? { guard let token = store.token(for: account.id) else { return nil } return makeClient(account.instance, token) } /// Runs before an account is removed, while its token still exists. /// `gitbayApp` points this at push deregistration; this type knows /// nothing about push beyond that something wants to run first. var willRemoveAccount: ((Account) async -> Void)? ``` and make `remove` async, awaiting the hook first: ```swift func remove(_ account: Account) async { await willRemoveAccount?(account) store.deleteToken(for: account.id) // ... the rest unchanged ... } ``` - [ ] **Step 4: Update the one call site** `gitbay/Views/Repos/RepoListView.swift:283` becomes: ```swift Task { await session.remove(current) } ``` - [ ] **Step 5: Confirm it passes** Run the whole `gitbayTests` suite — `remove`'s signature changed and other tests may call it. Expected: PASS. - [ ] **Step 6: Commit** ```bash git add gitbay/Auth/SessionStore.swift gitbay/Views/Repos/RepoListView.swift gitbayTests/SessionClientTests.swift git -c commit.gpgsign=true commit -m "auth: vend clients per account, announce removals Push registration talks to every signed-in account, not just the active one, and deregistration needs the token remove() discards. Ref krz/gitbay#89" ``` --- ### Task 5: PushRegistrar **Files:** - Create: `gitbay/Push/PushRegistrar.swift` - Create: `gitbayTests/PushRegistrarTests.swift` **Interfaces:** - Consumes: `SessionStore.client(for:)` and `accounts` from Task 4. - Produces: - `@MainActor @Observable final class PushRegistrar` - `init(session: SessionStore, defaults: UserDefaults = .standard)` - `func deviceTokenArrived(_ data: Data) async` - `func registerAll() async` - `func deregister(_ account: Account) async` - [ ] **Step 1: Write the failing test** `gitbayTests/PushRegistrarTests.swift`. Use `StubProtocol` as the other client tests do: ```swift @Test @MainActor func registrarRegistersEveryAccountOnce() async throws { // session with two accounts; stub returns {"id":7,"status":"registered"} let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef])) let adds = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) } #expect(adds.count == 2) // The token is the lowercase hex of the raw bytes, on stdin, never argv. #expect(stdin(of: adds[0]) == "deadbeef") #expect(!argv(of: adds[0]).contains("deadbeef")) } @Test @MainActor func registrarDeregistersWithTheStoredID() async throws { let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0x01])) box.reset() await registrar.deregister(try #require(session.accounts.first)) let removes = box.requests().filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) } #expect(removes.count == 1) #expect(argv(of: removes[0]).last == "7") } // Registration is a side channel: a failure leaves the account working // and is retried on the next launch, not surfaced. @Test @MainActor func registrarSurvivesAFailedRegistration() async throws { // stub returns 500 let registrar = PushRegistrar(session: session, defaults: scratchDefaults()) await registrar.deviceTokenArrived(Data([0x01])) // must not throw } ``` Write `argv(of:)`, `stdin(of:)` and `scratchDefaults()` as private helpers in this file, modelled on `AccountTests.swift`'s `argvOf`. Use a `UserDefaults(suiteName:)` unique per test so the stored ids do not leak between parallel tests. - [ ] **Step 2: Run it and confirm it fails** Expected: FAIL — `cannot find 'PushRegistrar' in scope`. - [ ] **Step 3: Implement** `gitbay/Push/PushRegistrar.swift`: ```swift import Foundation import Observation /// Registers this install's APNs token against every signed-in account, /// and deregisters one when its account goes away. /// /// A device row is keyed on (user, token) server-side, so one install /// holds a row per account. Registering only the active account would /// silently stop notifications from the others the moment you switched. @Observable @MainActor final class PushRegistrar { private let session: SessionStore private let defaults: UserDefaults /// The APNs token as lowercase hex, once iOS has handed it over. private(set) var deviceToken: String? init(session: SessionStore, defaults: UserDefaults = .standard) { self.session = session self.defaults = defaults } func deviceTokenArrived(_ data: Data) async { deviceToken = data.map { String(format: "%02x", $0) }.joined() await registerAll() } /// Registers every signed-in account. Apple rotates device tokens, /// so this runs on launch too; `device add` upserts on the token, so /// a repeat costs one call and changes nothing. func registerAll() async { guard let token = deviceToken else { return } for account in session.accounts { await register(account, token: token) } } private func register(_ account: Account, token: String) async { guard let client = session.client(for: account) else { return } struct Registered: Decodable, Sendable { let id: Int64 } do { let out = try await client.run( ["notifications", "device", "add", "--label", Self.deviceLabel], stdin: token, as: Registered.self) if let id = out?.id { // Stored as Int, which UserDefaults handles natively — // it has no Int64 overload, so an Int64 goes in as a // boxed NSNumber and comes back out through a // conditional bridge. Int is 64-bit on every device // this ships to. defaults.set(Int(id), forKey: Self.idKey(account)) } } catch { // A side channel. The account still works and the next // launch registers again. } } /// Removes this device from one account, using the id that account /// returned at registration. Must run while the account's token /// still exists — SessionStore.willRemoveAccount is where it is /// wired, and that hook is awaited before the token is discarded. func deregister(_ account: Account) async { let key = Self.idKey(account) // object(forKey:) rather than integer(forKey:), which cannot // tell a stored 0 from an absent key. guard let id = defaults.object(forKey: key) as? Int, let client = session.client(for: account) else { return } try? await client.run(["notifications", "device", "remove", String(id)]) defaults.removeObject(forKey: key) } private static func idKey(_ account: Account) -> String { "pushDeviceID#\(account.id)" } /// Names the row in `notifications device list` on every surface. private static var deviceLabel: String { UIDevice.current.name } } ``` `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. - [ ] **Step 4: Confirm it passes** Run the three registrar tests. Expected: PASS. - [ ] **Step 5: Commit** ```bash git add gitbay/Push/PushRegistrar.swift gitbayTests/PushRegistrarTests.swift git -c commit.gpgsign=true commit -m "push: register this device with every signed-in account Apple rotates tokens, so registration runs on launch as well as on arrival; device add upserts, so a repeat is free. Deregistration uses the id the server now returns rather than matching a truncated token. Ref krz/gitbay#89" ``` --- ### Task 6: Entitlement and capability **Files:** - Create: `gitbay/gitbay.entitlements` - Modify: `gitbay.xcodeproj/project.pbxproj` (build settings only) **Interfaces:** - Consumes: nothing. - Produces: an app that can call `registerForRemoteNotifications()` without iOS refusing. This is the one task that edits `project.pbxproj`. It adds a build setting, not a file reference — the synchronized group still handles files. - [ ] **Step 1: Create the entitlements file** `gitbay/gitbay.entitlements`: ```xml aps-environment development ``` `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. - [ ] **Step 2: Point the target at it** In `gitbay.xcodeproj/project.pbxproj`, add to **both** the Debug and Release build configurations of the `gitbay` app target (the ones that already carry `PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;`): ``` CODE_SIGN_ENTITLEMENTS = gitbay/gitbay.entitlements; ``` Add it beside `CODE_SIGN_STYLE`, keeping the file's tab indentation. Do not touch the test target's configurations. - [ ] **Step 3: Verify it builds and the entitlement is attached** ```bash cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' build 2>&1 | tail -5 ``` Expected: `BUILD SUCCEEDED`. A malformed plist or a wrong path fails here with a code-signing error naming the entitlements file. - [ ] **Step 4: Commit** ```bash git add gitbay/gitbay.entitlements gitbay.xcodeproj/project.pbxproj git -c commit.gpgsign=true commit -m "push: the Push Notifications entitlement aps-environment is development; Xcode rewrites it to production when it exports for TestFlight and the App Store. Ref krz/gitbay#89" ``` --- ### Task 7: The delegate and the router **Files:** - Create: `gitbay/Push/PushRouter.swift` - Create: `gitbay/Push/AppDelegate.swift` - Modify: `gitbay/gitbayApp.swift` **Interfaces:** - Consumes: `PushPayload`, `PushTarget`, `PushRegistrar`, `SessionStore`. - Produces: - `@MainActor @Observable final class PushRouter { var pending: PushTarget? }` - `final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate` - `PushRegistrar` and `PushRouter` in the environment - `func requestPushPermission() async -> Bool` on `PushRegistrar` - [ ] **Step 1: PushRouter** `gitbay/Push/PushRouter.swift`: ```swift import Observation /// Carries a tapped notification's destination into the view tree. /// /// Owned by `gitbayApp`, above `ContentView` — which is `.id(account.id)` /// and is therefore destroyed and rebuilt by the account switch a /// cross-account tap performs. A target held inside that subtree would /// go with it; held here, the rebuilt tree drains it on appear. @Observable @MainActor final class PushRouter { var pending: PushTarget? } ``` - [ ] **Step 2: The delegate** `gitbay/Push/AppDelegate.swift`: ```swift import UIKit import UserNotifications /// The only way to receive an APNs device token and a notification tap. /// SwiftUI has no equivalent, so the app keeps one delegate whose whole /// job is handing both to the push types. final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { @MainActor weak var registrar: PushRegistrar? @MainActor weak var router: PushRouter? @MainActor weak var session: SessionStore? func application( _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { UNUserNotificationCenter.current().delegate = self return true } func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Task { @MainActor in await registrar?.deviceTokenArrived(deviceToken) } } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { // Nothing to do and nothing to say: push is a side channel, and // the next launch tries again. } /// A notification arriving while the app is open still shows — you /// may be reading a different repository when one lands. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { [.banner, .sound, .badge] } func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { await MainActor.run { guard let session, let router, let payload = PushPayload(userInfo: response.notification.request.content.userInfo), let target = PushTarget(payload: payload, accounts: session.accounts) else { return } router.pending = target } } } ``` - [ ] **Step 3: Permission and registration entry point** Add to `PushRegistrar`: ```swift /// Asks iOS for permission and, on approval, registers for remote /// notifications. Called from the notifications screen's toggle, so /// the system prompt only appears because the user asked for it. func requestPushPermission() async -> Bool { let center = UNUserNotificationCenter.current() let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false if granted { UIApplication.shared.registerForRemoteNotifications() } return granted } /// Whether iOS has been asked, and what it said. func authorizationStatus() async -> UNAuthorizationStatus { await UNUserNotificationCenter.current().notificationSettings().authorizationStatus } /// Re-registers on launch when permission is already granted. func registerIfAlreadyAuthorized() async { if await authorizationStatus() == .authorized { UIApplication.shared.registerForRemoteNotifications() } } ``` `import UserNotifications` at the top of that file. - [ ] **Step 4: Wire it in gitbayApp** `gitbay/gitbayApp.swift`: ```swift @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var session = SessionStore() @State private var router = PushRouter() @State private var registrar: PushRegistrar? ``` and in the scene body, attach: ```swift ContentView() .environment(session) .environment(router) .environment(registrar ?? PushRegistrar(session: session)) .task { let registrar = registrar ?? PushRegistrar(session: session) self.registrar = registrar appDelegate.registrar = registrar appDelegate.router = router appDelegate.session = session session.willRemoveAccount = { [weak registrar] account in await registrar?.deregister(account) } await registrar.registerIfAlreadyAuthorized() } ``` If the optional-`registrar` dance proves awkward against Swift 6 isolation, construct the registrar eagerly in an `init()` alongside `GitbayFonts.register()` instead, and say so in your report. The requirement is only that one registrar instance exists, the delegate and the session hook both point at it, and it outlives `ContentView`. - [ ] **Step 5: Verify it builds and the suite still passes** ```bash xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -20 ``` Expected: `TEST SUCCEEDED`. There are no unit tests for the delegate — it needs a running app and a real APNs round trip, which the Simulator cannot do. Task 11 covers it in the live suite. - [ ] **Step 6: Commit** ```bash git add gitbay/Push/PushRouter.swift gitbay/Push/AppDelegate.swift gitbay/Push/PushRegistrar.swift gitbay/gitbayApp.swift git -c commit.gpgsign=true commit -m "push: receive the device token and notification taps PushRouter is owned above ContentView because ContentView is keyed on the account id, so a cross-account tap destroys anything held inside it. Ref krz/gitbay#89" ``` --- ### Task 8: Routing a tap to the screen **Files:** - Modify: `gitbay/ContentView.swift:1-48` **Interfaces:** - Consumes: `PushRouter.pending`, `SessionStore.activate(_:)`. - Produces: nothing later tasks use. - [ ] **Step 1: Give the Dashboard tab a path and the TabView a selection** In `ContentView`: ```swift @Environment(PushRouter.self) private var router @State private var selectedTab = Tabs.dashboard @State private var dashboardPath = NavigationPath() enum Tabs { case dashboard, feed, repositories, explore, profile } ``` Give each `Tab` a `value:` matching the case, bind `TabView(selection: $selectedTab)`, and bind the Dashboard stack: `NavigationStack(path: $dashboardPath)`. Leave the other four stacks exactly as they are — only the deep-link target needs a bound path. - [ ] **Step 2: Drain the pending target** On the `TabView`: ```swift .task(id: router.pending) { await routePendingNotification() } ``` and: ```swift /// Routes a tapped notification, switching accounts first when it /// belongs to another one. /// /// A switch changes `account.id`, which rebuilds this whole view. /// That is why the target is left pending across it: the rebuilt /// tree runs this again and finds the account already active, so the /// second pass does the navigating. The drain is the mechanism, not /// a workaround for it. private func routePendingNotification() async { guard let target = router.pending else { return } if session.current?.id != target.accountID { guard let account = session.accounts.first(where: { $0.id == target.accountID }) else { router.pending = nil // signed out of it since the tap return } _ = session.activate(account) return // the rebuilt tree drains it } selectedTab = .dashboard switch target.destination { case .issue(let repo, let number): dashboardPath.append(IssueRoute.issue(repo: repo, number: number)) case .mr(let repo, let number): dashboardPath.append(MRRoute.mr(repo: repo, number: number)) case .build(let repo, let number): dashboardPath.append(BuildRoute.detail(repo: repo, number: number)) } router.pending = nil } ``` Check the three route case names against `RouteDestinations` in the same file — `BuildRoute.detail` in particular — and use whatever is actually there. - [ ] **Step 3: Verify it builds and the suite passes** Run the full test target as in Task 7 Step 5. Expected: `TEST SUCCEEDED`. - [ ] **Step 4: Commit** ```bash git add gitbay/ContentView.swift git -c commit.gpgsign=true commit -m "push: route a tap to the issue, merge request or build A tap for another account switches to it first; the rebuild that causes is what delivers the route, since the pending target outlives the subtree. Ref krz/gitbay#89" ``` --- ### Task 9: The notifications screen **Files:** - Modify: `gitbay/Views/Account/NotificationsView.swift` - Modify: `gitbay/Account/NotificationsViewModel.swift` **Interfaces:** - Consumes: `PushRegistrar.requestPushPermission()`, `authorizationStatus()`, `deregister(_:)`. - Produces: nothing later tasks use. - [ ] **Step 1: Add the push state to the view model** In `NotificationsViewModel`, add the server-side preference and the device list, both read through the client the model already holds: ```swift /// `notifications settings show` reports mail, watch and push. private(set) var pushEnabled = false private(set) var devices: [PushDevice] = [] nonisolated struct PushDevice: Decodable, Sendable, Identifiable { let id: Int64 let label: String let token: String // truncated by the server; never the whole value let added: String } nonisolated struct PushSettings: Decodable, Sendable { let mail: Bool let watch: Bool let push: Bool } func loadPushSettings() async { guard let settings = try? await client.read( ["notifications", "settings", "show"], as: PushSettings.self) else { return } pushEnabled = settings.push devices = (try? await client.readList( ["notifications", "device", "list"], as: PushDevice.self)) ?? [] } func setPush(_ on: Bool) async { await perform(["notifications", "settings", "push", on ? "on" : "off"]) await loadPushSettings() } func removeDevice(_ id: Int64) async { await perform(["notifications", "device", "remove", String(id)]) await loadPushSettings() } ``` Check `read` and `readList`'s real signatures in `GitbayClient.swift` — this plan assumes an `as:` parameter matching `run`'s. Use whatever is there, and follow the file's existing `perform(_ argv:)` helper for the writes rather than adding a second way to dispatch. The server's `notifications settings show` emits `{"mail": bool, "watch": bool, "push": bool}`; `device list` emits rows of `{id, label, token, added}` with the token already truncated. - [ ] **Step 2: Add the section to the view** Above the existing list, a `Section("Push notifications")` with one toggle whose three states are: | iOS authorization | Toggle | On change | |---|---|---| | `.notDetermined` | off, enabled | `requestPushPermission()`; on approval `setPush(true)` | | `.authorized` | mirrors `pushEnabled` | `setPush(newValue)` | | `.denied` | off, **disabled** | n/a — show the text below | ```swift @Environment(PushRegistrar.self) private var registrar @State private var authorization: UNAuthorizationStatus = .notDetermined private var pushSection: some View { Section("Push notifications") { Toggle("Push notifications", isOn: Binding( get: { authorization == .authorized && model.pushEnabled }, set: { on in Task { await setPush(on) } } )) .disabled(authorization == .denied) if authorization == .denied { Text("Notifications are turned off for gitbay in Settings.") .font(.gbSans(.footnote)) Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } } .task { authorization = await registrar.authorizationStatus() } } private func setPush(_ on: Bool) async { if on, authorization == .notDetermined { let granted = await registrar.requestPushPermission() authorization = await registrar.authorizationStatus() guard granted else { return } } await model.setPush(on) } ``` The `.denied` branch is the case the explicit toggle exists for: without it a single reflexive "Don't Allow" leaves push silent with nothing accounting for it. Match the file's existing font and spacing idiom rather than the `.gbSans` call above if it differs. Below the toggle, the devices with a swipe-to-delete or a trailing remove button, matching how the app presents other removable lists. No add control — only the app can mint an APNs token. - [ ] **Step 3: Verify** Build and run the suite. Expected: `TEST SUCCEEDED`. Then run the app in the Simulator and open the notifications screen. The toggle should appear and, on tap, show the system permission prompt. The Simulator cannot complete registration — there is no APNs — so expect the toggle to return to off after granting. That is correct behaviour there and not a bug to chase. - [ ] **Step 4: Commit** ```bash git add gitbay/Views/Account/NotificationsView.swift gitbay/Account/NotificationsViewModel.swift git -c commit.gpgsign=true commit -m "push: the toggle and device list on the notifications screen Permission is asked when the toggle is turned on, not on arrival: iOS asks once, and a prompt sprung by navigation earns a reflexive refusal that nothing afterwards can explain. Ref krz/gitbay#89" ``` --- ### Task 10: Documentation **Files:** - Modify: `DESIGN.org:245` (the gap table) - Modify: `README.org` (the capability table) - Modify: `~/git/krz/gitbay/.gitbay/wiki/Parity.org` (the ios column) - [ ] **Step 1: DESIGN.org** The gap table row reads "No push notifications | Poll on foreground, use background refresh. Mail notifications already exist server-side. | not planned; propose if the app makes the case". Replace the status with a pointer to the shipped feature, in the style the other done rows use. - [ ] **Step 2: README.org** The capability table has a Notifications row: "The inbox, unread or all, marked read singly or in bulk". Extend it to mention push and that a tap opens the item. - [ ] **Step 3: Parity.org, in the other repository** `~/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. - [ ] **Step 4: Commit** Two commits, one per repository. The gitbay-ios one: ```bash git add DESIGN.org README.org git -c commit.gpgsign=true commit -m "docs: push notifications Closes krz/gitbay#89" ``` --- ### Task 11: Live verification and the merge request **Files:** none. - [ ] **Step 1: Verify the branch** ```bash cd ~/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6' test 2>&1 | tail -20 ``` Expected: `TEST SUCCEEDED`. Do not claim the branch is green without this output in front of you. - [ ] **Step 2: Live push test, on a real device** The Simulator cannot receive a push. On a device, with the app installed from Xcode: 1. Sign in to gitbay.org, open the notifications screen, turn the toggle on, grant permission. 2. On the laptop, confirm the device registered: `gitbay notifications device list --json` should show it with a truncated token. 3. From another account — or the web as a second user — comment on an issue in a repository the signed-in account watches. 4. The notification should arrive within a few seconds, titled with the repository. 5. Tap it. The app should open the issue. 6. Check `gitbay dashboard --json` — the push queue should show zero failed. A climbing `failed` count means `key_id` or `team_id` is wrong, which is the one thing config validation cannot check. A debug build from Xcode carries a **sandbox** token, and gitbay.org is configured `production`. So this test will get `BadDeviceToken` and the device row will be **reaped**. Either point the app at a sandbox-configured instance for this test, or archive a TestFlight build, which carries a production token. Do not skip this step and do not report the feature working without it — every unit test in this plan passes without a single real push having been delivered. - [ ] **Step 3: Open the merge request** ```bash git push -u origin push ``` ```bash gitbay mr create --source push --target main --title "Push notifications" ``` Body via `--body` or `--file -`. State what landed, that it depends on the two server additions in Task 1, and what the live test showed. No attribution to any assistant or model. - [ ] **Step 4: Do not merge** Report back instead. The App Store resubmission, the privacy nutrition label update and the `aps-environment` production export are the human's to do. --- ## Notes for whoever executes this - **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. - **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. - **`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. - **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.