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 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 else {
44 ship(content)
45 return
46 }
47
48 // Stamp the owning space onto the delivered banner: the tap route
49 // reads it before anything else (NotificationRoute .stamped), so
50 // routing survives the park being evicted between delivery and tap.
51 // On-device only — userInfo never travels anywhere.
52 content.userInfo["origin"] = entry.origin
53
54 guard let key = try? GroupKey(rawKey: entry.key),
55 let payload = try? key.decryptJSON(MessagePayload.self, blob: blob)
56 else {
57 ship(content)
58 return
59 }
60
61 // Muted (#578): a muted THREAD (flag parked with the key — belt and
62 // braces, the server already skips these pushes) or a muted PERSON
63 // (the shared muted-people set; their uid rides inside the ciphertext,
64 // so only this post-decrypt point can know) never buzzes. Without the
65 // filtering entitlement the extension must ship *something*, so muted
66 // pushes take the same passive, silent shape the non-author reaction
67 // path uses — no sound, no banner, no lit screen.
68 let mutedPeople = SharedMutedPeople.lookup(origin: entry.origin)
69 let quiet = (entry.muted ?? false)
70 || (payload.uid.map { mutedPeople.contains($0) } ?? false)
71
72 // Reaction events notify only the reacted-to message's author. The
73 // author's uid (targetUid) rides inside the ciphertext — stamped by the
74 // reacting client, invisible to the server — so the decision can only
75 // happen here, after decryption. Without Apple's notification-filtering
76 // entitlement (com.apple.developer.usernotifications.filtering) the
77 // extension must ship *something*, so non-authors (and un-reacts, and
78 // reactions to legacy uid-less messages) get a passive, silent entry —
79 // no sound, no banner, no lit screen; it only lands in the notification
80 // list. The author gets the real banner.
81 // Senders resolve through the parked member-name map — the name THIS
82 // member shows for them (per-viewer names, #577) — falling back to
83 // the payload's snapshot for older entries.
84 let senderName = { (uid: String?, fallback: String?) -> String? in
85 if let uid, let named = entry.names?[uid], !named.isEmpty { return named }
86 return fallback
87 }
88
89 if payload.isReactionEvent {
90 let sender = senderName(payload.uid, payload.name) ?? "Someone"
91 let emoji = payload.emoji ?? ""
92 content.title = entry.title
93 content.threadIdentifier = thread
94 let toMine = payload.on == true && payload.targetUid != nil && payload.targetUid == entry.myUid
95 if toMine && !quiet {
96 // In a DM the title already names the reactor; groups prefix them.
97 content.body = entry.group
98 ? "\(sender) reacted \(emoji) to your message"
99 : "Reacted \(emoji) to your message"
100 } else {
101 content.body = payload.on == true
102 ? (entry.group ? "\(sender) reacted \(emoji)" : "Reacted \(emoji)")
103 : (entry.group ? "\(sender) removed a reaction" : "Removed a reaction")
104 content.sound = nil
105 content.interruptionLevel = .passive
106 }
107 ship(content)
108 return
109 }
110
111 // "Convert to thread": nobody said anything — an inline reply chain
112 // moved into a thread. There is nothing to announce, but without
113 // Apple's notification-filtering entitlement the extension must ship
114 // SOMETHING, so it takes the same passive, silent shape a non-author
115 // reaction does: no sound, no banner, no lit screen, only a list entry.
116 // It is still ingested, so the app opens with the chain already tucked
117 // into its thread rather than inline until the next history pull.
118 if payload.isThreadConvertEvent {
119 content.title = entry.title
120 content.threadIdentifier = thread
121 content.body = convertLine(senderName(payload.uid, payload.name) ?? "Someone",
122 payload.ids?.count ?? 0)
123 content.sound = nil
124 content.interruptionLevel = .passive
125 let id = (info["id"] as? NSNumber)?.int64Value ?? 0
126 let at = (info["at"] as? NSNumber)?.int64Value ?? 0
127 guard id > 0, at > 0 else {
128 ship(content)
129 return
130 }
131 let final: UNNotificationContent = content
132 Task {
133 await MessageStore.shared.resume()
134 await MessageStore.shared.upsert(
135 instance: entry.origin, thread: thread,
136 [.init(id: id, at: at, edited: false, reactions: [], payload: payload)])
137 await MessageStore.shared.suspend()
138 ship(final)
139 }
140 return
141 }
142
143 let sender = senderName(payload.uid, payload.name) ?? "Someone"
144 // Plain projection, not raw text — a notification body shows the
145 // composer's opt-out escapes and markdown marks otherwise (same rule
146 // as the sidebar previews).
147 var text = markdownPlainText(payload.text ?? "")
148 if text.isEmpty {
149 let n = payload.attachments?.count ?? 0
150 text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message"
151 }
152 content.title = entry.title
153 // In a DM the title already names the sender; groups prefix them.
154 content.body = entry.group ? "\(sender): \(text)" : text
155 content.threadIdentifier = thread
156 if quiet {
157 // Muted thread or muted sender: full content (it's the member's
158 // own device — nothing to hide), but silent and passive; it only
159 // lands in the notification list.
160 content.sound = nil
161 content.interruptionLevel = .passive
162 }
163
164 // The row this banner is about, resolved BEFORE the restyle below
165 // because two things need it: the store ingest at the bottom, and the
166 // tap's landing stamp — which has to ride the content the restyle
167 // copies from. Older servers/relays send no id (`renderable` also
168 // rules out state-only app events); both simply mean no target.
169 let rowID = (info["id"] as? NSNumber)?.int64Value ?? 0
170 let rowAt = (info["at"] as? NSNumber)?.int64Value ?? 0
171 let stored = rowID > 0 ? ChatFeedModel.renderable(blob, key: key, group: entry.group) : nil
172 // Stamp the message beside the origin, for the same delivery-time-truth
173 // reason: only here is the banner known to be about a real feed row, so
174 // the tap can land on THAT message instead of merely opening the chat
175 // (PushManager → routeNotificationTap → NativeChatScreen.pendingJump).
176 // Reaction pushes returned above and are deliberately never stamped —
177 // a reaction event has no bubble of its own to land on.
178 if stored != nil { content.userInfo["message"] = rowID }
179
180 // Restyle as a communication notification — the sender's photo (parked
181 // in SharedAvatarCache by the app) as the banner icon, the app icon
182 // badged on its corner. Only a real person-to-person message (named
183 // sender) qualifies; app/system events keep the plain banner, as does
184 // anything the restyle can't handle (no entitlement, no cached photo →
185 // monogram is still applied by the system). Muted pushes skip the
186 // dress-up — a passive list entry doesn't want a contact banner.
187 var deliver: UNNotificationContent = content
188 if !quiet, let name = senderName(payload.uid, payload.name), !name.isEmpty,
189 let styled = CommunicationNotification.decorate(
190 content, senderName: name, senderUID: payload.uid,
191 origin: entry.origin, conversationID: thread,
192 groupName: entry.group ? entry.title : nil, messageText: text) {
193 deliver = styled
194 bestEffort = styled
195 }
196
197 // Ingest into the shared store when the payload carries the message's
198 // server id (older servers/relays don't — the banner still works, the
199 // app just fetches on open as before). Stored via the same projection
200 // the feed folds (renderable), so app events land as their chat lines.
201 guard let stored, rowAt > 0 else {
202 ship(deliver)
203 return
204 }
205 let final = deliver
206 Task {
207 // A reused NSE process is still gated from the previous push's
208 // close (MessageStore.suspend) — every ingest opens its own bracket.
209 await MessageStore.shared.resume()
210 await MessageStore.shared.upsert(
211 instance: entry.origin, thread: thread,
212 [.init(id: rowID, at: rowAt, edited: false, reactions: [], payload: stored)])
213 // Drop the connection before shipping: this process suspends the
214 // moment the banner is handed over, and an app-group sqlite lock
215 // held across suspension is the 0xdead10cc kill — for the NSE
216 // itself, and (via the 3s busy_timeout the app then waits out)
217 // for the suspending app blocked behind it.
218 await MessageStore.shared.suspend()
219 ship(final)
220 }
221 }
222
223 override func serviceExtensionTimeWillExpire() {
224 if let bestEffort { ship(bestEffort) }
225 }
226}
227