diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ab3f5579..acd7419f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9147,6 +9147,12 @@ private extension NSWindow { if let eventWindow = event.window, eventWindow !== window { return nil } + if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( + event.locationInWindow, + in: window + ) as? CmuxWebView { + return portalWebView + } guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event) else { return nil } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 1a5ea166..d53e1a71 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1196,6 +1196,12 @@ enum BrowserWindowPortalRegistry { portalsByWindowId[windowId]?.detachWebView(withId: webViewId) } + static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { + let windowId = ObjectIdentifier(window) + guard let portal = portalsByWindowId[windowId] else { return nil } + return portal.webViewAtWindowPoint(windowPoint) + } + #if DEBUG static func debugPortalCount() -> Int { portalsByWindowId.count diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 400ab90f..5aecfac8 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -411,6 +411,67 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") } + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusForPortalHostedWebView() { + _ = 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 anchor = NSView(frame: NSRect(x: 80, y: 60, width: 240, height: 150)) + container.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + container.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + defer { + BrowserWindowPortalRegistry.detach(webView: webView) + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerPointInContent = NSPoint(x: anchor.frame.midX, y: anchor.frame.midY) + let pointerPointInWindow = container.convert(pointerPointInContent, to: nil) + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointerPointInWindow, + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(descendant), + "Expected portal-hosted pointer click context to bypass blocked policy" + ) + } + @MainActor func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() { _ = NSApplication.shared