eleven / ocho Public

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

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