eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import 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.
15final 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