import LChatCore import SwiftUI import AppKit // 🤖 The event debugger (Debug builds only, like ClickLab): every hub frame // any instance socket receives is recorded here — thread-scoped frames and // instance-wide ones (presence, apps_changed, space_thread) alike. Two // windows read the log: "Debug Events" in a thread's context menu (that // thread's frames plus the live inputs of its unread computation), and // Debug ▸ All Received Events (everything, across every mounted space) — // the native counterpart of watching /ws traffic in the web inspector. // The raw wire text rides on each frame (ChatSocket.Frame.raw) so the // windows show exactly what arrived, including fields the lenient decode // drops. Ciphertext blobs stay ciphertext — nothing here decrypts anything. #if DEBUG @MainActor final class ThreadEventLog: ObservableObject { static let shared = ThreadEventLog() struct Entry: Identifiable { let id: Int let at: Date let origin: String let threadID: String? let type: String let summary: String let raw: String // The blob's plaintext JSON, decrypted at record time with the // thread's key from the store (nil when there's no blob or no key — // e.g. the space thread, which isn't in the sidebar's list). Local // display of the user's own data on their own device; the E2E rule // binds the server, not this window. let decrypted: String? } // One global stream, newest last — the per-thread window filters it. // Capped so a chatty space can't grow the log without limit for the // app's whole run. @Published private(set) var all: [Entry] = [] private var nextID = 0 private static let cap = 2000 /// Called from each instance socket's onFrame (main thread) with every /// frame — several mounted spaces' sockets record here at once. The /// store supplies the thread keys for payload decryption. func record(_ frame: ChatSocket.Frame, store: ThreadStore) { var decrypted: String? var payloadType: String? if let blob = frame.blob, let tid = frame.thread_id, let keyB64 = store.threads.first(where: { $0.id == tid })?.threadKeyB64, let key = try? GroupKey(base64url: keyB64), let data = try? key.decryptData(blob: blob) { decrypted = Self.pretty(String(data: data, encoding: .utf8)) // The payload's inner event type ("reaction", "todo.add", …) — // plain chat messages carry none. let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] payloadType = obj?["type"] as? String } append(Entry(id: nextID, at: Date(), origin: store.instance.origin, threadID: frame.thread_id, type: frame.type, summary: Self.summary(frame, payloadType: payloadType, decrypted: decrypted != nil), raw: Self.pretty(frame.raw), decrypted: decrypted)) } func clear() { all = [] } func clear(origin: String, threadID: String) { all.removeAll { $0.origin == origin && $0.threadID == threadID } } /// Wires the store's unread-flip tap into the log (called once at app /// start): every dot a refresh turns on or off becomes a synthetic /// "unread" entry carrying the rule's inputs, so a phantom flip shows up /// in the same window as the frames that did (or didn't) cause it. static func installStoreTaps() { ThreadStore.debugUnreadChanged = { instance, prev, row in let seen = ReadState.lastSeen(instance: instance.id, thread: row.id) let manual = ReadState.manualUnreadAt(instance: instance.id, thread: row.id) let mine = row.lastMine.map { $0 ? "yes" : "no" } ?? "unknown" let detail: [String: Any] = [ "event": "refresh flipped unread", "unread": ["was": prev.unread, "now": row.unread], "lastAt": ["was": prev.lastAt, "now": row.lastAt], "seen_watermark": seen, "manual_unread_at": manual, "last_message_mine": mine, "title": row.title, ] let raw = (try? JSONSerialization.data(withJSONObject: detail, options: [.prettyPrinted, .sortedKeys])) .flatMap { String(data: $0, encoding: .utf8) } ?? "\(detail)" let summary = "\(prev.unread ? "YES" : "no") → \(row.unread ? "YES" : "no")" + " · lastAt \(row.lastAt)\(row.lastAt == prev.lastAt ? "" : " (was \(prev.lastAt))")" + " · seen \(seen) · manual \(manual) · mine \(mine)" shared.append(Entry(id: shared.nextID, at: Date(), origin: instance.origin, threadID: row.id, type: "unread", summary: summary, raw: raw, decrypted: nil)) } } private func append(_ entry: Entry) { nextID += 1 all.append(entry) if all.count > Self.cap { all.removeFirst(all.count - Self.cap) } } // One line of the frame's interesting fields, skipping what it doesn't // carry. A decrypted blob shows its inner event type (or "chat" for a // plain message); an undecryptable one stays a ciphertext length. private static func summary(_ f: ChatSocket.Frame, payloadType: String?, decrypted: Bool) -> String { var parts: [String] = [] if let id = f.id { parts.append("id \(id)") } if let uid = f.user_id { parts.append("user \(uid)") } if let p = f.presence { parts.append(p) } if let k = f.kind { parts.append(k) } if let at = f.edited_at { parts.append("edited_at \(at)") } if let at = f.last_read_at { parts.append("last_read_at \(at)") } if f.ephemeral == true { parts.append("ephemeral") } if let blob = f.blob { if decrypted { parts.append("payload \(payloadType ?? "chat")") } else { parts.append(blob.count == 1 ? "blob 1 char (no key)" : "blob \(blob.count.formatted()) chars (no key)") } } return parts.joined(separator: " · ") } private static func pretty(_ raw: String?) -> String { guard let raw else { return "(no raw frame text captured)" } guard let obj = try? JSONSerialization.jsonObject(with: Data(raw.utf8)), let data = try? JSONSerialization.data( withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]), let s = String(data: data, encoding: .utf8) else { return raw } return s } } // One debug window per thread (plus one global), keyed so re-invoking the // menu item fronts the existing window instead of stacking duplicates. // Hand-rolled NSWindows, per LChatMacApp's single-Window rule — breakout // surfaces are their own windows, never extra copies of the app scene. @MainActor enum ThreadDebugWindows { private static var windows: [String: NSWindow] = [:] private static var closeObservers: [String: NSObjectProtocol] = [:] static func show(thread: ChatThread, store: ThreadStore) { let origin = store.instance.origin present(key: "\(origin)|\(thread.id)", title: thread.title, subtitle: "Events — \(thread.id)") { AnyView(ThreadDebugView(origin: origin, threadID: thread.id, store: store)) } } static func showAll() { present(key: "*all*", title: "All Received Events", subtitle: "Every WebSocket frame, all spaces") { AnyView(AllEventsDebugView()) } } private static func present(key: String, title: String, subtitle: String, content: () -> AnyView) { if let w = windows[key] { w.makeKeyAndOrderFront(nil) return } let w = NSWindow(contentViewController: NSHostingController(rootView: content())) w.title = title w.subtitle = subtitle w.setContentSize(NSSize(width: 680, height: 480)) // The dict keeps the window alive; releasedWhenClosed would have // AppKit deallocate it under us on close. w.isReleasedWhenClosed = false windows[key] = w closeObservers[key] = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: w, queue: .main ) { _ in Task { @MainActor in windows[key] = nil if let o = closeObservers.removeValue(forKey: key) { NotificationCenter.default.removeObserver(o) } } } w.center() w.makeKeyAndOrderFront(nil) } } // The per-thread window: that thread's frames, under a band showing the live // inputs of the sidebar's unread rule (ThreadStore.isUnread) — the dot is // computed at refresh from these, never set directly by a frame, so "unread // with no events" is answered here, not in the list below. private struct ThreadDebugView: View { let origin: String let threadID: String @ObservedObject var store: ThreadStore @ObservedObject private var log = ThreadEventLog.shared var body: some View { let entries = log.all.filter { $0.origin == origin && $0.threadID == threadID } VStack(spacing: 0) { unreadBand Divider() DebugEventList(entries: entries, showSource: false) { log.clear(origin: origin, threadID: threadID) } } .frame(minWidth: 460, minHeight: 320) } // Re-renders whenever the store publishes (refresh, markRead), so the // watermark values here are as fresh as the sidebar's own dot. private var unreadBand: some View { let row = store.threads.first { $0.id == threadID } let seen = ReadState.lastSeen(instance: store.instance.id, thread: threadID) let manual = ReadState.manualUnreadAt(instance: store.instance.id, thread: threadID) let lastAt = row?.lastAt ?? 0 let why: String if row == nil { why = "row not in store" } else if lastAt > seen && manual == lastAt { why = "watermark behind AND manual flag" } else if lastAt > seen { why = "watermark behind lastAt" } else if manual == lastAt, manual != 0 { why = "manual mark-as-unread" } else { why = "read (or last message is yours)" } let mine = row?.lastMine.map { $0 ? "yes" : "no" } ?? "unknown" return VStack(alignment: .leading, spacing: 2) { Text("unread: \(row?.unread == true ? "YES" : "no") — \(why)") .font(.system(size: 11, weight: .semibold, design: .monospaced)) Text("lastAt \(stamp(lastAt)) · seen \(stamp(seen)) · manualUnreadAt \(stamp(manual)) · lastMine \(mine)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) } .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 6) } private func stamp(_ t: Int64) -> String { guard t > 0 else { return "0" } let d = Date(timeIntervalSince1970: TimeInterval(t)) return "\(t) (\(DebugEventList.timeFmt.string(from: d)))" } } // The global window: every frame from every mounted space, tagged by source. private struct AllEventsDebugView: View { @ObservedObject private var log = ThreadEventLog.shared var body: some View { DebugEventList(entries: log.all, showSource: true) { log.clear() } .frame(minWidth: 560, minHeight: 320) } } // The shared list: count + Clear header, newest-first rows, expandable raw. private struct DebugEventList: View { let entries: [ThreadEventLog.Entry] let showSource: Bool let onClear: () -> Void static let timeFmt: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm:ss.SSS" return f }() var body: some View { VStack(spacing: 0) { HStack { Text(entries.count == 1 ? "1 event" : "\(entries.count.formatted()) events") .foregroundStyle(.secondary) Spacer() Button("Clear", action: onClear) .disabled(entries.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 8) Divider() if entries.isEmpty { VStack(spacing: 4) { Spacer() Text("No events received yet") .foregroundStyle(.secondary) Text("WebSocket frames appear here as they land.") .font(.caption) .foregroundStyle(.tertiary) Spacer() } .frame(maxWidth: .infinity) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { // Newest first, so live traffic lands at the top — // no scroll pinning needed to watch it arrive. ForEach(entries.reversed()) { entry in DebugEventRow(entry: entry, showSource: showSource) Divider() } } } } } } } private struct DebugEventRow: View { let entry: ThreadEventLog.Entry let showSource: Bool @State private var expanded = false var body: some View { DisclosureGroup(isExpanded: $expanded) { VStack(alignment: .leading, spacing: 6) { if let decrypted = entry.decrypted { section("payload (decrypted)", decrypted) } section("frame (wire)", entry.raw) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) } label: { HStack(spacing: 8) { Text(DebugEventList.timeFmt.string(from: entry.at)) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) if showSource { Text(source) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.tertiary) .lineLimit(1) } Text(entry.type) .font(.system(size: 11, weight: .semibold, design: .monospaced)) Text(entry.summary) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } } .padding(.horizontal, 12) .padding(.vertical, 3) } private func section(_ title: String, _ body: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.system(size: 10, weight: .semibold, design: .monospaced)) .foregroundStyle(.tertiary) Text(body) .font(.system(size: 11, design: .monospaced)) .textSelection(.enabled) } } // "host › thread" — enough to tell streams apart without eating the row. private var source: String { let host = URL(string: entry.origin)?.host ?? entry.origin guard let tid = entry.threadID else { return host } return "\(host) › \(tid)" } } #endif