From ea57786e1827e6904b7d8923c39eb266f06be732 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:10:28 -0800 Subject: [PATCH] Fix drag-handle top-hit reentrancy guard scoping --- Sources/WindowDragHandleView.swift | 55 +++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 75 +++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index a468c088..8786dae8 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -41,6 +41,7 @@ func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { } private var windowDragSuppressionDepthKey: UInt8 = 0 +private var windowDragTopHitResolutionDepthKey: UInt8 = 0 func beginWindowDragSuppression(window: NSWindow?) -> Int? { guard let window else { return nil } @@ -119,7 +120,51 @@ func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> } private enum WindowDragHandleHitTestState { - static var isResolvingTopHit = false + static func depth(window: NSWindow?) -> Int { + guard let window, + let value = objc_getAssociatedObject(window, &windowDragTopHitResolutionDepthKey) as? NSNumber else { + return 0 + } + return value.intValue + } + + static func begin(window: NSWindow?) { + guard let window else { return } + let next = depth(window: window) + 1 + objc_setAssociatedObject( + window, + &windowDragTopHitResolutionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + @discardableResult + static func end(window: NSWindow?) -> Int { + guard let window else { return 0 } + let current = depth(window: window) + let next = max(0, current - 1) + if next == 0 { + objc_setAssociatedObject( + window, + &windowDragTopHitResolutionDepthKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + window, + &windowDragTopHitResolutionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return next + } + + static func isResolvingTopHit(window: NSWindow?) -> Bool { + depth(window: window) > 0 + } } /// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty @@ -178,13 +223,15 @@ func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSVie if let window = dragHandleView.window, let contentView = window.contentView, - !WindowDragHandleHitTestState.isResolvingTopHit { + !WindowDragHandleHitTestState.isResolvingTopHit(window: window) { let pointInWindow = dragHandleView.convert(point, to: nil) let pointInContent = contentView.convert(pointInWindow, from: nil) - WindowDragHandleHitTestState.isResolvingTopHit = true + WindowDragHandleHitTestState.begin(window: window) + defer { + WindowDragHandleHitTestState.end(window: window) + } let topHit = contentView.hitTest(pointInContent) - WindowDragHandleHitTestState.isResolvingTopHit = false if let topHit { let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index bcc96c61..ed9203e6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5541,6 +5541,20 @@ final class WindowDragHandleHitTests: XCTestCase { } private final class HostContainerView: NSView {} + private final class BlockingTopHitContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + private final class PassThroughProbeView: NSView { + var onHitTest: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + onHitTest?() + return nil + } + } private final class PassiveHostContainerView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { guard bounds.contains(point) else { return nil } @@ -5628,6 +5642,67 @@ final class WindowDragHandleHitTests: XCTestCase { "Interactive controls inside passive host wrappers should still receive hits" ) } + + func testTopHitResolutionStateIsScopedPerWindow() { + let point = NSPoint(x: 100, y: 18) + + let outerWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { outerWindow.orderOut(nil) } + guard let outerContentView = outerWindow.contentView else { + XCTFail("Expected outer content view") + return + } + let outerContainer = NSView(frame: outerContentView.bounds) + outerContainer.autoresizingMask = [.width, .height] + outerContentView.addSubview(outerContainer) + let outerDragHandle = NSView(frame: outerContainer.bounds) + outerDragHandle.autoresizingMask = [.width, .height] + outerContainer.addSubview(outerDragHandle) + + let nestedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { nestedWindow.orderOut(nil) } + guard let nestedContentView = nestedWindow.contentView else { + XCTFail("Expected nested content view") + return + } + let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds) + nestedContainer.autoresizingMask = [.width, .height] + nestedContentView.addSubview(nestedContainer) + let nestedDragHandle = NSView(frame: nestedContainer.bounds) + nestedDragHandle.autoresizingMask = [.width, .height] + nestedContainer.addSubview(nestedDragHandle) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(point, in: nestedDragHandle), + "Nested window drag handle should be blocked by top-hit titlebar container" + ) + + var nestedCaptureResult: Bool? + let probe = PassThroughProbeView(frame: outerContainer.bounds) + probe.autoresizingMask = [.width, .height] + probe.onHitTest = { + nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle) + } + outerContainer.addSubview(probe) + + _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle) + + XCTAssertEqual( + nestedCaptureResult, + false, + "Top-hit recursion in one window must not disable top-hit resolution in another window" + ) + } } @MainActor