Skip to content
47 changes: 46 additions & 1 deletion Loop/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return .terminateLater
}

// LoopManager and WindowDragManager are explicitly shut down so that their
// event monitors are stopped immediately (in case they are active)
LoopManager.shared.shutdown()
WindowDragManager.shared.shutdown()

shutdownTask = Task { @MainActor in
await StashManager.shared.shutdown()
let didFinishStashShutdown = await runStashShutdownWithTimeout(.seconds(3))
if !didFinishStashShutdown {
log.warn("Timed out while restoring stashed windows during termination. Continuing shutdown.")
}

self.shutdownTask = nil
sender.reply(toApplicationShouldTerminate: true)
}
Expand All @@ -153,4 +162,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
urlCommandHandler.handle(url)
}
}

private func runStashShutdownWithTimeout(_ duration: Duration) async -> Bool {
await withCheckedContinuation { continuation in
let reply = OneShotContinuation(continuation)

let shutdownTask = Task { @MainActor in
await StashManager.shared.shutdown()
reply.resume(returning: true)
}

Task {
try? await Task.sleep(for: duration)
shutdownTask.cancel()
reply.resume(returning: false)
}
}
}
}

private final class OneShotContinuation<T>: @unchecked Sendable {
private let lock = NSLock()
private var didResume = false
private let continuation: CheckedContinuation<T, Never>

init(_ continuation: CheckedContinuation<T, Never>) {
self.continuation = continuation
}

func resume(returning result: T) {
lock.lock()
defer { lock.unlock() }

guard !didResume else { return }
didResume = true
continuation.resume(returning: result)
}
}
111 changes: 93 additions & 18 deletions Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Defaults
import os
import Scribe
import SwiftUI

Expand All @@ -25,7 +26,28 @@ final class LoopManager {

private var accessibilityCheckerTask: Task<(), Never>?

private(set) var isLoopActive: Bool = false
/// Opening prepares resizeContext asynchronously. We track that setup separately
/// so rapid trigger events cannot act on the previous/default context.
private var isLoopOpening: Bool = false
private var pendingOpeningAction: WindowAction?
private var shouldCancelOpening: Bool = false

private(set) var isLoopActive: Bool = false {
didSet {
let value = isLoopActive
isLoopActiveMirror.withLock { $0 = value }
}
}

private let isLoopActiveMirror = OSAllocatedUnfairLock<Bool>(initialState: false)
nonisolated var isLoopActiveAtomic: Bool {
isLoopActiveMirror.withLock { $0 }
}

private let hasParentCycleActionMirror = OSAllocatedUnfairLock<Bool>(initialState: false)
nonisolated var hasParentCycleActionAtomic: Bool {
hasParentCycleActionMirror.withLock { $0 }
}

private lazy var triggerKeyTimeoutTimer = TriggerKeyTimeoutTimer(
closeCallback: { [weak self] forceClose in
Expand All @@ -46,7 +68,7 @@ final class LoopManager {
}
},
checkIfLoopOpen: { [weak self] in
self?.isLoopActive ?? false
self?.isLoopActiveAtomic ?? false
}
)

Expand All @@ -61,7 +83,7 @@ final class LoopManager {
await self?.closeLoop(forceClose: forceClose)
}
},
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false }
checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }
)

private(set) lazy var mouseInteractionObserver = MouseInteractionObserver(
Expand All @@ -81,9 +103,9 @@ final class LoopManager {
}
},
canSelectNextCycleitem: { [weak self] in
self?.resizeContext.parentAction != nil
self?.hasParentCycleActionAtomic ?? false
},
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false }
checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }
)

func start() {
Expand All @@ -103,6 +125,24 @@ final class LoopManager {
}
}
}

func shutdown() {
accessibilityCheckerTask?.cancel()
accessibilityCheckerTask = nil

indicatorService.closeAll()

keybindTrigger.stop()
middleClickTrigger.stop()
mouseInteractionObserver.stop()
triggerKeyTimeoutTimer.cancel()

isLoopOpening = false
pendingOpeningAction = nil
shouldCancelOpening = false
isLoopActive = false
hasParentCycleActionMirror.withLock { $0 = false }
}
}

// MARK: - Opening/Closing Loop
Expand All @@ -113,6 +153,13 @@ extension LoopManager {
return
}

guard !isLoopOpening else {
if startingAction.direction != .noSelection {
pendingOpeningAction = startingAction
}
return
}

guard !isLoopActive else {
// If using Karabiner-Elements, TriggerKeybindObserver may call openLoop twice, as key events arrive in quick succession.
// This happens because Karabiner-Elements sends modifier keys and other keys as separate, rapid events.
Expand All @@ -134,6 +181,17 @@ extension LoopManager {
return
}

isLoopOpening = true
pendingOpeningAction = nil
shouldCancelOpening = false
hasParentCycleActionMirror.withLock { $0 = false }

defer {
isLoopOpening = false
pendingOpeningAction = nil
shouldCancelOpening = false
}

log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")")

