Commit d1fb0150b5
Verified · cmc
Layout: unified · split
gitbay/Push/PushRegistrar.swift added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import 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 | |
| 13 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private let whoami = """ | |
| 6 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 7 | """ | |
| 8 | private let registeredJSON = """ | |
| 9 | {"protocol_version":1,"data":{"id":7,"status":"registered"},"exit_code":0} | |
| 10 | """ | |
| 11 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 12 | private let serverErrorJSON = #"{"protocol_version":1,"error":"boom","exit_code":1}"# | |
| 13 | ||
| 14 | @MainActor | |
| 15 | private 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 | |
| 26 | private 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 | ||
| 35 | private 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 | ||
| 42 | private 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. | |
| 51 | private func scratchDefaults() -> UserDefaults { | |
| 52 | UserDefaults(suiteName: "test.\(UUID().uuidString)")! | |
| 53 | } | |
| 54 | ||
| 55 | @MainActor | |
| 56 | struct 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 | } | |