import Foundation import Observation import UIKit import UserNotifications /// Registers this install's APNs token against every signed-in account, /// and deregisters one when its account goes away. /// /// A device row is keyed on (user, token) server-side, so one install /// holds a row per account. Registering only the active account would /// silently stop notifications from the others the moment you switched. @Observable @MainActor final class PushRegistrar { private let session: SessionStore private let defaults: UserDefaults /// The APNs token as lowercase hex, once iOS has handed it over. /// Private rather than `private(set)`: this object is in the /// environment, so a readable token would be one any view could /// put on a screen. private var deviceToken: String? init(session: SessionStore, defaults: UserDefaults = .standard) { self.session = session self.defaults = defaults } func deviceTokenArrived(_ data: Data) async { deviceToken = data.map { String(format: "%02x", $0) }.joined() await registerAll() } /// Registers every signed-in account. Apple rotates device tokens, /// so this runs on launch too; `device add` upserts on the token, so /// a repeat costs one call and changes nothing. func registerAll() async { guard let token = deviceToken else { return } for account in session.accounts { await register(account, token: token) } } /// Registers one account, for one that signs in while the app is /// running. Does nothing until APNs has handed over a token; the /// account is picked up by `registerAll` when it does. func register(_ account: Account) async { guard let token = deviceToken else { return } await register(account, token: token) } private func register(_ account: Account, token: String) async { guard let client = session.client(for: account) else { return } nonisolated struct Registered: Decodable, Sendable { let id: Int64 } do { let out = try await client.run( ["notifications", "device", "add", "--label", Self.deviceLabel], stdin: token, as: Registered.self) if let id = out?.id { // Stored as Int, which UserDefaults handles natively — // it has no Int64 overload, so an Int64 goes in as a // boxed NSNumber and comes back out through a // conditional bridge. Int is 64-bit on every device // this ships to. defaults.set(Int(id), forKey: Self.idKey(account)) } } catch { // A side channel. The account still works and the next // launch registers again. } } /// Removes this device from one account, using the id that account /// returned at registration. Must run while the account's token /// still exists — SessionStore.willRemoveAccount is where it is /// wired, and that hook is awaited before the token is discarded. func deregister(_ account: Account) async { let key = Self.idKey(account) // object(forKey:) rather than integer(forKey:), which cannot // tell a stored 0 from an absent key. guard let id = defaults.object(forKey: key) as? Int, let client = session.client(for: account) else { return } try? await client.run(["notifications", "device", "remove", String(id)]) defaults.removeObject(forKey: key) } /// Asks iOS for permission and, on approval, registers for remote /// notifications. Called from the notifications screen's toggle, so /// the system prompt only appears because the user asked for it. func requestPushPermission() async -> Bool { let center = UNUserNotificationCenter.current() let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false if granted { UIApplication.shared.registerForRemoteNotifications() } return granted } /// Whether iOS has been asked, and what it said. func authorizationStatus() async -> UNAuthorizationStatus { await UNUserNotificationCenter.current().notificationSettings().authorizationStatus } /// Asks iOS for a token when permission is already granted: on /// launch, and when the user grants it in Settings rather than /// through the toggle. Idempotent — `device add` upserts. func registerIfAlreadyAuthorized() async { if await authorizationStatus() == .authorized { UIApplication.shared.registerForRemoteNotifications() } } private static func idKey(_ account: Account) -> String { "pushDeviceID#\(account.id)" } /// Names the row in `notifications device list` on every surface. /// /// Without the user-assigned-device-name entitlement, iOS already /// answers with a generic, non-empty model name ("iPhone") rather /// than the user's custom name — the `isEmpty` guard is defense in /// depth against a future OS returning nothing at all. private static var deviceLabel: String { let name = UIDevice.current.name return name.isEmpty ? "iPhone" : name } }