// Refresh accent colors in case user has enabled the wallpaper processor
Expand All @@ -143,7 +201,7 @@ extension LoopManager {

let initialFrame: CGRect = if let window {
// In case of a stashed window, use the revealed frame instead to prevent issue with frame calculation later.
StashManager.shared.getRevealedFrameForStashedWindow(
await StashManager.shared.getRevealedFrameForStashedWindow(
id: window.cgWindowID
) ?? window.frame
} else {
Expand All @@ -157,24 +215,33 @@ extension LoopManager {
)
await resizeContext.refreshResolvedState()

guard !shouldCancelOpening else {
return
}

if !Defaults[.disableCursorInteraction] {
mouseInteractionObserver.start(initialMousePosition: resizeContext.initialMousePosition)
}

isLoopActive = true
indicatorService.openAndUpdate(context: resizeContext)

isLoopActive = true
await changeAction(startingAction, disableHapticFeedback: true)
await changeAction(pendingOpeningAction ?? startingAction, disableHapticFeedback: true)

triggerKeyTimeoutTimer.start()
}

private func closeLoop(forceClose: Bool) async {
if isLoopOpening {
shouldCancelOpening = true
}

guard isLoopActive == true else { return }
log.info("Closing Loop (force closed: \(forceClose))")

indicatorService.closeAll()
isLoopActive = false
hasParentCycleActionMirror.withLock { $0 = false }

triggerKeyTimeoutTimer.cancel()
mouseInteractionObserver.stop()
Expand Down Expand Up @@ -220,7 +287,6 @@ extension LoopManager {
) async {
guard
isLoopActive,
resizeContext.action.id != newAction.id || newAction.canRepeat,
let currentScreen = resizeContext.screen ?? resolveAndStoreTargetScreen(
action: newAction,
window: resizeContext.window
Expand All @@ -229,16 +295,20 @@ extension LoopManager {
return
}

if StashManager.shared.handleIfStashed(newAction, screen: currentScreen) {
return
}

guard resizeContext.action.id != newAction.id || newAction.canRepeat else {
return
}

var newAction: WindowAction = newAction
var newParentAction: WindowAction? = nil

triggerKeyTimeoutTimer.cancel()
triggerKeyTimeoutTimer.start()

if StashManager.shared.handleIfStashed(newAction, screen: currentScreen) {
return
}

if newAction.direction == .cycle {
newParentAction = newAction

Expand Down Expand Up @@ -312,7 +382,7 @@ extension LoopManager {
if let lastAction = await WindowRecords.shared.getCurrentAction(for: targetWindow),
lastAction.getName() != screenSwitchingCustomActionName,
!lastAction.forceProportionalFrameOnScreenChange {
resizeContext.setAction(to: lastAction, parent: nil)
setResizeAction(to: lastAction, parent: nil)
} else {
let currentFrame = targetWindow.frame

Expand All @@ -327,7 +397,7 @@ extension LoopManager {
height: currentFrame.height / adjustedBounds.height
)

resizeContext.setAction(
setResizeAction(
to: .init(
.custom,
keybind: [],
Expand All @@ -344,15 +414,15 @@ extension LoopManager {
)
}
} else {
resizeContext.setAction(to: .init(.center), parent: nil)
setResizeAction(to: .init(.center), parent: nil)
}
}

resizeContext.setScreen(to: newScreen)
indicatorService.openAndUpdate(context: resizeContext)

if let parent = newParentAction {
resizeContext.setAction(to: newAction, parent: newParentAction)
setResizeAction(to: newAction, parent: newParentAction)
await changeAction(parent, triggeredFromScreenChange: true)
} else {
if !Defaults[.previewVisibility] {
Expand All @@ -377,7 +447,7 @@ extension LoopManager {

if newAction != resizeContext.action || newAction.canRepeat {
let previousActionWasNoOp = resizeContext.action.direction.isNoOp
resizeContext.setAction(to: newAction, parent: newParentAction)
setResizeAction(to: newAction, parent: newParentAction)
if !Defaults[.previewVisibility], !previousActionWasNoOp {
await resizeContext.refreshResolvedState()
}
Expand Down Expand Up @@ -459,6 +529,11 @@ extension LoopManager {
}
}

private func setResizeAction(to newAction: WindowAction, parent newParentAction: WindowAction?) {
resizeContext.setAction(to: newAction, parent: newParentAction)
hasParentCycleActionMirror.withLock { $0 = newParentAction != nil }
}

/// Resolves the target screen for `screenToResizeOn`.
///
/// By default, this uses the user's `useScreenWithCursor` setting.
Expand Down
2 changes: 2 additions & 0 deletions Loop/Core/Observers/MouseInteractionObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ final class MouseInteractionObserver {
}

func start(initialMousePosition: CGPoint) {
stop()

screenBounds = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) })?.frame

if let screenBounds {
Expand Down
10 changes: 10 additions & 0 deletions Loop/Core/WindowDragManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,17 @@ final class WindowDragManager {
}
}

func shutdown() {
accessibilityCheckerTask?.cancel()
accessibilityCheckerTask = nil
removeListeners()
resetDragState()
previewController.close()
}

private func setupListeners() {
removeListeners()

let leftMouseDraggedMonitor = PassiveEventMonitor(
"snapping_left_mouse_dragged_monitor",
events: [.leftMouseDragged],
Expand Down
Loading