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 UserNotifications
3
4// The E2EE half of push: the APNs payload carries only the ciphertext blob and
5// thread id (apns.go) — this extension decrypts it ON DEVICE with the thread key
6// the app parked in the shared keychain (SharedThreadKeys) and rewrites the
7// placeholder "New message" banner into the real sender + text. If anything is
8// missing (no key yet, undecryptable, dev build without the shared keychain
9// entitlement) the placeholder ships as-is — never an error, never plaintext
10// leaving the device.
11//
12// Since the decrypt happens here anyway, the message is also FILED into the
13// shared local store (MessageStore, App Group container) at delivery time —
14// so when the banner is tapped, the chat renders the message from disk
15// instantly instead of waiting on a network round-trip. Best-effort like the
16// banner itself: a store failure never blocks the notification.
17final class NotificationService: UNNotificationServiceExtension {
18 private var handler: ((UNNotificationContent) -> Void)?
19 private var bestEffort: UNNotificationContent?
20
21 // One-shot: the store-ingest Task and the expiry callback can race; the
22 // banner must ship exactly once.
23 private let shipLock = NSLock()
24 private var shipped = false
25 private func ship(_ content: UNNotificationContent) {
26 shipLock.lock()
27 let already = shipped
28 shipped = true
29 shipLock.unlock()
30 if !already { handler?(content) }
31 }
32
33 override func didReceive(_ request: UNNotificationRequest,
34 withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
35 handler = contentHandler
36 let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
37 bestEffort = content
38
39 let info = request.content.userInfo
40 guard let thread = info["thread"] as? String,
41 let blob = info["blob"] as? String,
42 let entry = SharedThreadKeys.lookup(threadID: thread),
43 let key = try? GroupKey(rawKey: entry.key),
44 let payload = try? key.decryptJSON(MessagePayload.self, blob: blob)
45 else {
46 ship(content)
47 return
48 }
49
50 // Muted (#578): a muted THREAD (flag parked with the key — belt and
51 // braces, the server already skips these pushes) or a muted PERSON
52 // (the shared muted-people set; their uid rides inside the ciphertext,
53 // so only this post-decrypt point can know) never buzzes. Without the
54 // filtering entitlement the extension must ship *something*, so muted
55 // pushes take the same passive, silent shape the non-author reaction
56 // path uses — no sound, no banner, no lit screen.
57 let mutedPeople = SharedMutedPeople.lookup(origin: entry.origin)
58 let quiet = (entry.muted ?? false)
59 || (payload.uid.map { mutedPeople.contains($0) } ?? false)
60
61 // Reaction events notify only the reacted-to message's author. The
62 // author's uid (targetUid) rides inside the ciphertext — stamped by the
63 // reacting client, invisible to the server — so the decision can only
64 // happen here, after decryption. Without Apple's notification-filtering
65 // entitlement (com.apple.developer.usernotifications.filtering) the
66 // extension must ship *something*, so non-authors (and un-reacts, and
67 // reactions to legacy uid-less messages) get a passive, silent entry —
68 // no sound, no banner, no lit screen; it only lands in the notification
69 // list. The author gets the real banner.
70 // Senders resolve through the parked member-name map — the name THIS
71 // member shows for them (per-viewer names, #577) — falling back to
72 // the payload's snapshot for older entries.
73 let senderName = { (uid: String?, fallback: String?) -> String? in
74 if let uid, let named = entry.names?[uid], !named.isEmpty { return named }
75 return fallback
76 }
77
78 if payload.isReactionEvent {
79 let sender = senderName(payload.uid, payload.name) ?? "Someone"
80 let emoji = payload.emoji ?? ""
81 content.title = entry.title
82 content.threadIdentifier = thread
83 let toMine = payload.on == true && payload.targetUid != nil && payload.targetUid == entry.myUid
84 if toMine && !quiet {
85 // In a DM the title already names the reactor; groups prefix them.
86 content.body = entry.group
87 ? "\(sender) reacted \(emoji) to your message"
88 : "Reacted \(emoji) to your message"
89 } else {
90 content.body = payload.on == true
91 ? (entry.group ? "\(sender) reacted \(emoji)" : "Reacted \(emoji)")
92 : (entry.group ? "\(sender) removed a reaction" : "Removed a reaction")
93 content.sound = nil
94 content.interruptionLevel = .passive
95 }
96 ship(content)
97 return
98 }
99
100 let sender = senderName(payload.uid, payload.name) ?? "Someone"
101 // Plain projection, not raw text — a notification body shows the
102 // composer's opt-out escapes and markdown marks otherwise (same rule
103 // as the sidebar previews).
104 var text = markdownPlainText(payload.text ?? "")
105 if text.isEmpty {
106 let n = payload.attachments?.count ?? 0
107 text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message"
108 }
109 content.title = entry.title
110 // In a DM the title already names the sender; groups prefix them.
111 content.body = entry.group ? "\(sender): \(text)" : text
112 content.threadIdentifier = thread
113 if quiet {
114 // Muted thread or muted sender: full content (it's the member's
115 // own device — nothing to hide), but silent and passive; it only
116 // lands in the notification list.
117 content.sound = nil
118 content.interruptionLevel = .passive
119 }
120
121 // Restyle as a communication notification — the sender's photo (parked
122 // in SharedAvatarCache by the app) as the banner icon, the app icon
123 // badged on its corner. Only a real person-to-person message (named
124 // sender) qualifies; app/system events keep the plain banner, as does
125 // anything the restyle can't handle (no entitlement, no cached photo →
126 // monogram is still applied by the system). Muted pushes skip the
127 // dress-up — a passive list entry doesn't want a contact banner.
128 var deliver: UNNotificationContent = content
129 if !quiet, let name = senderName(payload.uid, payload.name), !name.isEmpty,
130 let styled = CommunicationNotification.decorate(
131 content, senderName: name, senderUID: payload.uid,
132 origin: entry.origin, conversationID: thread,
133 groupName: entry.group ? entry.title : nil, messageText: text) {
134 deliver = styled
135 bestEffort = styled
136 }
137
138 // Ingest into the shared store when the payload carries the message's
139 // server id (older servers/relays don't — the banner still works, the
140 // app just fetches on open as before). Stored via the same projection
141 // the feed folds (renderable), so app events land as their chat lines.
142 guard let id = (info["id"] as? NSNumber)?.int64Value, id > 0,
143 let at = (info["at"] as? NSNumber)?.int64Value,
144 let stored = ChatFeedModel.renderable(blob, key: key, group: entry.group)
145 else {
146 ship(deliver)
147 return
148 }
149 let final = deliver
150 Task {
151 await MessageStore.shared.upsert(
152 instance: entry.origin, thread: thread,
153 [.init(id: id, at: at, edited: false, reactions: [], payload: stored)])
154 // Drop the connection before shipping: this process suspends the
155 // moment the banner is handed over, and an app-group sqlite lock
156 // held across suspension is the 0xdead10cc kill — for the NSE
157 // itself, and (via the 3s busy_timeout the app then waits out)
158 // for the suspending app blocked behind it.
159 await MessageStore.shared.suspend()
160 ship(final)
161 }
162 }
163
164 override func serviceExtensionTimeWillExpire() {
165 if let bestEffort { ship(bestEffort) }
166 }
167}
168