import Foundation import os import UIKit import UserNotifications private let logger = Logger(subsystem: "social.grain.grain", category: "Push") /// Manages push notification registration and token delivery to grain-serve. @Observable @MainActor final class PushManager: NSObject { private weak var authManager: AuthManager? func configure(authManager: AuthManager) { self.authManager = authManager } /// Request notification permission and register for remote notifications. func registerIfNeeded() { Task { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() switch settings.authorizationStatus { case .notDetermined: do { let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) if granted { logger.info("Push permission granted") UIApplication.shared.registerForRemoteNotifications() } } catch { logger.error("Push permission request failed: \(error)") } case .authorized, .provisional: UIApplication.shared.registerForRemoteNotifications() default: logger.info("Push notifications not authorized (status: \(String(describing: settings.authorizationStatus)))") } } } /// Called when APNs returns a device token. func didRegisterForRemoteNotifications(deviceToken: Data) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() logger.info("APNs token: \(token.prefix(16))...") Task { await sendTokenToServer(token: token) } } /// Called when APNs registration fails. func didFailToRegisterForRemoteNotifications(error: Error) { logger.error("APNs registration failed: \(error)") } /// Unregister the current token when an account stops being active — on /// sign-out, or on a switch to another account. /// /// Takes the outgoing account's auth context explicitly and uses a client /// with no refresh hook: by the time this resolves, the app may already be /// acting as somebody else, and unregistering *their* device token is /// exactly the bug this avoids. func unregisterToken(auth: AuthContext?) async { guard let token = currentToken, let auth else { return } currentToken = nil let client = XRPCClient(baseURL: AuthManager.serverURL) do { try await client.procedure( "social.grain.notification.unregisterPush", input: PushDevice(token: token), auth: auth ) logger.info("Push token unregistered from server") } catch { logger.error("Failed to unregister push token: \(error)") } } // MARK: - Private private var currentToken: String? { get { StorageEnvironment.defaults.string(forKey: "apns_device_token") } set { StorageEnvironment.defaults.set(newValue, forKey: "apns_device_token") } } private func sendTokenToServer(token: String) async { currentToken = token guard let authManager, let auth = await authManager.authContext() else { logger.warning("No auth context, skipping token registration") return } let client = authManager.makeClient() do { try await client.procedure( "social.grain.notification.registerPush", input: PushDevice(token: token), auth: auth ) logger.info("Push token registered with server") } catch { logger.error("Failed to register push token: \(error)") } } } /// A device, as `social.grain.notification.registerPush` and /// `unregisterPush` name it: the service that pushes to it (grain-serve), /// its APNs token, and the app it runs. struct PushDevice: Encodable { let serviceDid: String let token: String let platform: String let appId: String init(token: String) { serviceDid = AuthManager.appviewDID self.token = token platform = "ios" appId = Bundle.main.bundleIdentifier ?? "social.grain.grain" } }