import LChatCore import UserNotifications // The E2EE half of push: the APNs payload carries only the ciphertext blob and // thread id (apns.go) — this extension decrypts it ON DEVICE with the thread key // the app parked in the shared keychain (SharedThreadKeys) and rewrites the // placeholder "New message" banner into the real sender + text. If anything is // missing (no key yet, undecryptable, dev build without the shared keychain // entitlement) the placeholder ships as-is — never an error, never plaintext // leaving the device. // // Since the decrypt happens here anyway, the message is also FILED into the // shared local store (MessageStore, App Group container) at delivery time — // so when the banner is tapped, the chat renders the message from disk // instantly instead of waiting on a network round-trip. Best-effort like the // banner itself: a store failure never blocks the notification. final class NotificationService: UNNotificationServiceExtension { private var handler: ((UNNotificationContent) -> Void)? private var bestEffort: UNNotificationContent? // One-shot: the store-ingest Task and the expiry callback can race; the // banner must ship exactly once. private let shipLock = NSLock() private var shipped = false private func ship(_ content: UNNotificationContent) { shipLock.lock() let already = shipped shipped = true shipLock.unlock() if !already { handler?(content) } } override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { handler = contentHandler let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() bestEffort = content let info = request.content.userInfo guard let thread = info["thread"] as? String, let blob = info["blob"] as? String, let entry = SharedThreadKeys.lookup(threadID: thread) else { ship(content) return } // Stamp the owning space onto the delivered banner: the tap route // reads it before anything else (NotificationRoute .stamped), so // routing survives the park being evicted between delivery and tap. // On-device only — userInfo never travels anywhere. content.userInfo["origin"] = entry.origin guard let key = try? GroupKey(rawKey: entry.key), let payload = try? key.decryptJSON(MessagePayload.self, blob: blob) else { ship(content) return } // Muted (#578): a muted THREAD (flag parked with the key — belt and // braces, the server already skips these pushes) or a muted PERSON // (the shared muted-people set; their uid rides inside the ciphertext, // so only this post-decrypt point can know) never buzzes. Without the // filtering entitlement the extension must ship *something*, so muted // pushes take the same passive, silent shape the non-author reaction // path uses — no sound, no banner, no lit screen. let mutedPeople = SharedMutedPeople.lookup(origin: entry.origin) let quiet = (entry.muted ?? false) || (payload.uid.map { mutedPeople.contains($0) } ?? false) // Reaction events notify only the reacted-to message's author. The // author's uid (targetUid) rides inside the ciphertext — stamped by the // reacting client, invisible to the server — so the decision can only // happen here, after decryption. Without Apple's notification-filtering // entitlement (com.apple.developer.usernotifications.filtering) the // extension must ship *something*, so non-authors (and un-reacts, and // reactions to legacy uid-less messages) get a passive, silent entry — // no sound, no banner, no lit screen; it only lands in the notification // list. The author gets the real banner. // Senders resolve through the parked member-name map — the name THIS // member shows for them (per-viewer names, #577) — falling back to // the payload's snapshot for older entries. let senderName = { (uid: String?, fallback: String?) -> String? in if let uid, let named = entry.names?[uid], !named.isEmpty { return named } return fallback } if payload.isReactionEvent { let sender = senderName(payload.uid, payload.name) ?? "Someone" let emoji = payload.emoji ?? "" content.title = entry.title content.threadIdentifier = thread let toMine = payload.on == true && payload.targetUid != nil && payload.targetUid == entry.myUid if toMine && !quiet { // In a DM the title already names the reactor; groups prefix them. content.body = entry.group ? "\(sender) reacted \(emoji) to your message" : "Reacted \(emoji) to your message" } else { content.body = payload.on == true ? (entry.group ? "\(sender) reacted \(emoji)" : "Reacted \(emoji)") : (entry.group ? "\(sender) removed a reaction" : "Removed a reaction") content.sound = nil content.interruptionLevel = .passive } ship(content) return } // "Convert to thread": nobody said anything — an inline reply chain // moved into a thread. There is nothing to announce, but without // Apple's notification-filtering entitlement the extension must ship // SOMETHING, so it takes the same passive, silent shape a non-author // reaction does: no sound, no banner, no lit screen, only a list entry. // It is still ingested, so the app opens with the chain already tucked // into its thread rather than inline until the next history pull. if payload.isThreadConvertEvent { content.title = entry.title content.threadIdentifier = thread content.body = convertLine(senderName(payload.uid, payload.name) ?? "Someone", payload.ids?.count ?? 0) content.sound = nil content.interruptionLevel = .passive let id = (info["id"] as? NSNumber)?.int64Value ?? 0 let at = (info["at"] as? NSNumber)?.int64Value ?? 0 guard id > 0, at > 0 else { ship(content) return } let final: UNNotificationContent = content Task { await MessageStore.shared.resume() await MessageStore.shared.upsert( instance: entry.origin, thread: thread, [.init(id: id, at: at, edited: false, reactions: [], payload: payload)]) await MessageStore.shared.suspend() ship(final) } return } let sender = senderName(payload.uid, payload.name) ?? "Someone" // Plain projection, not raw text — a notification body shows the // composer's opt-out escapes and markdown marks otherwise (same rule // as the sidebar previews). var text = markdownPlainText(payload.text ?? "") if text.isEmpty { let n = payload.attachments?.count ?? 0 text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message" } content.title = entry.title // In a DM the title already names the sender; groups prefix them. content.body = entry.group ? "\(sender): \(text)" : text content.threadIdentifier = thread if quiet { // Muted thread or muted sender: full content (it's the member's // own device — nothing to hide), but silent and passive; it only // lands in the notification list. content.sound = nil content.interruptionLevel = .passive } // The row this banner is about, resolved BEFORE the restyle below // because two things need it: the store ingest at the bottom, and the // tap's landing stamp — which has to ride the content the restyle // copies from. Older servers/relays send no id (`renderable` also // rules out state-only app events); both simply mean no target. let rowID = (info["id"] as? NSNumber)?.int64Value ?? 0 let rowAt = (info["at"] as? NSNumber)?.int64Value ?? 0 let stored = rowID > 0 ? ChatFeedModel.renderable(blob, key: key, group: entry.group) : nil // Stamp the message beside the origin, for the same delivery-time-truth // reason: only here is the banner known to be about a real feed row, so // the tap can land on THAT message instead of merely opening the chat // (PushManager → routeNotificationTap → NativeChatScreen.pendingJump). // Reaction pushes returned above and are deliberately never stamped — // a reaction event has no bubble of its own to land on. if stored != nil { content.userInfo["message"] = rowID } // Restyle as a communication notification — the sender's photo (parked // in SharedAvatarCache by the app) as the banner icon, the app icon // badged on its corner. Only a real person-to-person message (named // sender) qualifies; app/system events keep the plain banner, as does // anything the restyle can't handle (no entitlement, no cached photo → // monogram is still applied by the system). Muted pushes skip the // dress-up — a passive list entry doesn't want a contact banner. var deliver: UNNotificationContent = content if !quiet, let name = senderName(payload.uid, payload.name), !name.isEmpty, let styled = CommunicationNotification.decorate( content, senderName: name, senderUID: payload.uid, origin: entry.origin, conversationID: thread, groupName: entry.group ? entry.title : nil, messageText: text) { deliver = styled bestEffort = styled } // Ingest into the shared store when the payload carries the message's // server id (older servers/relays don't — the banner still works, the // app just fetches on open as before). Stored via the same projection // the feed folds (renderable), so app events land as their chat lines. guard let stored, rowAt > 0 else { ship(deliver) return } let final = deliver Task { // A reused NSE process is still gated from the previous push's // close (MessageStore.suspend) — every ingest opens its own bracket. await MessageStore.shared.resume() await MessageStore.shared.upsert( instance: entry.origin, thread: thread, [.init(id: rowID, at: rowAt, edited: false, reactions: [], payload: stored)]) // Drop the connection before shipping: this process suspends the // moment the banner is handed over, and an app-group sqlite lock // held across suspension is the 0xdead10cc kill — for the NSE // itself, and (via the 3s busy_timeout the app then waits out) // for the suspending app blocked behind it. await MessageStore.shared.suspend() ship(final) } } override func serviceExtensionTimeWillExpire() { if let bestEffort { ship(bestEffort) } } }