import LChatCore import SwiftUI import UserNotifications // APNs plumbing for the shell. The server relays only ciphertext (see apns.go); // the notification extension (LChatNSE) decrypts locally, so this file is just // registration + routing: // - ask permission and register once the user has a chat // - post the APNs token to EVERY joined instance (each server pushes its own) // - suppress banners for the chat that's open on-screen // - tapping a notification deep-links to its chat // // Note: receiving real pushes needs the aps-environment entitlement (paid team, // see ios/README.md go-live notes); registration silently no-ops without it. final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { private static let tokenKey = "lchat.apnsToken" // last token, for re-subscribing new instances // A tapped notification's thread id, awaiting routing to its chat. Published — // NOT an ephemeral NotificationCenter post — so a COLD-LAUNCH tap survives: the // OS can deliver the tap before SwiftUI has subscribed, which would drop a post. // RootView both reacts to changes and drains any value already waiting on first // appear, so the tap routes whether it arrives before or after the view is up. @Published var pendingThreadID: String? // The NSE-stamped owning origin riding beside pendingThreadID (nil when the // extension couldn't decrypt). Set BEFORE the published id so the route // always reads a matched pair; plain var — the id's change is the signal. var pendingThreadOrigin: String? // The NSE-stamped MESSAGE the banner was about, riding beside the origin on // the same terms: a tap must land on that message, not merely open its chat. // Nil when the extension couldn't decrypt, when the push predates the stamp, // or for a reaction (no bubble of its own) — the route then falls back to // opening the chat, as it always did. var pendingMessageID: Int64? // Bumped whenever a push lands while the app is open, for a thread other than // the one on-screen. RootView watches it and re-fetches the active thread list, // so a chat someone *just* started with us (or a message in another chat) shows // up live instead of only after a pull-to-refresh or relaunch. @Published var inboxPing = 0 // A thread that should jump straight to its newest message the next time it // appears — set on a notification tap, so "you have a new message" always lands // on that message even if the parked feed had been scrolled up into history. static var forceScrollThreadID: String? // A tapped changelog banner ("What's new in …", announce.go) awaiting routing. // The payload carries no thread id — the server can't know the Admin thread's // id (its name rides encrypted) — so RootView resolves it locally: the current // space's Admin app-thread if there is one, else just the app. Published for // the same cold-launch reason as pendingThreadID. @Published var pendingChangelogTap = false func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self // BGTaskScheduler requires registration before launch finishes. BackgroundRefresh.register() // Signposts + (consent-gated) perf recording — apps only, never the NSE. Perf.install() #if DEBUG // The sim rig's notification-tap door (the LCHAT_DEV_JOIN pattern): a // thread id in the environment replays a tap's exact effect — what // didReceive below does — shortly after launch, so the cold-launch // tap route can run unattended in the simulator, where no script can // press a springboard banner. if let thread = ProcessInfo.processInfo.environment["LCHAT_TEST_TAP_THREAD"], !thread.isEmpty { let delay = Double(ProcessInfo.processInfo .environment["LCHAT_TEST_TAP_DELAY"] ?? "") ?? 0.5 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in Self.forceScrollThreadID = thread self?.pendingThreadID = thread } } #endif return true } // Not push at all, but this class is the app's one UIApplicationDelegate: // the iPhone app is portrait-only EXCEPT while the video lightbox is up // (VideoOrientation), when a playing video may rotate into landscape // fullscreen. Implementing this callback overrides the Info.plist masks, // so it must reproduce the plist's iPad "all orientations" too. func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { if UIDevice.current.userInterfaceIdiom == .pad { return .all } return VideoOrientation.allowsLandscape ? .allButUpsideDown : .portrait } /// Ask for permission (first time) and (re)register. Call whenever the app /// has at least one instance — repeat calls are cheap and idempotent. func enablePush() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in guard granted else { return } DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } } } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() UserDefaults.standard.set(hex, forKey: Self.tokenKey) subscribeAll(apnsToken: hex) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { // Silent on dev builds without the push entitlement; real failures are // visible as a missing /api/apns/subscribe in the server's router log. } /// Push the stored APNs token to an instance that was just added (the /// registration callback only fires once per token). func subscribeNewInstance(_ inst: SavedInstance) { guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return } subscribe(inst, apnsToken: hex) } private func subscribeAll(apnsToken: String) { for inst in InstancesStore.load() { subscribe(inst, apnsToken: apnsToken) } } private func subscribe(_ inst: SavedInstance, apnsToken: String) { guard let base = inst.baseURL else { return } #if DEBUG let sandbox = true // Xcode/dev builds get APNs sandbox tokens #else let sandbox = false #endif var req = URLRequest(url: base.appendingPathComponent("api/apns/subscribe")) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: [ "token": inst.deviceToken, "apns_token": apnsToken, "sandbox": sandbox, ]) URLSession.shared.dataTask(with: req).resume() // best-effort } /// Tear this device's subscription down on every instance — the sign-out /// path. Without it a signed-out phone keeps getting pushed sealed /// payloads forever: the wipe removed the keys, so the NSE can neither /// decrypt them nor drop them (dropping needs the filtering entitlement /// we don't hold yet). Must run BEFORE the wipe — these calls authenticate /// with the very instance tokens the wipe erases — and is awaited so the /// wipe can't race it; a dead instance only costs its own short timeout. func unsubscribeAll() async { guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return } await withTaskGroup(of: Void.self) { group in for inst in InstancesStore.load() { group.addTask { await Self.unsubscribe(inst, apnsToken: hex) } } } } private static func unsubscribe(_ inst: SavedInstance, apnsToken: String) async { guard let base = inst.baseURL else { return } var req = URLRequest(url: base.appendingPathComponent("api/apns/unsubscribe")) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.timeoutInterval = 5 // sign-out must not hang on an unreachable space req.httpBody = try? JSONSerialization.data(withJSONObject: [ "token": inst.deviceToken, "apns_token": apnsToken, ]) _ = try? await URLSession.shared.data(for: req) } // MARK: Presentation + taps // Which chat is on-screen right now (set by the pushed ThreadDestination) — // a banner for the conversation you're reading is just noise. static var activeThreadID: String? /// Sweep a read chat's banners out of Notification Center — iOS never does /// this itself, so without it a message read in the app still sits in the /// shade. Matched on the payload's thread id, which every push carries; /// threadIdentifier is only set when the NSE could decrypt, so the /// placeholder "New message" banners would slip through that match. static func clearDelivered(thread: String) { let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { notes in let ids = notes .filter { ($0.request.content.userInfo["thread"] as? String) == thread } .map { $0.request.identifier } if !ids.isEmpty { center.removeDeliveredNotifications(withIdentifiers: ids) } } } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let thread = notification.request.content.userInfo["thread"] as? String if let thread, thread == Self.activeThreadID, UIApplication.shared.applicationState == .active { completionHandler([]) return } // A push for a chat we're not reading — the list may be stale (a brand-new // chat isn't in it at all). Nudge RootView to re-fetch. (UN callbacks run on // the main thread, so touching @Published here is safe.) inboxPing &+= 1 completionHandler([.banner, .sound]) } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if let thread = response.notification.request.content.userInfo["thread"] as? String { // Tapping a notification means "show me that message": mark the thread // so its feed jumps to the newest message, then hand it to RootView to // route to the right instance + chat. (UN delegate callbacks are on the // main thread, so touching @Published / this static here is safe.) Self.forceScrollThreadID = thread let info = response.notification.request.content.userInfo pendingThreadOrigin = info["origin"] as? String pendingMessageID = (info["message"] as? NSNumber)?.int64Value pendingThreadID = thread } else if response.notification.request.content.userInfo["changelog"] != nil { // "What's new" (the release-notes banner): route to the Admin app. pendingChangelogTap = true } completionHandler() } } extension Notification.Name { /// A notification tap parked a message to land on (NativeChatScreen's /// pendingJump) for a chat that may ALREADY be on screen — the common tap: /// you were reading that chat when the app went to the background. Nothing /// re-appears in that case, so the route posts this and the open screen /// consumes the pending jump. A screen opening fresh picks the jump up /// through its own appear/ready pass and ignores the post. static let lchatJumpToMessage = Notification.Name("lchatJumpToMessage") }