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 AppKit
4
5// 🤖 The event debugger (Debug builds only, like ClickLab): every hub frame
6// any instance socket receives is recorded here — thread-scoped frames and
7// instance-wide ones (presence, apps_changed, space_thread) alike. Two
8// windows read the log: "Debug Events" in a thread's context menu (that
9// thread's frames plus the live inputs of its unread computation), and
10// Debug ▸ All Received Events (everything, across every mounted space) —
11// the native counterpart of watching /ws traffic in the web inspector.
12// The raw wire text rides on each frame (ChatSocket.Frame.raw) so the
13// windows show exactly what arrived, including fields the lenient decode
14// drops. Ciphertext blobs stay ciphertext — nothing here decrypts anything.
15#if DEBUG
16
17@MainActor
18final class ThreadEventLog: ObservableObject {
19 static let shared = ThreadEventLog()
20
21 struct Entry: Identifiable {
22 let id: Int
23 let at: Date
24 let origin: String
25 let threadID: String?
26 let type: String
27 let summary: String
28 let raw: String
29 // The blob's plaintext JSON, decrypted at record time with the
30 // thread's key from the store (nil when there's no blob or no key —
31 // e.g. the space thread, which isn't in the sidebar's list). Local
32 // display of the user's own data on their own device; the E2E rule
33 // binds the server, not this window.
34 let decrypted: String?
35 }
36
37 // One global stream, newest last — the per-thread window filters it.
38 // Capped so a chatty space can't grow the log without limit for the
39 // app's whole run.
40 @Published private(set) var all: [Entry] = []
41 private var nextID = 0
42 private static let cap = 2000
43
44 /// Called from each instance socket's onFrame (main thread) with every
45 /// frame — several mounted spaces' sockets record here at once. The
46 /// store supplies the thread keys for payload decryption.
47 func record(_ frame: ChatSocket.Frame, store: ThreadStore) {
48 var decrypted: String?
49 var payloadType: String?
50 if let blob = frame.blob, let tid = frame.thread_id,
51 let keyB64 = store.threads.first(where: { $0.id == tid })?.threadKeyB64,
52 let key = try? GroupKey(base64url: keyB64),
53 let data = try? key.decryptData(blob: blob) {
54 decrypted = Self.pretty(String(data: data, encoding: .utf8))
55 // The payload's inner event type ("reaction", "todo.add", …) —
56 // plain chat messages carry none.
57 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
58 payloadType = obj?["type"] as? String
59 }
60 append(Entry(id: nextID, at: Date(), origin: store.instance.origin,
61 threadID: frame.thread_id, type: frame.type,
62 summary: Self.summary(frame, payloadType: payloadType,
63 decrypted: decrypted != nil),
64 raw: Self.pretty(frame.raw), decrypted: decrypted))
65 }
66
67 func clear() { all = [] }
68 func clear(origin: String, threadID: String) {
69 all.removeAll { $0.origin == origin && $0.threadID == threadID }
70 }
71
72 /// Wires the store's unread-flip tap into the log (called once at app
73 /// start): every dot a refresh turns on or off becomes a synthetic
74 /// "unread" entry carrying the rule's inputs, so a phantom flip shows up
75 /// in the same window as the frames that did (or didn't) cause it.
76 static func installStoreTaps() {
77 ThreadStore.debugUnreadChanged = { instance, prev, row in
78 let seen = ReadState.lastSeen(instance: instance.id, thread: row.id)
79 let manual = ReadState.manualUnreadAt(instance: instance.id, thread: row.id)
80 let mine = row.lastMine.map { $0 ? "yes" : "no" } ?? "unknown"
81 let detail: [String: Any] = [
82 "event": "refresh flipped unread",
83 "unread": ["was": prev.unread, "now": row.unread],
84 "lastAt": ["was": prev.lastAt, "now": row.lastAt],
85 "seen_watermark": seen,
86 "manual_unread_at": manual,
87 "last_message_mine": mine,
88 "title": row.title,
89 ]
90 let raw = (try? JSONSerialization.data(withJSONObject: detail,
91 options: [.prettyPrinted, .sortedKeys]))
92 .flatMap { String(data: $0, encoding: .utf8) } ?? "\(detail)"
93 let summary = "\(prev.unread ? "YES" : "no") → \(row.unread ? "YES" : "no")"
94 + " · lastAt \(row.lastAt)\(row.lastAt == prev.lastAt ? "" : " (was \(prev.lastAt))")"
95 + " · seen \(seen) · manual \(manual) · mine \(mine)"
96 shared.append(Entry(id: shared.nextID, at: Date(), origin: instance.origin,
97 threadID: row.id, type: "unread",
98 summary: summary, raw: raw, decrypted: nil))
99 }
100 }
101
102 private func append(_ entry: Entry) {
103 nextID += 1
104 all.append(entry)
105 if all.count > Self.cap { all.removeFirst(all.count - Self.cap) }
106 }
107
108 // One line of the frame's interesting fields, skipping what it doesn't
109 // carry. A decrypted blob shows its inner event type (or "chat" for a
110 // plain message); an undecryptable one stays a ciphertext length.
111 private static func summary(_ f: ChatSocket.Frame, payloadType: String?,
112 decrypted: Bool) -> String {
113 var parts: [String] = []
114 if let id = f.id { parts.append("id \(id)") }
115 if let uid = f.user_id { parts.append("user \(uid)") }
116 if let p = f.presence { parts.append(p) }
117 if let k = f.kind { parts.append(k) }
118 if let at = f.edited_at { parts.append("edited_at \(at)") }
119 if let at = f.last_read_at { parts.append("last_read_at \(at)") }
120 if f.ephemeral == true { parts.append("ephemeral") }
121 if let blob = f.blob {
122 if decrypted {
123 parts.append("payload \(payloadType ?? "chat")")
124 } else {
125 parts.append(blob.count == 1 ? "blob 1 char (no key)"
126 : "blob \(blob.count.formatted()) chars (no key)")
127 }
128 }
129 return parts.joined(separator: " · ")
130 }
131
132 private static func pretty(_ raw: String?) -> String {
133 guard let raw else { return "(no raw frame text captured)" }
134 guard let obj = try? JSONSerialization.jsonObject(with: Data(raw.utf8)),
135 let data = try? JSONSerialization.data(
136 withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
137 let s = String(data: data, encoding: .utf8) else { return raw }
138 return s
139 }
140}
141
142// One debug window per thread (plus one global), keyed so re-invoking the
143// menu item fronts the existing window instead of stacking duplicates.
144// Hand-rolled NSWindows, per LChatMacApp's single-Window rule — breakout
145// surfaces are their own windows, never extra copies of the app scene.
146@MainActor
147enum ThreadDebugWindows {
148 private static var windows: [String: NSWindow] = [:]
149 private static var closeObservers: [String: NSObjectProtocol] = [:]
150
151 static func show(thread: ChatThread, store: ThreadStore) {
152 let origin = store.instance.origin
153 present(key: "\(origin)|\(thread.id)", title: thread.title,
154 subtitle: "Events — \(thread.id)") {
155 AnyView(ThreadDebugView(origin: origin, threadID: thread.id, store: store))
156 }
157 }
158
159 static func showAll() {
160 present(key: "*all*", title: "All Received Events",
161 subtitle: "Every WebSocket frame, all spaces") {
162 AnyView(AllEventsDebugView())
163 }
164 }
165
166 private static func present(key: String, title: String, subtitle: String,
167 content: () -> AnyView) {
168 if let w = windows[key] {
169 w.makeKeyAndOrderFront(nil)
170 return
171 }
172 let w = NSWindow(contentViewController: NSHostingController(rootView: content()))
173 w.title = title
174 w.subtitle = subtitle
175 w.setContentSize(NSSize(width: 680, height: 480))
176 // The dict keeps the window alive; releasedWhenClosed would have
177 // AppKit deallocate it under us on close.
178 w.isReleasedWhenClosed = false
179 windows[key] = w
180 closeObservers[key] = NotificationCenter.default.addObserver(
181 forName: NSWindow.willCloseNotification, object: w, queue: .main
182 ) { _ in
183 Task { @MainActor in
184 windows[key] = nil
185 if let o = closeObservers.removeValue(forKey: key) {
186 NotificationCenter.default.removeObserver(o)
187 }
188 }
189 }
190 w.center()
191 w.makeKeyAndOrderFront(nil)
192 }
193}
194
195// The per-thread window: that thread's frames, under a band showing the live
196// inputs of the sidebar's unread rule (ThreadStore.isUnread) — the dot is
197// computed at refresh from these, never set directly by a frame, so "unread
198// with no events" is answered here, not in the list below.
199private struct ThreadDebugView: View {
200 let origin: String
201 let threadID: String
202 @ObservedObject var store: ThreadStore
203 @ObservedObject private var log = ThreadEventLog.shared
204
205 var body: some View {
206 let entries = log.all.filter { $0.origin == origin && $0.threadID == threadID }
207 VStack(spacing: 0) {
208 unreadBand
209 Divider()
210 DebugEventList(entries: entries, showSource: false) {
211 log.clear(origin: origin, threadID: threadID)
212 }
213 }
214 .frame(minWidth: 460, minHeight: 320)
215 }
216
217 // Re-renders whenever the store publishes (refresh, markRead), so the
218 // watermark values here are as fresh as the sidebar's own dot.
219 private var unreadBand: some View {
220 let row = store.threads.first { $0.id == threadID }
221 let seen = ReadState.lastSeen(instance: store.instance.id, thread: threadID)
222 let manual = ReadState.manualUnreadAt(instance: store.instance.id, thread: threadID)
223 let lastAt = row?.lastAt ?? 0
224 let why: String
225 if row == nil {
226 why = "row not in store"
227 } else if lastAt > seen && manual == lastAt {
228 why = "watermark behind AND manual flag"
229 } else if lastAt > seen {
230 why = "watermark behind lastAt"
231 } else if manual == lastAt, manual != 0 {
232 why = "manual mark-as-unread"
233 } else {
234 why = "read (or last message is yours)"
235 }
236 let mine = row?.lastMine.map { $0 ? "yes" : "no" } ?? "unknown"
237 return VStack(alignment: .leading, spacing: 2) {
238 Text("unread: \(row?.unread == true ? "YES" : "no") — \(why)")
239 .font(.system(size: 11, weight: .semibold, design: .monospaced))
240 Text("lastAt \(stamp(lastAt)) · seen \(stamp(seen)) · manualUnreadAt \(stamp(manual)) · lastMine \(mine)")
241 .font(.system(size: 11, design: .monospaced))
242 .foregroundStyle(.secondary)
243 }
244 .textSelection(.enabled)
245 .frame(maxWidth: .infinity, alignment: .leading)
246 .padding(.horizontal, 12)
247 .padding(.vertical, 6)
248 }
249
250 private func stamp(_ t: Int64) -> String {
251 guard t > 0 else { return "0" }
252 let d = Date(timeIntervalSince1970: TimeInterval(t))
253 return "\(t) (\(DebugEventList.timeFmt.string(from: d)))"
254 }
255}
256
257// The global window: every frame from every mounted space, tagged by source.
258private struct AllEventsDebugView: View {
259 @ObservedObject private var log = ThreadEventLog.shared
260
261 var body: some View {
262 DebugEventList(entries: log.all, showSource: true) { log.clear() }
263 .frame(minWidth: 560, minHeight: 320)
264 }
265}
266
267// The shared list: count + Clear header, newest-first rows, expandable raw.
268private struct DebugEventList: View {
269 let entries: [ThreadEventLog.Entry]
270 let showSource: Bool
271 let onClear: () -> Void
272
273 static let timeFmt: DateFormatter = {
274 let f = DateFormatter()
275 f.dateFormat = "HH:mm:ss.SSS"
276 return f
277 }()
278
279 var body: some View {
280 VStack(spacing: 0) {
281 HStack {
282 Text(entries.count == 1 ? "1 event"
283 : "\(entries.count.formatted()) events")
284 .foregroundStyle(.secondary)
285 Spacer()
286 Button("Clear", action: onClear)
287 .disabled(entries.isEmpty)
288 }
289 .padding(.horizontal, 12)
290 .padding(.vertical, 8)
291 Divider()
292 if entries.isEmpty {
293 VStack(spacing: 4) {
294 Spacer()
295 Text("No events received yet")
296 .foregroundStyle(.secondary)
297 Text("WebSocket frames appear here as they land.")
298 .font(.caption)
299 .foregroundStyle(.tertiary)
300 Spacer()
301 }
302 .frame(maxWidth: .infinity)
303 } else {
304 ScrollView {
305 LazyVStack(alignment: .leading, spacing: 0) {
306 // Newest first, so live traffic lands at the top —
307 // no scroll pinning needed to watch it arrive.
308 ForEach(entries.reversed()) { entry in
309 DebugEventRow(entry: entry, showSource: showSource)
310 Divider()
311 }
312 }
313 }
314 }
315 }
316 }
317}
318
319private struct DebugEventRow: View {
320 let entry: ThreadEventLog.Entry
321 let showSource: Bool
322 @State private var expanded = false
323
324 var body: some View {
325 DisclosureGroup(isExpanded: $expanded) {
326 VStack(alignment: .leading, spacing: 6) {
327 if let decrypted = entry.decrypted {
328 section("payload (decrypted)", decrypted)
329 }
330 section("frame (wire)", entry.raw)
331 }
332 .frame(maxWidth: .infinity, alignment: .leading)
333 .padding(.vertical, 4)
334 } label: {
335 HStack(spacing: 8) {
336 Text(DebugEventList.timeFmt.string(from: entry.at))
337 .font(.system(size: 11, design: .monospaced))
338 .foregroundStyle(.secondary)
339 if showSource {
340 Text(source)
341 .font(.system(size: 11, design: .monospaced))
342 .foregroundStyle(.tertiary)
343 .lineLimit(1)
344 }
345 Text(entry.type)
346 .font(.system(size: 11, weight: .semibold, design: .monospaced))
347 Text(entry.summary)
348 .font(.system(size: 11, design: .monospaced))
349 .foregroundStyle(.secondary)
350 .lineLimit(1)
351 .truncationMode(.tail)
352 }
353 }
354 .padding(.horizontal, 12)
355 .padding(.vertical, 3)
356 }
357
358 private func section(_ title: String, _ body: String) -> some View {
359 VStack(alignment: .leading, spacing: 2) {
360 Text(title)
361 .font(.system(size: 10, weight: .semibold, design: .monospaced))
362 .foregroundStyle(.tertiary)
363 Text(body)
364 .font(.system(size: 11, design: .monospaced))
365 .textSelection(.enabled)
366 }
367 }
368
369 // "host › thread" — enough to tell streams apart without eating the row.
370 private var source: String {
371 let host = URL(string: entry.origin)?.host ?? entry.origin
372 guard let tid = entry.threadID else { return host }
373 return "\(host) › \(tid)"
374 }
375}
376
377#endif
378