Commit f0267e5ff1

f0267e5ff1f4ec8c6f74d945004377c7d8ce3973

parent: d5499b57f6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 23:57 UTC

push: register an account that signs in while the app runs

The spec names three registration events: the APNs token arriving, an
account signing in, and launch. Only two were wired, so an account
added while the app ran had no device row until the next cold launch.
SessionStore gains didAddAccount beside willRemoveAccount, fired at
the end of a successful signIn and pointed at the registrar in the
same place the removal hook is.

deviceToken is private rather than private(set): the registrar is in
the environment, so a readable token was one any view could put on a
screen.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Auth/SessionStore.swift +7
@@ -76,6 +76,7 @@ final class SessionStore {
7676 currentAccountID = account.id
7777 signedOutMessage = nil
7878 watchForRevocation(candidate, account: account)
79 await didAddAccount?(account)
7980 }
8081
8182 /// A client for any signed-in account, active or not. Push
@@ -91,6 +92,12 @@ final class SessionStore {
9192 /// nothing about push beyond that something wants to run first.
9293 var willRemoveAccount: ((Account) async -> Void)?
9394
95 /// Runs after an account signs in, with its token stored. The
96 /// counterpart to `willRemoveAccount`: an account added while the
97 /// app runs has no push device row until something registers it,
98 /// and nothing else would until the next cold launch.
99 var didAddAccount: ((Account) async -> Void)?
100
94101 /// Switch to another stored account. Returns false if its token is
95102 /// gone from the Keychain, in which case the account is dropped too.
96103 @discardableResult
gitbay/Push/PushRegistrar.swift +15 −2
@@ -17,7 +17,10 @@ final class PushRegistrar {
1717 private let defaults: UserDefaults
1818
1919 /// The APNs token as lowercase hex, once iOS has handed it over.
20 private(set) var deviceToken: String?
20 /// Private rather than `private(set)`: this object is in the
21 /// environment, so a readable token would be one any view could
22 /// put on a screen.
23 private var deviceToken: String?
2124
2225 init(session: SessionStore, defaults: UserDefaults = .standard) {
2326 self.session = session
@@ -39,6 +42,14 @@ final class PushRegistrar {
3942 }
4043 }
4144
45 /// Registers one account, for one that signs in while the app is
46 /// running. Does nothing until APNs has handed over a token; the
47 /// account is picked up by `registerAll` when it does.
48 func register(_ account: Account) async {
49 guard let token = deviceToken else { return }
50 await register(account, token: token)
51 }
52
4253 private func register(_ account: Account, token: String) async {
4354 guard let client = session.client(for: account) else { return }
4455 nonisolated struct Registered: Decodable, Sendable { let id: Int64 }
@@ -91,7 +102,9 @@ final class PushRegistrar {
91102 await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
92103 }
93104
94 /// Re-registers on launch when permission is already granted.
105 /// Asks iOS for a token when permission is already granted: on
106 /// launch, and when the user grants it in Settings rather than
107 /// through the toggle. Idempotent — `device add` upserts.
95108 func registerIfAlreadyAuthorized() async {
96109 if await authorizationStatus() == .authorized {
97110 UIApplication.shared.registerForRemoteNotifications()
gitbay/gitbayApp.swift +3
@@ -40,6 +40,9 @@ struct gitbayApp: App {
4040 session.willRemoveAccount = { [weak registrar] account in
4141 await registrar?.deregister(account)
4242 }
43 session.didAddAccount = { [weak registrar] account in
44 await registrar?.register(account)
45 }
4346 // A tap that launched the app reaches the delegate
4447 // before this runs; it parked the payload.
4548 appDelegate.deliverPendingNotification()
gitbayTests/PushRegistrarTests.swift +31
@@ -85,6 +85,37 @@ struct PushRegistrarTests {
8585 #expect(argv(of: removes[0]).last == "7")
8686 }
8787
88 // What SessionStore.didAddAccount calls: the account that just
89 // signed in gets a row, and no other account is touched.
90 @Test func registrarRegistersOneAccountOnDemand() async throws {
91 let (session, box) = try await twoAccounts()
92 box.enqueue(.init(status: 200, json: registeredJSON))
93 box.enqueue(.init(status: 200, json: registeredJSON))
94 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
95 await registrar.deviceTokenArrived(Data([0x01]))
96 let before = box.seen.count
97
98 box.enqueue(.init(status: 200, json: registeredJSON))
99 await registrar.register(try #require(session.accounts.last))
100
101 let adds = box.seen.dropFirst(before).filter {
102 argv(of: $0).starts(with: ["notifications", "device", "add"])
103 }
104 #expect(adds.count == 1)
105 }
106
107 // There is nothing to register with before APNs answers; the
108 // token's arrival then registers every account, this one included.
109 @Test func registrarRegistersNothingBeforeTheTokenArrives() async throws {
110 let (session, box) = try await twoAccounts()
111 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
112 let before = box.seen.count
113
114 await registrar.register(try #require(session.accounts.first))
115
116 #expect(box.seen.count == before)
117 }
118
88119 // Registration is a side channel: a failure leaves the account working
89120 // and is retried on the next launch, not surfaced.
90121 @Test func registrarSurvivesAFailedRegistration() async throws {
gitbayTests/SessionClientTests.swift +29
@@ -58,6 +58,35 @@ private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) {
5858 #expect(session.client(for: victim) == nil)
5959}
6060
61// The counterpart to the removal hook: an account added while the app
62// runs has no push device row until something registers it, and
63// nothing else does before the next cold launch.
64@Test @MainActor func signInRunsTheAddHook() async throws {
65 let (session, box) = makeSession()
66 var added: [String] = []
67 session.didAddAccount = { added.append($0.id) }
68
69 box.enqueue(.init(status: 200, json: whoami))
70 try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
71
72 #expect(added == [try #require(session.current).id])
73}
74
75// A sign-in that stored nothing must not announce an account.
76@Test @MainActor func aFailedSignInDoesNotRunTheAddHook() async throws {
77 let (session, box) = makeSession()
78 var hookRan = false
79 session.didAddAccount = { _ in hookRan = true }
80
81 box.enqueue(.init(status: 401, json: #"{"protocol_version":1,"error":"nope","exit_code":4}"#))
82 await #expect(throws: (any Error).self) {
83 try await session.signIn(instanceURL: "https://gitbay.org", token: "bad")
84 }
85
86 #expect(hookRan == false)
87 #expect(session.accounts.isEmpty)
88}
89
6190// expire is reached only after the server has already rejected the
6291// account's token with a 401 (revocation, or handle's
6392// requiresReauthentication path), so a deregistration call carrying