| new file mode 100644 |
| index 0000000..dbb0aed |
| --- /dev/null |
| +++ b/ios/LChatMac/ClickLab.swift |
| @@ -0,0 +1,231 @@ |
| +import AppKit |
| + |
| +// 🤖 ClickLab — the click-eating diagnostic rig behind the two dead-composer |
| +// fixes (#930 focus-stealing, #1023 FrostedBand hitTest), made a permanent |
| +// resident. The bug class is intermittent and not SwiftUI-debuggable (SwiftUI |
| +// does not keep representable NSViews' z-order in step with its own stack, and |
| +// NSView.hitTest walks NSView order), so a repro is only worth anything if the |
| +// build that shows it can say who actually received the click. Debug builds |
| +// carry the rig; plain Release builds compile it out. For an OPTIMIZED build |
| +// that still records (a Debug daily driver multiplies every wedge at -Onone), |
| +// build Release with SWIFT_ACTIVE_COMPILATION_CONDITIONS=CLICKLAB — and add |
| +// CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES so tools/wedge-watcher.sh can still |
| +// `sample` it mid-wedge. |
| +// |
| +// Log: <container>/Library/Logs/ClickLab.log (the app is sandboxed, so from |
| +// the outside that's ~/Library/Containers/<bundle id>/Data/Library/Logs/). |
| +// A launch rotates the previous session's log to ClickLab.prev.log. |
| +// Opt out with `defaults write <bundle id> clickLab -bool NO`. |
| +enum ClickLab { |
| +#if DEBUG || CLICKLAB |
| + private static var mouseMonitor: Any? |
| + private static var pollTimer: Timer? |
| + private static var observers: [NSObjectProtocol] = [] |
| + private static var watchdog: DispatchSourceTimer? |
| + private static var lastResponder = "" |
| + private static var handle: FileHandle? |
| + |
| + private static let logURL: URL = { |
| + let logs = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| + .appendingPathComponent("Logs", isDirectory: true) |
| + try? FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) |
| + return logs.appendingPathComponent("ClickLab.log") |
| + }() |
| + |
| + // The heartbeat: rewritten from the MAIN thread every ~1s, so an outside |
| + // watcher can `sample` the process while a wedge is still in progress |
| + // (the in-log watchdog only reports a stall after it drains — too late |
| + // to capture the blocked stack). |
| + private static let beatURL = logURL.deletingLastPathComponent() |
| + .appendingPathComponent("ClickLab.heartbeat") |
| + private static var beatCounter = 0 |
| + |
| + private static let timeFmt: DateFormatter = { |
| + let f = DateFormatter() |
| + f.dateFormat = "HH:mm:ss.SSS" |
| + return f |
| + }() |
| + |
| + static func start() { |
| + if UserDefaults.standard.object(forKey: "clickLab") != nil, |
| + !UserDefaults.standard.bool(forKey: "clickLab") { return } |
| + // Keep exactly one previous session — the interesting window is always |
| + // "the run where it just happened", and unbounded append only grows. |
| + let prev = logURL.deletingPathExtension().appendingPathExtension("prev.log") |
| + try? FileManager.default.removeItem(at: prev) |
| + try? FileManager.default.moveItem(at: logURL, to: prev) |
| + |
| + let info = Bundle.main.infoDictionary |
| + let ver = "\(info?["CFBundleShortVersionString"] ?? "?") (\(info?["CFBundleVersion"] ?? "?"))" |
| + log("=== ClickLab start — \(Bundle.main.bundleIdentifier ?? "?") \(ver)") |
| + |
| + mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in |
| + logMouseDown(event) |
| + return event |
| + } |
| + // The caret ping-pong in #1023 played out over ~a second (click resigns |
| + // to the window, the focus binding re-grabs a beat later) — a 0.25s |
| + // change-only timeline is fine-grained enough to show that dance. |
| + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in |
| + pollFirstResponder() |
| + } |
| + RunLoop.main.add(pollTimer!, forMode: .common) |
| + |
| + // The activation timeline — "dead right after loading into the |
| + // window" implicates the focus gauntlet (app-activate → pullHome, |
| + // window-key → the composer focus binding re-asserts), so stamp |
| + // every transition the dead spell can be correlated against. |
| + let nc = NotificationCenter.default |
| + let acts: [(Notification.Name, String)] = [ |
| + (NSApplication.didBecomeActiveNotification, "app ACTIVE"), |
| + (NSApplication.didResignActiveNotification, "app inactive"), |
| + (NSWindow.didBecomeKeyNotification, "window KEY"), |
| + (NSWindow.didResignKeyNotification, "window unkey"), |
| + ] |
| + for (name, label) in acts { |
| + observers.append(nc.addObserver(forName: name, object: nil, queue: .main) { note in |
| + let w = (note.object as? NSWindow).map { " w#\($0.windowNumber)" } ?? "" |
| + log("\(label)\(w)") |
| + }) |
| + } |
| + |
| + // The stall watchdog — distinguishes "clicks are being eaten" (main |
| + // thread fine, hit-test chain wrong) from "the app is hung" (main |
| + // thread blocked, nothing responds). A background ticker pings main |
| + // every 200ms; any ping serviced >400ms late is a stall, and the |
| + // printed lag ≈ how long main was wedged. |
| + let ticker = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clicklab.watchdog")) |
| + ticker.schedule(deadline: .now() + 1, repeating: .milliseconds(200)) |
| + ticker.setEventHandler { |
| + let sent = Date() |
| + DispatchQueue.main.async { |
| + let lag = -sent.timeIntervalSinceNow |
| + if lag > 0.4 { log(String(format: "⏳ main-thread stall: ping serviced %.2fs late", lag)) } |
| + } |
| + } |
| + ticker.resume() |
| + watchdog = ticker |
| + } |
| + |
| + private static func logMouseDown(_ event: NSEvent) { |
| + guard let window = event.window, let root = window.contentView else { |
| + log("mouseDown w#\(event.windowNumber) — no contentView") |
| + return |
| + } |
| + // hitTest takes the point in the receiver's SUPERVIEW's coordinates |
| + // (the theme frame, for a window's contentView). |
| + let p = (root.superview ?? root).convert(event.locationInWindow, from: nil) |
| + let hit = root.hitTest(p) |
| + var chain: [String] = [] |
| + var v: NSView? = hit |
| + while let cur = v, chain.count < 10 { |
| + chain.append(String(describing: type(of: cur))) |
| + if cur === root { break } |
| + v = cur.superview |
| + } |
| + let at = "(\(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)))" |
| + let notKey = window.isKeyWindow ? "" : " [window NOT key]" |
| + if let hit { |
| + let eater = hit is NSVisualEffectView ? " ⚠️ decorative view took the click" : "" |
| + log("mouseDown \(at) w#\(window.windowNumber)\(notKey) → \(chain.joined(separator: " < "))\(eater)") |
| + } else { |
| + log("mouseDown \(at) w#\(window.windowNumber)\(notKey) → hitTest nil (falls to the window)") |
| + } |
| + composerAudit(in: root, click: event.locationInWindow, hit: hit) |
| + } |
| + |
| + // Capture 1 (2026-07-17 10:14) showed clicks in the composer region |
| + // reaching only NSHostingView — no composer took them, while programmatic |
| + // focus still worked. With several spaces mounted, parked panes overlap |
| + // the active one (their AppKit alpha/hidden are untouched by SwiftUI's |
| + // opacity — only the .allowsHitTesting gate hides them from clicks), so |
| + // "some OTHER composer's frame contains the click" is the NORMAL case. |
| + // The one true anomaly: a click inside a composer frame that reaches NO |
| + // composer at all — and then the blame walk names the ancestor whose |
| + // hitTest kills it. |
| + private static func composerAudit(in root: NSView, click: NSPoint, hit: NSView?) { |
| + var composers: [ComposerTextView] = [] |
| + func walk(_ v: NSView) { |
| + if let c = v as? ComposerTextView { composers.append(c) } |
| + for s in v.subviews { walk(s) } |
| + } |
| + walk(root) |
| + if let taken = composers.first(where: { c in |
| + guard let s = c.enclosingScrollView else { return false } |
| + return hit?.isDescendant(of: s) ?? false |
| + }) { |
| + // Tag which instance took the click — a caret that lands in a |
| + // tag the responder timeline never shows as the working composer |
| + // is a ghost (a parked pane's field swallowing input). |
| + log(" → composer #\(tag(taken))") |
| + return |
| + } |
| + let containing = composers.filter { c in |
| + guard let s = c.enclosingScrollView else { return false } |
| + return s.convert(s.bounds, to: nil).contains(click) |
| + } |
| + for c in containing { |
| + guard let scroll = c.enclosingScrollView else { continue } |
| + let f = scroll.convert(scroll.bounds, to: nil) |
| + log(String(format: "⚠️ click (%.0f,%.0f) inside composer #%@'s frame (%.0f,%.0f %.0f×%.0f) hit NO composer — blame walk:", |
| + click.x, click.y, tag(c), f.origin.x, f.origin.y, f.width, f.height)) |
| + blameWalk(from: scroll, click: click) |
| + } |
| + } |
| + |
| + private static func tag(_ v: AnyObject) -> String { |
| + String(UInt(bitPattern: ObjectIdentifier(v).hashValue) & 0xFFFF, radix: 16) |
| + } |
| + |
| + // Hit-test every ancestor of the field's scroll view directly at the |
| + // click point: the first level that geometrically contains the point but |
| + // hit-tests to nil (or outside itself) is the view gating the click — |
| + // a stale SwiftUI allowsHitTesting on a PlatformViewHost, a mis-framed |
| + // container, whatever it is, this prints its name. |
| + private static func blameWalk(from scroll: NSView, click: NSPoint) { |
| + var v: NSView? = scroll |
| + while let cur = v, let sup = cur.superview { |
| + let p = sup.convert(click, from: nil) |
| + let ht = cur.hitTest(p) |
| + log(String(format: " %@#%@ frameContains=%@ hitTest=%@ alpha=%.2f hidden=%@", |
| + "\(type(of: cur))", tag(cur), |
| + cur.frame.contains(p) ? "yes" : "NO", |
| + ht.map { "\(type(of: $0))" } ?? "nil", |
| + cur.alphaValue, cur.isHidden ? "YES" : "no")) |
| + v = sup |
| + } |
| + } |
| + |
| + private static func pollFirstResponder() { |
| + beatCounter += 1 |
| + if beatCounter % 4 == 0 { try? Data("♥".utf8).write(to: beatURL) } |
| + let window = NSApp.keyWindow |
| + let fr = window?.firstResponder |
| + let desc: String |
| + if let fr { |
| + let ptr = UInt(bitPattern: ObjectIdentifier(fr).hashValue) & 0xFFFF |
| + desc = "\(type(of: fr))#\(String(ptr, radix: 16)) w#\(window?.windowNumber ?? -1)" |
| + } else { |
| + desc = window == nil ? "no key window" : "nil" |
| + } |
| + guard desc != lastResponder else { return } |
| + log("responder: \(lastResponder.isEmpty ? "—" : lastResponder) → \(desc)") |
| + lastResponder = desc |
| + } |
| + |
| + private static func log(_ line: String) { |
| + NSLog("[ClickLab] %@", line) |
| + let data = Data("\(timeFmt.string(from: Date())) \(line)\n".utf8) |
| + if handle == nil { |
| + if !FileManager.default.fileExists(atPath: logURL.path) { |
| + FileManager.default.createFile(atPath: logURL.path, contents: nil) |
| + } |
| + handle = try? FileHandle(forWritingTo: logURL) |
| + _ = try? handle?.seekToEnd() |
| + } |
| + try? handle?.write(contentsOf: data) |
| + } |
| +#else |
| + static func start() {} |
| +#endif |
| +} |