diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 87a3d192..82534727 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -49,8 +49,13 @@ func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { return true } -private var windowDragSuppressionDepthKey: UInt8 = 0 -private var windowDragTopHitResolutionDepthKey: UInt8 = 0 +private enum WindowDragHandleAssociatedObjectKeys { + private static let suppressionDepthToken = NSObject() + private static let topHitResolutionDepthToken = NSObject() + + static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque()) + static let topHitResolutionDepth = UnsafeRawPointer(Unmanaged.passUnretained(topHitResolutionDepthToken).toOpaque()) +} func beginWindowDragSuppression(window: NSWindow?) -> Int? { guard let window else { return nil } @@ -58,7 +63,7 @@ func beginWindowDragSuppression(window: NSWindow?) -> Int? { let next = current + 1 objc_setAssociatedObject( window, - &windowDragSuppressionDepthKey, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) @@ -71,11 +76,16 @@ func endWindowDragSuppression(window: NSWindow?) -> Int { let current = windowDragSuppressionDepth(window: window) let next = max(0, current - 1) if next == 0 { - objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) } else { objc_setAssociatedObject( window, - &windowDragSuppressionDepthKey, + WindowDragHandleAssociatedObjectKeys.suppressionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) @@ -85,7 +95,7 @@ func endWindowDragSuppression(window: NSWindow?) -> Int { func windowDragSuppressionDepth(window: NSWindow?) -> Int { guard let window, - let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else { + let value = objc_getAssociatedObject(window, WindowDragHandleAssociatedObjectKeys.suppressionDepth) as? NSNumber else { return 0 } return value.intValue @@ -131,7 +141,10 @@ func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> private enum WindowDragHandleHitTestState { static func depth(window: NSWindow?) -> Int { guard let window, - let value = objc_getAssociatedObject(window, &windowDragTopHitResolutionDepthKey) as? NSNumber else { + let value = objc_getAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.topHitResolutionDepth + ) as? NSNumber else { return 0 } return value.intValue @@ -142,7 +155,7 @@ private enum WindowDragHandleHitTestState { let next = depth(window: window) + 1 objc_setAssociatedObject( window, - &windowDragTopHitResolutionDepthKey, + WindowDragHandleAssociatedObjectKeys.topHitResolutionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) @@ -156,14 +169,14 @@ private enum WindowDragHandleHitTestState { if next == 0 { objc_setAssociatedObject( window, - &windowDragTopHitResolutionDepthKey, + WindowDragHandleAssociatedObjectKeys.topHitResolutionDepth, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } else { objc_setAssociatedObject( window, - &windowDragTopHitResolutionDepthKey, + WindowDragHandleAssociatedObjectKeys.topHitResolutionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 984112a4..5a783abe 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5912,6 +5912,13 @@ final class WindowDragHandleHitTests: XCTestCase { } } + private final class ReentrantDragHandleView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown) + return shouldCapture ? self : nil + } + } + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) let dragHandle = NSView(frame: container.bounds) @@ -6079,6 +6086,34 @@ final class WindowDragHandleHitTests: XCTestCase { "Subview mutations during hit testing should not crash or break drag-handle capture" ) } + + func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() { + let point = NSPoint(x: 180, y: 18) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + container.autoresizingMask = [.width, .height] + contentView.addSubview(container) + + let dragHandle = ReentrantDragHandleView(frame: container.bounds) + dragHandle.autoresizingMask = [.width, .height] + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown), + "Reentrant same-window top-hit resolution should not trigger exclusivity crashes" + ) + } } #if DEBUG