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