cmux/Sources/WindowDragHandleView.swift
Lawrence Chen 2202044af4
Fix drag-handle crash on launch from stale foreign-window events (#490) (#620)
Add window-identity check to windowDragHandleShouldCaptureHit so stale
leftMouseDown events from other apps (Finder, Dock) during launch don't
trigger the SwiftUI hierarchy walk while initial layout is mutating.
Add NSLock to breadcrumb limiter for thread safety. Update existing
tests to pass eventWindow for window-attached drag handles.
2026-02-27 01:42:17 -08:00

415 lines
14 KiB
Swift

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.
@discardableResult
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
guard let window else { return false }
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() {
switch action {
case "minimize":
window.miniaturize(nil)
return true
case "none":
return false
case "maximize", "zoom":
window.zoom(nil)
return true
default:
break
}
}
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
miniaturizeOnDoubleClick {
window.miniaturize(nil)
return true
}
window.zoom(nil)
return true
}
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
}
/// 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
}
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
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 handled = performStandardTitlebarDoubleClick(window: window)
#if DEBUG
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
#endif
if handled {
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)
}
}
}
}