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: /Library/Logs/ClickLab.log (the app is sandboxed, so from // the outside that's ~/Library/Containers//Data/Library/Logs/). // A launch rotates the previous session's log to ClickLab.prev.log. // Opt out with `defaults write 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 }