| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import UserNotifications |
| 4 | |
| 5 | // APNs plumbing for the shell. The server relays only ciphertext (see apns.go); |
| 6 | // the notification extension (LChatNSE) decrypts locally, so this file is just |
| 7 | // registration + routing: |
| 8 | // - ask permission and register once the user has a chat |
| 9 | // - post the APNs token to EVERY joined instance (each server pushes its own) |
| 10 | // - suppress banners for the chat that's open on-screen |
| 11 | // - tapping a notification deep-links to its chat |
| 12 | // |
| 13 | // Note: receiving real pushes needs the aps-environment entitlement (paid team, |
| 14 | // see ios/README.md go-live notes); registration silently no-ops without it. |
| 15 | final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 16 | private static let tokenKey = "lchat.apnsToken" // last token, for re-subscribing new instances |
| 17 | |
| 18 | // A tapped notification's thread id, awaiting routing to its chat. Published — |
| 19 | // NOT an ephemeral NotificationCenter post — so a COLD-LAUNCH tap survives: the |
| 20 | // OS can deliver the tap before SwiftUI has subscribed, which would drop a post. |
| 21 | // RootView both reacts to changes and drains any value already waiting on first |
| 22 | // appear, so the tap routes whether it arrives before or after the view is up. |
| 23 | @Published var pendingThreadID: String? |
| 24 | |
| 25 | // Bumped whenever a push lands while the app is open, for a thread other than |
| 26 | // the one on-screen. RootView watches it and re-fetches the active thread list, |
| 27 | // so a chat someone *just* started with us (or a message in another chat) shows |
| 28 | // up live instead of only after a pull-to-refresh or relaunch. |
| 29 | @Published var inboxPing = 0 |
| 30 | |
| 31 | // A thread that should jump straight to its newest message the next time it |
| 32 | // appears — set on a notification tap, so "you have a new message" always lands |
| 33 | // on that message even if the parked feed had been scrolled up into history. |
| 34 | static var forceScrollThreadID: String? |
| 35 | |
| 36 | // A tapped changelog banner ("What's new in …", announce.go) awaiting routing. |
| 37 | // The payload carries no thread id — the server can't know the Admin thread's |
| 38 | // id (its name rides encrypted) — so RootView resolves it locally: the current |
| 39 | // space's Admin app-thread if there is one, else just the app. Published for |
| 40 | // the same cold-launch reason as pendingThreadID. |
| 41 | @Published var pendingChangelogTap = false |
| 42 | |
| 43 | func application(_ application: UIApplication, |
| 44 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 45 | UNUserNotificationCenter.current().delegate = self |
| 46 | // BGTaskScheduler requires registration before launch finishes. |
| 47 | BackgroundRefresh.register() |
| 48 | // Signposts + (consent-gated) perf recording — apps only, never the NSE. |
| 49 | Perf.install() |
| 50 | #if DEBUG |
| 51 | // The sim rig's notification-tap door (the LCHAT_DEV_JOIN pattern): a |
| 52 | // thread id in the environment replays a tap's exact effect — what |
| 53 | // didReceive below does — shortly after launch, so the cold-launch |
| 54 | // tap route can run unattended in the simulator, where no script can |
| 55 | // press a springboard banner. |
| 56 | if let thread = ProcessInfo.processInfo.environment["LCHAT_TEST_TAP_THREAD"], |
| 57 | !thread.isEmpty { |
| 58 | let delay = Double(ProcessInfo.processInfo |
| 59 | .environment["LCHAT_TEST_TAP_DELAY"] ?? "") ?? 0.5 |
| 60 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in |
| 61 | Self.forceScrollThreadID = thread |
| 62 | self?.pendingThreadID = thread |
| 63 | } |
| 64 | } |
| 65 | #endif |
| 66 | return true |
| 67 | } |
| 68 | |
| 69 | // Not push at all, but this class is the app's one UIApplicationDelegate: |
| 70 | // the iPhone app is portrait-only EXCEPT while the video lightbox is up |
| 71 | // (VideoOrientation), when a playing video may rotate into landscape |
| 72 | // fullscreen. Implementing this callback overrides the Info.plist masks, |
| 73 | // so it must reproduce the plist's iPad "all orientations" too. |
| 74 | func application(_ application: UIApplication, |
| 75 | supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { |
| 76 | if UIDevice.current.userInterfaceIdiom == .pad { return .all } |
| 77 | return VideoOrientation.allowsLandscape ? .allButUpsideDown : .portrait |
| 78 | } |
| 79 | |
| 80 | /// Ask for permission (first time) and (re)register. Call whenever the app |
| 81 | /// has at least one instance — repeat calls are cheap and idempotent. |
| 82 | func enablePush() { |
| 83 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in |
| 84 | guard granted else { return } |
| 85 | DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func application(_ application: UIApplication, |
| 90 | didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { |
| 91 | let hex = deviceToken.map { String(format: "%02x", $0) }.joined() |
| 92 | UserDefaults.standard.set(hex, forKey: Self.tokenKey) |
| 93 | subscribeAll(apnsToken: hex) |
| 94 | } |
| 95 | |
| 96 | func application(_ application: UIApplication, |
| 97 | didFailToRegisterForRemoteNotificationsWithError error: Error) { |
| 98 | // Silent on dev builds without the push entitlement; real failures are |
| 99 | // visible as a missing /api/apns/subscribe in the server's router log. |
| 100 | } |
| 101 | |
| 102 | /// Push the stored APNs token to an instance that was just added (the |
| 103 | /// registration callback only fires once per token). |
| 104 | func subscribeNewInstance(_ inst: SavedInstance) { |
| 105 | guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return } |
| 106 | subscribe(inst, apnsToken: hex) |
| 107 | } |
| 108 | |
| 109 | private func subscribeAll(apnsToken: String) { |
| 110 | for inst in InstancesStore.load() { subscribe(inst, apnsToken: apnsToken) } |
| 111 | } |
| 112 | |
| 113 | private func subscribe(_ inst: SavedInstance, apnsToken: String) { |
| 114 | guard let base = inst.baseURL else { return } |
| 115 | #if DEBUG |
| 116 | let sandbox = true // Xcode/dev builds get APNs sandbox tokens |
| 117 | #else |
| 118 | let sandbox = false |
| 119 | #endif |
| 120 | var req = URLRequest(url: base.appendingPathComponent("api/apns/subscribe")) |
| 121 | req.httpMethod = "POST" |
| 122 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 123 | req.httpBody = try? JSONSerialization.data(withJSONObject: [ |
| 124 | "token": inst.deviceToken, "apns_token": apnsToken, "sandbox": sandbox, |
| 125 | ]) |
| 126 | URLSession.shared.dataTask(with: req).resume() // best-effort |
| 127 | } |
| 128 | |
| 129 | /// Tear this device's subscription down on every instance — the sign-out |
| 130 | /// path. Without it a signed-out phone keeps getting pushed sealed |
| 131 | /// payloads forever: the wipe removed the keys, so the NSE can neither |
| 132 | /// decrypt them nor drop them (dropping needs the filtering entitlement |
| 133 | /// we don't hold yet). Must run BEFORE the wipe — these calls authenticate |
| 134 | /// with the very instance tokens the wipe erases — and is awaited so the |
| 135 | /// wipe can't race it; a dead instance only costs its own short timeout. |
| 136 | func unsubscribeAll() async { |
| 137 | guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return } |
| 138 | await withTaskGroup(of: Void.self) { group in |
| 139 | for inst in InstancesStore.load() { |
| 140 | group.addTask { await Self.unsubscribe(inst, apnsToken: hex) } |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | private static func unsubscribe(_ inst: SavedInstance, apnsToken: String) async { |
| 146 | guard let base = inst.baseURL else { return } |
| 147 | var req = URLRequest(url: base.appendingPathComponent("api/apns/unsubscribe")) |
| 148 | req.httpMethod = "POST" |
| 149 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 150 | req.timeoutInterval = 5 // sign-out must not hang on an unreachable space |
| 151 | req.httpBody = try? JSONSerialization.data(withJSONObject: [ |
| 152 | "token": inst.deviceToken, "apns_token": apnsToken, |
| 153 | ]) |
| 154 | _ = try? await URLSession.shared.data(for: req) |
| 155 | } |
| 156 | |
| 157 | // MARK: Presentation + taps |
| 158 | |
| 159 | // Which chat is on-screen right now (set by the pushed ThreadDestination) — |
| 160 | // a banner for the conversation you're reading is just noise. |
| 161 | static var activeThreadID: String? |
| 162 | |
| 163 | /// Sweep a read chat's banners out of Notification Center — iOS never does |
| 164 | /// this itself, so without it a message read in the app still sits in the |
| 165 | /// shade. Matched on the payload's thread id, which every push carries; |
| 166 | /// threadIdentifier is only set when the NSE could decrypt, so the |
| 167 | /// placeholder "New message" banners would slip through that match. |
| 168 | static func clearDelivered(thread: String) { |
| 169 | let center = UNUserNotificationCenter.current() |
| 170 | center.getDeliveredNotifications { notes in |
| 171 | let ids = notes |
| 172 | .filter { ($0.request.content.userInfo["thread"] as? String) == thread } |
| 173 | .map { $0.request.identifier } |
| 174 | if !ids.isEmpty { center.removeDeliveredNotifications(withIdentifiers: ids) } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func userNotificationCenter(_ center: UNUserNotificationCenter, |
| 179 | willPresent notification: UNNotification, |
| 180 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { |
| 181 | let thread = notification.request.content.userInfo["thread"] as? String |
| 182 | if let thread, thread == Self.activeThreadID, |
| 183 | UIApplication.shared.applicationState == .active { |
| 184 | completionHandler([]) |
| 185 | return |
| 186 | } |
| 187 | // A push for a chat we're not reading — the list may be stale (a brand-new |
| 188 | // chat isn't in it at all). Nudge RootView to re-fetch. (UN callbacks run on |
| 189 | // the main thread, so touching @Published here is safe.) |
| 190 | inboxPing &+= 1 |
| 191 | completionHandler([.banner, .sound]) |
| 192 | } |
| 193 | |
| 194 | func userNotificationCenter(_ center: UNUserNotificationCenter, |
| 195 | didReceive response: UNNotificationResponse, |
| 196 | withCompletionHandler completionHandler: @escaping () -> Void) { |
| 197 | if let thread = response.notification.request.content.userInfo["thread"] as? String { |
| 198 | // Tapping a notification means "show me that message": mark the thread |
| 199 | // so its feed jumps to the newest message, then hand it to RootView to |
| 200 | // route to the right instance + chat. (UN delegate callbacks are on the |
| 201 | // main thread, so touching @Published / this static here is safe.) |
| 202 | Self.forceScrollThreadID = thread |
| 203 | pendingThreadID = thread |
| 204 | } else if response.notification.request.content.userInfo["changelog"] != nil { |
| 205 | // "What's new" (the release-notes banner): route to the Admin app. |
| 206 | pendingChangelogTap = true |
| 207 | } |
| 208 | completionHandler() |
| 209 | } |
| 210 | } |
| 211 | |