docs/superpowers/plans/2026-09-20-push-notifications.md

main
gitbay-ios/docs/superpowers/plans/2026-09-20-push-notifications.md rendered · source · history · blame · raw

1370 lines · 53607 bytes

14 symbols in this file

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 <owner>/<repo>/<section>/<n>.

  • 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:

    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": <n>, "status": "registered"}; the APNs payload gains aps.badge.

  • Step 1: Branch

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:

// 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:

	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:

// 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:

	// 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:

		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)
		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:

		"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
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"
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:

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
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:

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
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:

/// Where a notification's `path` points — a web-style route,
/// `<owner>/<repo>/<section>/<n>`. 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:

    var destination: NotificationDestination? { notificationDestination(forPath: path) }

Keep the existing doc comment on the property.

  • Step 2: Write the failing test

gitbayTests/PushTargetTests.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:

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 URLs 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:

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
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:

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:

    /// 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:

    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:

                            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
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:

@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:

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
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 version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>aps-environment</key>
	<string>development</string>
</dict>
</plist>

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
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
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:

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:

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:

    /// 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:

    @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:

            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
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
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:

    @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:

            .task(id: router.pending) { await routePendingNotification() }

and:

    /// 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
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:

    /// `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
    @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
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:

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
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
git push -u origin push
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.