gitbay/Push/PushRegistrar.swift
128 lines · 5343 bytes
1import Foundation
2import Observation
3import UIKit
4import UserNotifications
5
6/// Registers this install's APNs token against every signed-in account,
7/// and deregisters one when its account goes away.
8///
9/// A device row is keyed on (user, token) server-side, so one install
10/// holds a row per account. Registering only the active account would
11/// silently stop notifications from the others the moment you switched.
12@Observable
13@MainActor
14final class PushRegistrar {
15
16 private let session: SessionStore
17 private let defaults: UserDefaults
18
19 /// The APNs token as lowercase hex, once iOS has handed it over.
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?
24
25 init(session: SessionStore, defaults: UserDefaults = .standard) {
26 self.session = session
27 self.defaults = defaults
28 }
29
30 func deviceTokenArrived(_ data: Data) async {
31 deviceToken = data.map { String(format: "%02x", $0) }.joined()
32 await registerAll()
33 }
34
35 /// Registers every signed-in account. Apple rotates device tokens,
36 /// so this runs on launch too; `device add` upserts on the token, so
37 /// a repeat costs one call and changes nothing.
38 func registerAll() async {
39 guard let token = deviceToken else { return }
40 for account in session.accounts {
41 await register(account, token: token)
42 }
43 }
44
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
53 private func register(_ account: Account, token: String) async {
54 guard let client = session.client(for: account) else { return }
55 nonisolated struct Registered: Decodable, Sendable { let id: Int64 }
56 do {
57 let out = try await client.run(
58 ["notifications", "device", "add", "--label", Self.deviceLabel],
59 stdin: token, as: Registered.self)
60 if let id = out?.id {
61 // Stored as Int, which UserDefaults handles natively —
62 // it has no Int64 overload, so an Int64 goes in as a
63 // boxed NSNumber and comes back out through a
64 // conditional bridge. Int is 64-bit on every device
65 // this ships to.
66 defaults.set(Int(id), forKey: Self.idKey(account))
67 }
68 } catch {
69 // A side channel. The account still works and the next
70 // launch registers again.
71 }
72 }
73
74 /// Removes this device from one account, using the id that account
75 /// returned at registration. Must run while the account's token
76 /// still exists — SessionStore.willRemoveAccount is where it is
77 /// wired, and that hook is awaited before the token is discarded.
78 func deregister(_ account: Account) async {
79 let key = Self.idKey(account)
80 // object(forKey:) rather than integer(forKey:), which cannot
81 // tell a stored 0 from an absent key.
82 guard let id = defaults.object(forKey: key) as? Int,
83 let client = session.client(for: account) else { return }
84 try? await client.run(["notifications", "device", "remove", String(id)])
85 defaults.removeObject(forKey: key)
86 }
87
88 /// Asks iOS for permission and, on approval, registers for remote
89 /// notifications. Called from the notifications screen's toggle, so
90 /// the system prompt only appears because the user asked for it.
91 func requestPushPermission() async -> Bool {
92 let center = UNUserNotificationCenter.current()
93 let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
94 if granted {
95 UIApplication.shared.registerForRemoteNotifications()
96 }
97 return granted
98 }
99
100 /// Whether iOS has been asked, and what it said.
101 func authorizationStatus() async -> UNAuthorizationStatus {
102 await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
103 }
104
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.
108 func registerIfAlreadyAuthorized() async {
109 if await authorizationStatus() == .authorized {
110 UIApplication.shared.registerForRemoteNotifications()
111 }
112 }
113
114 private static func idKey(_ account: Account) -> String {
115 "pushDeviceID#\(account.id)"
116 }
117
118 /// Names the row in `notifications device list` on every surface.
119 ///
120 /// Without the user-assigned-device-name entitlement, iOS already
121 /// answers with a generic, non-empty model name ("iPhone") rather
122 /// than the user's custom name — the `isEmpty` guard is defense in
123 /// depth against a future OS returning nothing at all.
124 private static var deviceLabel: String {
125 let name = UIDevice.current.name
126 return name.isEmpty ? "iPhone" : name
127 }
128}