Commit d1fb0150b5

d1fb0150b50289d3342a91765cf836a0fe06b5b1

parent: 809306694d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 22:30 UTC

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

Layout: unified · split

gitbay/Push/PushRegistrar.swift added +90
@@ -0,0 +1,90 @@
1import Foundation
2import Observation
3import UIKit
4
5/// Registers this install's APNs token against every signed-in account,
6/// and deregisters one when its account goes away.
7///
8/// A device row is keyed on (user, token) server-side, so one install
9/// holds a row per account. Registering only the active account would
10/// silently stop notifications from the others the moment you switched.
11@Observable
12@MainActor
13final class PushRegistrar {
14
15 private let session: SessionStore
16 private let defaults: UserDefaults
17
18 /// The APNs token as lowercase hex, once iOS has handed it over.
19 private(set) var deviceToken: String?
20
21 init(session: SessionStore, defaults: UserDefaults = .standard) {
22 self.session = session
23 self.defaults = defaults
24 }
25
26 func deviceTokenArrived(_ data: Data) async {
27 deviceToken = data.map { String(format: "%02x", $0) }.joined()
28 await registerAll()
29 }
30
31 /// Registers every signed-in account. Apple rotates device tokens,
32 /// so this runs on launch too; `device add` upserts on the token, so
33 /// a repeat costs one call and changes nothing.
34 func registerAll() async {
35 guard let token = deviceToken else { return }
36 for account in session.accounts {
37 await register(account, token: token)
38 }
39 }
40
41 private func register(_ account: Account, token: String) async {
42 guard let client = session.client(for: account) else { return }
43 nonisolated struct Registered: Decodable, Sendable { let id: Int64 }
44 do {
45 let out = try await client.run(
46 ["notifications", "device", "add", "--label", Self.deviceLabel],
47 stdin: token, as: Registered.self)
48 if let id = out?.id {
49 // Stored as Int, which UserDefaults handles natively —
50 // it has no Int64 overload, so an Int64 goes in as a
51 // boxed NSNumber and comes back out through a
52 // conditional bridge. Int is 64-bit on every device
53 // this ships to.
54 defaults.set(Int(id), forKey: Self.idKey(account))
55 }
56 } catch {
57 // A side channel. The account still works and the next
58 // launch registers again.
59 }
60 }
61
62 /// Removes this device from one account, using the id that account
63 /// returned at registration. Must run while the account's token
64 /// still exists — SessionStore.willRemoveAccount is where it is
65 /// wired, and that hook is awaited before the token is discarded.
66 func deregister(_ account: Account) async {
67 let key = Self.idKey(account)
68 // object(forKey:) rather than integer(forKey:), which cannot
69 // tell a stored 0 from an absent key.
70 guard let id = defaults.object(forKey: key) as? Int,
71 let client = session.client(for: account) else { return }
72 try? await client.run(["notifications", "device", "remove", String(id)])
73 defaults.removeObject(forKey: key)
74 }
75
76 private static func idKey(_ account: Account) -> String {
77 "pushDeviceID#\(account.id)"
78 }
79
80 /// Names the row in `notifications device list` on every surface.
81 ///
82 /// Without the user-assigned-device-name entitlement, iOS already
83 /// answers with a generic, non-empty model name ("iPhone") rather
84 /// than the user's custom name — the `isEmpty` guard is defense in
85 /// depth against a future OS returning nothing at all.
86 private static var deviceLabel: String {
87 let name = UIDevice.current.name
88 return name.isEmpty ? "iPhone" : name
89 }
90}
gitbayTests/PushRegistrarTests.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private let whoami = """
6 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
7 """
8private let registeredJSON = """
9 {"protocol_version":1,"data":{"id":7,"status":"registered"},"exit_code":0}
10 """
11private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
12private let serverErrorJSON = #"{"protocol_version":1,"error":"boom","exit_code":1}"#
13
14@MainActor
15private func makeSession() -> (SessionStore, StubProtocol.Box) {
16 let box = StubProtocol.box()
17 let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
18 let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in
19 GitbayClient(instance: instance, token: token, session: box.session())
20 }
21 return (session, box)
22}
23
24/// Signs two accounts in, on different instances.
25@MainActor
26private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) {
27 let (session, box) = makeSession()
28 box.enqueue(.init(status: 200, json: whoami))
29 try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
30 box.enqueue(.init(status: 200, json: whoami))
31 try await session.signIn(instanceURL: "https://dev.local", token: "t2")
32 return (session, box)
33}
34
35private func argv(of seen: StubProtocol.Seen) -> [String] {
36 guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else {
37 return []
38 }
39 return body["argv"] as? [String] ?? []
40}
41
42private func stdin(of seen: StubProtocol.Seen) -> String? {
43 guard let body = try? JSONSerialization.jsonObject(with: seen.body) as? [String: Any] else {
44 return nil
45 }
46 return body["stdin"] as? String
47}
48
49/// A UserDefaults suite unique to the call, so stored device ids never
50/// leak between parallel tests.
51private func scratchDefaults() -> UserDefaults {
52 UserDefaults(suiteName: "test.\(UUID().uuidString)")!
53}
54
55@MainActor
56struct PushRegistrarTests {
57
58 @Test func registrarRegistersEveryAccountOnce() async throws {
59 let (session, box) = try await twoAccounts()
60 box.enqueue(.init(status: 200, json: registeredJSON))
61 box.enqueue(.init(status: 200, json: registeredJSON))
62 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
63
64 await registrar.deviceTokenArrived(Data([0xde, 0xad, 0xbe, 0xef]))
65
66 let adds = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "add"]) }
67 #expect(adds.count == 2)
68 // The token is the lowercase hex of the raw bytes, on stdin, never argv.
69 #expect(stdin(of: adds[0]) == "deadbeef")
70 #expect(!argv(of: adds[0]).contains("deadbeef"))
71 }
72
73 @Test func registrarDeregistersWithTheStoredID() async throws {
74 let (session, box) = try await twoAccounts()
75 box.enqueue(.init(status: 200, json: registeredJSON))
76 box.enqueue(.init(status: 200, json: registeredJSON))
77 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
78 await registrar.deviceTokenArrived(Data([0x01]))
79
80 box.enqueue(.init(status: 200, json: okJSON))
81 await registrar.deregister(try #require(session.accounts.first))
82
83 let removes = box.seen.filter { argv(of: $0).starts(with: ["notifications", "device", "remove"]) }
84 #expect(removes.count == 1)
85 #expect(argv(of: removes[0]).last == "7")
86 }
87
88 // Registration is a side channel: a failure leaves the account working
89 // and is retried on the next launch, not surfaced.
90 @Test func registrarSurvivesAFailedRegistration() async throws {
91 let (session, box) = makeSession()
92 box.enqueue(.init(status: 200, json: whoami))
93 try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
94 // The transport retries a 5xx once before giving up.
95 box.enqueue(.init(status: 500, json: serverErrorJSON))
96 box.enqueue(.init(status: 500, json: serverErrorJSON))
97 let registrar = PushRegistrar(session: session, defaults: scratchDefaults())
98
99 await registrar.deviceTokenArrived(Data([0x01])) // must not throw
100 }
101}