Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 52 additions & 22 deletions Sources/Bonsplit/Internal/Controllers/TabDragLifecycleMonitor.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import AppKit

/// Ground-truth exits for a tab drag that never reaches a SwiftUI drop callback.
/// Event callbacks enqueue cancellation after returning so AppKit can finish
/// delivering a real drop first; the session generation rejects stale callbacks.
/// AppKit delivers event-monitor callbacks on the main thread, so one synchronous
/// bridge ends the matching generation without a deferred cleanup race.
@MainActor
final class TabDragLifecycleMonitor {
private static let escapeKeyCode: UInt16 = 53

private let generation: Int
private let onRequestEnd: @MainActor (Int) -> Void
private var appResignObserver: (any NSObjectProtocol)?
private var keyDownMonitor: Any?
private var localMouseUpMonitor: Any?
private var globalMouseUpMonitor: Any?
// Registration tokens are mutated only by main-actor start/stop. Deinit
// reads them after the monitor's last owner has released it, when no
// actor-isolated access can remain in flight.
private nonisolated(unsafe) var appResignObserver: (any NSObjectProtocol)?
private nonisolated(unsafe) var keyDownMonitor: Any?
private nonisolated(unsafe) var localMouseUpMonitor: Any?
private nonisolated(unsafe) var globalMouseUpMonitor: Any?
private var endRequested = false
private var isStopped = false

Expand All @@ -21,55 +24,82 @@ final class TabDragLifecycleMonitor {
self.onRequestEnd = onRequestEnd
}

deinit {
Self.removeRegistrations(
appResignObserver: appResignObserver,
keyDownMonitor: keyDownMonitor,
localMouseUpMonitor: localMouseUpMonitor,
globalMouseUpMonitor: globalMouseUpMonitor
)
}

func start() {
appResignObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.requestEnd()
self?.requestEndFromMainThreadCallback()
}
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if event.keyCode == Self.escapeKeyCode {
self?.requestEnd()
self?.requestEndFromMainThreadCallback()
}
return event
}
localMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
self?.requestEnd()
self?.requestEndFromMainThreadCallback()
return event
}
globalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] _ in
self?.requestEnd()
self?.requestEndFromMainThreadCallback()
}
}

func stop() {
guard !isStopped else { return }
isStopped = true
Self.removeRegistrations(
appResignObserver: appResignObserver,
keyDownMonitor: keyDownMonitor,
localMouseUpMonitor: localMouseUpMonitor,
globalMouseUpMonitor: globalMouseUpMonitor
)
appResignObserver = nil
keyDownMonitor = nil
localMouseUpMonitor = nil
globalMouseUpMonitor = nil
}

private nonisolated func requestEndFromMainThreadCallback() {
MainActor.assumeIsolated {
requestEnd()
}
}

private func requestEnd() {
guard !isStopped, !endRequested else { return }
endRequested = true
onRequestEnd(generation)
}

private nonisolated static func removeRegistrations(
appResignObserver: (any NSObjectProtocol)?,
keyDownMonitor: Any?,
localMouseUpMonitor: Any?,
globalMouseUpMonitor: Any?
) {
if let appResignObserver {
NotificationCenter.default.removeObserver(appResignObserver)
self.appResignObserver = nil
}
if let keyDownMonitor {
NSEvent.removeMonitor(keyDownMonitor)
self.keyDownMonitor = nil
}
if let localMouseUpMonitor {
NSEvent.removeMonitor(localMouseUpMonitor)
self.localMouseUpMonitor = nil
}
if let globalMouseUpMonitor {
NSEvent.removeMonitor(globalMouseUpMonitor)
self.globalMouseUpMonitor = nil
}
}

private nonisolated func requestEnd() {
Task { @MainActor [weak self] in
guard let self, !self.isStopped, !self.endRequested else { return }
self.endRequested = true
self.onRequestEnd(self.generation)
}
}
}
3 changes: 1 addition & 2 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1736,7 +1736,7 @@ final class BonsplitTests: XCTestCase {
}

@MainActor
func testAppResignClearsTabDragLifecycle() async {
func testAppResignClearsTabDragLifecycleSynchronously() {
let controller = SplitViewController()
let pane = controller.focusedPane!
let tab = pane.selectedTab!
Expand All @@ -1752,7 +1752,6 @@ final class BonsplitTests: XCTestCase {
name: NSApplication.didResignActiveNotification,
object: nil
)
await Task.yield()

XCTAssertNil(
controller.tabDragSession,
Expand Down
Loading