| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import 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 |
| 18 | final 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 |
| 147 | enum 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. |
| 199 | private 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. |
| 258 | private 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. |
| 268 | private 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 | |
| 319 | private 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 | |