diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8565f176..3804f6b7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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(_ 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) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 07f1fd45..9790d93e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e792a494..023269d6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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()