import AppKit import Bonsplit import SwiftUI private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { String(format: "(%.1f,%.1f)", point.x, point.y) } private func windowDragHandleEventTypeDescription(_ eventType: NSEvent.EventType?) -> String { eventType.map { String(describing: $0) } ?? "nil" } private enum WindowDragHandleBreadcrumbLimiter { private static let lock = NSLock() private static var lastEmissionByKey: [String: CFAbsoluteTime] = [:] static func shouldEmit(key: String, minInterval: CFTimeInterval) -> Bool { lock.lock() defer { lock.unlock() } let now = CFAbsoluteTimeGetCurrent() if let previous = lastEmissionByKey[key], (now - previous) < minInterval { return false } lastEmissionByKey[key] = now if lastEmissionByKey.count > 128 { let staleThreshold = now - max(minInterval * 4, 60) lastEmissionByKey = lastEmissionByKey.filter { _, timestamp in timestamp >= staleThreshold } } return true } } private func windowDragHandleEmitBreadcrumb( _ message: String, window: NSWindow?, eventType: NSEvent.EventType?, point: NSPoint, minInterval: CFTimeInterval = 10, extraData: [String: Any] = [:] ) { let windowNumber = window?.windowNumber ?? -1 let key = "\(message):\(windowNumber)" guard WindowDragHandleBreadcrumbLimiter.shouldEmit(key: key, minInterval: minInterval) else { return } var data: [String: Any] = [ "event_type": windowDragHandleEventTypeDescription(eventType), "point": windowDragHandleFormatPoint(point), "window_number": windowNumber, "window_present": window != nil ] for (name, value) in extraData { data[name] = value } sentryBreadcrumb(message, category: "titlebar.drag", data: data) } private func windowDragHandleShouldResolveActiveHitCapture( for eventType: NSEvent.EventType?, eventWindow: NSWindow?, dragHandleWindow: NSWindow? ) -> Bool { // We only need active hit resolution for titlebar mouse-down handling. // During launch, NSApp.currentEvent can transiently point at a stale // leftMouseDown from outside this window (for example Finder/Dock // activation). Treat those as passive events so we never walk SwiftUI/ // AppKit hierarchy while initial layout is mutating it. guard eventType == .leftMouseDown else { return false } guard let dragHandleWindow else { // Test-only views may not be attached to a window. return true } guard let eventWindow else { return false } return eventWindow === dragHandleWindow } /// Runs the same action macOS titlebars use for double-click: /// zoom by default, or minimize when the user preference is set. enum StandardTitlebarDoubleClickAction: Equatable { case miniaturize case zoom case none } func resolvedStandardTitlebarDoubleClickAction(globalDefaults: [String: Any]) -> StandardTitlebarDoubleClickAction { if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() { switch action { case "minimize", "miniaturize": return .miniaturize case "maximize", "zoom", "fill": return .zoom case "none", "no action": return .none default: break } } if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, miniaturizeOnDoubleClick { return .miniaturize } return .zoom } /// Runs the same action macOS titlebars use for double-click: /// zoom by default, or minimize when the user preference is set. @discardableResult func performStandardTitlebarDoubleClick(window: NSWindow?) -> StandardTitlebarDoubleClickAction? { guard let window else { return nil } let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] let action = resolvedStandardTitlebarDoubleClickAction(globalDefaults: globalDefaults) switch action { case .miniaturize: window.miniaturize(nil) case .zoom: window.zoom(nil) case .none: break } return action } private enum WindowDragHandleAssociatedObjectKeys { private static let suppressionDepthToken = NSObject() static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque()) } func beginWindowDragSuppression(window: NSWindow?) -> Int? { guard let window else { return nil } let current = windowDragSuppressionDepth(window: window) let next = current + 1 objc_setAssociatedObject( window, WindowDragHandleAssociatedObjectKeys.suppressionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) return next } @discardableResult func endWindowDragSuppression(window: NSWindow?) -> Int { guard let window else { return 0 } let current = windowDragSuppressionDepth(window: window) let next = max(0, current - 1) if next == 0 { objc_setAssociatedObject( window, WindowDragHandleAssociatedObjectKeys.suppressionDepth, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } else { objc_setAssociatedObject( window, WindowDragHandleAssociatedObjectKeys.suppressionDepth, NSNumber(value: next), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } return next } func windowDragSuppressionDepth(window: NSWindow?) -> Int { guard let window, let value = objc_getAssociatedObject(window, WindowDragHandleAssociatedObjectKeys.suppressionDepth) as? NSNumber else { return 0 } return value.intValue } func isWindowDragSuppressed(window: NSWindow?) -> Bool { windowDragSuppressionDepth(window: window) > 0 } @discardableResult func clearWindowDragSuppression(window: NSWindow?) -> Int { guard let window else { return 0 } var depth = windowDragSuppressionDepth(window: window) while depth > 0 { depth = endWindowDragSuppression(window: window) } return depth } /// Temporarily enables window movability for explicit drag-handle drags, then /// restores the previous movability state after `body` finishes. @discardableResult func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? { guard let window else { body() return nil } let previousMovableState = window.isMovable if !previousMovableState { window.isMovable = true } defer { if window.isMovable != previousMovableState { window.isMovable = previousMovableState } } body() return previousMovableState } /// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty /// titlebar space. Treat those as pass-through so explicit sibling checks decide. func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { let className = String(describing: type(of: view)) if className.contains("HostContainerView") || className.contains("AppKitWindowHostingView") || className.contains("NSHostingView") { return true } if let window = view.window, view === window.contentView { return true } return false } /// Re-entrancy guard for the sibling hit-test walk. When `sibling.hitTest()` /// triggers SwiftUI view-body evaluation, AppKit can call back into this /// function before the outer invocation finishes, causing a Swift /// exclusive-access violation (SIGABRT). Main-thread only, no lock needed. private var _windowDragHandleIsResolvingSiblingHits = false /// Returns whether the titlebar drag handle should capture a hit at `point`. /// We only claim the hit when no sibling view already handles it, so interactive /// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. func windowDragHandleShouldCaptureHit( _ point: NSPoint, in dragHandleView: NSView, eventType: NSEvent.EventType? = NSApp.currentEvent?.type, eventWindow: NSWindow? = NSApp.currentEvent?.window ) -> Bool { let dragHandleWindow = dragHandleView.window // Suppression recovery runs first so stale depth is cleared even for // passive events — the associated-object reads/writes here are pure ObjC // runtime calls and cannot trigger Swift exclusive-access violations. if isWindowDragSuppressed(window: dragHandleWindow) { // Recover from stale suppression if a prior interaction missed cleanup. // We only keep suppression active while the left mouse button is down. if (NSEvent.pressedMouseButtons & 0x1) == 0 { let clearedDepth = clearWindowDragSuppression(window: dragHandleWindow) windowDragHandleEmitBreadcrumb( "titlebar.dragHandle.suppression.recovered", window: dragHandleWindow, eventType: eventType, point: point, minInterval: 20, extraData: [ "cleared_depth": clearedDepth ] ) #if DEBUG dlog( "titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))" ) #endif } else { #if DEBUG let depth = windowDragSuppressionDepth(window: dragHandleWindow) dlog( "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" ) #endif return false } } // Bail out before the view-hierarchy walk so we never re-enter SwiftUI // views during a layout pass — which causes exclusive-access crashes (#490). if !windowDragHandleShouldResolveActiveHitCapture( for: eventType, eventWindow: eventWindow, dragHandleWindow: dragHandleWindow ) { #if DEBUG let eventTypeDescription = eventType.map { String(describing: $0) } ?? "nil" let eventWindowNumber = eventWindow?.windowNumber ?? -1 let dragWindowNumber = dragHandleWindow?.windowNumber ?? -1 dlog( "titlebar.dragHandle.hitTest capture=false reason=passiveEvent eventType=\(eventTypeDescription) eventWindow=\(eventWindowNumber) dragWindow=\(dragWindowNumber) point=\(windowDragHandleFormatPoint(point))" ) #endif return false } guard dragHandleView.bounds.contains(point) else { #if DEBUG dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") #endif return false } guard let superview = dragHandleView.superview else { #if DEBUG dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") #endif return true } // Bail out if we're already inside a sibling hit-test walk. This happens // when sibling.hitTest() re-enters SwiftUI layout, which calls hitTest on // this drag handle again. Proceeding would trigger an exclusive-access // violation in the Swift runtime. guard !_windowDragHandleIsResolvingSiblingHits else { #if DEBUG dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))") #endif return false } _windowDragHandleIsResolvingSiblingHits = true defer { _windowDragHandleIsResolvingSiblingHits = false } let siblingSnapshot = Array(superview.subviews.reversed()) #if DEBUG let siblingCount = siblingSnapshot.count #endif for sibling in siblingSnapshot { guard sibling !== dragHandleView else { continue } guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } let pointInSibling = dragHandleView.convert(point, to: sibling) if let hitView = sibling.hitTest(pointInSibling) { let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) if passiveHostHit { #if DEBUG dlog( "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" ) #endif continue } #if DEBUG dlog( "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" ) #endif windowDragHandleEmitBreadcrumb( "titlebar.dragHandle.hitTest.blockedBySiblingHit", window: dragHandleWindow, eventType: eventType, point: point, minInterval: 8, extraData: [ "sibling_type": String(describing: type(of: sibling)), "hit_type": String(describing: type(of: hitView)) ] ) return false } } #if DEBUG dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") #endif return true } /// A transparent view that enables dragging the window when clicking in empty titlebar space. /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. struct WindowDragHandleView: NSViewRepresentable { func makeNSView(context: Context) -> NSView { DraggableView() } func updateNSView(_ nsView: NSView, context: Context) { // No-op } private final class DraggableView: NSView { override var mouseDownCanMoveWindow: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { let currentEvent = NSApp.currentEvent // Fast bail-out: only claim hits for left-mouse-down events. // For mouseMoved / mouseEntered / etc., return nil immediately // to avoid re-entering SwiftUI view state during layout passes, // which causes exclusive-access crashes. guard currentEvent?.type == .leftMouseDown else { return nil } let shouldCapture = windowDragHandleShouldCaptureHit( point, in: self, eventType: currentEvent?.type, eventWindow: currentEvent?.window ) #if DEBUG dlog( "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" ) #endif return shouldCapture ? self : nil } override func mouseDown(with event: NSEvent) { #if DEBUG let point = convert(event.locationInWindow, from: nil) let depth = windowDragSuppressionDepth(window: window) dlog( "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" ) #endif if event.clickCount >= 2 { let action = performStandardTitlebarDoubleClick(window: window) #if DEBUG dlog("titlebar.dragHandle.mouseDownDoubleClick action=\(String(describing: action))") #endif if action != nil { return } } guard !isWindowDragSuppressed(window: window) else { #if DEBUG dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") #endif return } if let window { let previousMovableState = withTemporaryWindowMovableEnabled(window: window) { window.performDrag(with: event) } #if DEBUG let restored = previousMovableState.map { String($0) } ?? "nil" dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") #endif } else { super.mouseDown(with: event) } } } } /// Local monitor that guarantees double-clicks in custom titlebar surfaces trigger /// the standard macOS titlebar action even when the visible strip is hosted by /// higher-level SwiftUI/AppKit container views. struct TitlebarDoubleClickMonitorView: NSViewRepresentable { final class Coordinator { weak var view: NSView? var monitor: Any? deinit { if let monitor { NSEvent.removeMonitor(monitor) } } } func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) view.wantsLayer = true view.layer?.backgroundColor = NSColor.clear.cgColor context.coordinator.view = view let coordinator = context.coordinator coordinator.monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak coordinator] event in guard event.clickCount >= 2 else { return event } guard let coordinator, let view = coordinator.view, let window = view.window else { return event } guard event.window === window else { return event } let point = view.convert(event.locationInWindow, from: nil) guard view.bounds.contains(point) else { return event } let action = performStandardTitlebarDoubleClick(window: window) return action == nil ? event : nil } return view } func updateNSView(_ nsView: NSView, context: Context) { context.coordinator.view = nsView } }