guard devtools restore from unsafe first-responder churn

This commit is contained in:
Lawrence Chen 2026-02-25 15:59:49 -08:00
parent 71d468255e
commit 9140371fcd
3 changed files with 60 additions and 1 deletions

View file

@ -6691,8 +6691,22 @@ private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent?
private var cmuxFirstResponderGuardHitViewOverride: NSView?
#endif
private var cmuxBrowserReturnForwardingDepth = 0
private var cmuxWindowFirstResponderBypassDepth = 0
private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0
@discardableResult
func cmuxWithWindowFirstResponderBypass<T>(_ body: () -> T) -> T {
cmuxWindowFirstResponderBypassDepth += 1
defer {
cmuxWindowFirstResponderBypassDepth = max(0, cmuxWindowFirstResponderBypassDepth - 1)
}
return body()
}
func cmuxIsWindowFirstResponderBypassActive() -> Bool {
cmuxWindowFirstResponderBypassDepth > 0
}
private final class CmuxFieldEditorOwningWebViewBox: NSObject {
weak var webView: CmuxWebView?
@ -6703,6 +6717,16 @@ private final class CmuxFieldEditorOwningWebViewBox: NSObject {
private extension NSWindow {
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
if cmuxIsWindowFirstResponderBypassActive() {
#if DEBUG
dlog(
"focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " +
"window=\(ObjectIdentifier(self))"
)
#endif
return false
}
let currentEvent = Self.cmuxCurrentEvent(for: self)
let responderWebView = responder.flatMap {
Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent)

View file

@ -2150,7 +2150,12 @@ extension BrowserPanel {
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
}
#endif
inspector.cmuxCallVoid(selector: selector)
// WebKit inspector "show" can trigger transient first-responder churn while
// panel attachment is still stabilizing. Keep this auto-restore path from
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
cmuxWithWindowFirstResponderBypass {
inspector.cmuxCallVoid(selector: selector)
}
preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {

View file

@ -500,6 +500,36 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
)
}
@MainActor
func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
container.addSubview(responder)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
_ = window.makeFirstResponder(nil)
cmuxWithWindowFirstResponderBypass {
XCTAssertFalse(
window.makeFirstResponder(responder),
"Bypass scope should block transient first-responder changes during devtools auto-restore"
)
}
XCTAssertTrue(window.makeFirstResponder(responder))
}
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
let mainMenu = NSMenu()