From 3228b8670f73976b470f4d62e34f82e4e8d8124f Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Tue, 5 May 2026 23:42:29 -0600 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Make=20WindowTransformAnimati?= =?UTF-8?q?on=20run=20on=20main=20actor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowTransformAnimation.swift | 2 +- Loop/Window Management/Window/Window.swift | 32 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index 0242042f..b5bd44e4 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -8,6 +8,7 @@ import SwiftUI /// Animate a window's resize! +@MainActor final class WindowTransformAnimation: NSAnimation { private var targetFrame: CGRect private let originalFrame: CGRect @@ -53,7 +54,6 @@ final class WindowTransformAnimation: NSAnimation { fatalError("init(coder:) has not been implemented") } - @MainActor override func start() { super.start() } diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index bb40ad54..97a79c3d 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -476,13 +476,15 @@ final class Window { } } - @concurrent + @MainActor func setFrameAnimated( _ rect: CGRect, bounds: CGRect, resolvedProperties: ResolvedProperties? = nil ) async throws { - guard await !MainActor.run(resultType: Bool.self, body: { applyOwnWindowFrame(rect) }) else { + try Task.checkCancellation() + + guard !applyOwnWindowFrame(rect) else { return } @@ -496,22 +498,20 @@ final class Window { } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(), Error>) in - Task { - try Task.checkCancellation() - let animation = WindowTransformAnimation( - rect, - window: self, - bounds: bounds, - shouldSetSize: shouldSetSize - ) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } + let animation = WindowTransformAnimation( + rect, + window: self, + bounds: bounds, + shouldSetSize: shouldSetSize + ) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) } - await animation.start() } + + animation.start() } if enhancedUI { From 1a15928f0376ab63f45e01b56a6affa25e899b5a Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 6 May 2026 02:58:20 -0600 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9A=A1=20Harden=20event=20tap=20lifecy?= =?UTF-8?q?cle=20during=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/App/AppDelegate.swift | 47 +++++++++++- Loop/Core/LoopManager.swift | 59 ++++++++++++--- .../Observers/MouseInteractionObserver.swift | 2 + Loop/Core/WindowDragManager.swift | 10 +++ .../BaseEventTapMonitor.swift | 72 ++++++++++++++----- .../Event Monitoring/EventTapThread.swift | 61 ++++++++++++++++ 6 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 Loop/Utilities/Event Monitoring/EventTapThread.swift diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 9701971e..61d35b92 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -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) } @@ -153,4 +162,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate { urlCommandHandler.handle(url) } } + + private func runStashShutdownWithTimeout(_ duration: Duration) async -> Bool { + return 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: @unchecked Sendable { + private let lock = NSLock() + private var didResume = false + private let continuation: CheckedContinuation + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(returning result: T) { + lock.lock() + defer { lock.unlock() } + + guard !didResume else { return } + didResume = true + continuation.resume(returning: result) + } } diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 3a30f7cc..8e89b429 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -6,6 +6,7 @@ // import Defaults +import os import Scribe import SwiftUI @@ -25,7 +26,22 @@ final class LoopManager { private var accessibilityCheckerTask: Task<(), Never>? - private(set) var isLoopActive: Bool = false + private(set) var isLoopActive: Bool = false { + didSet { + let value = isLoopActive + isLoopActiveMirror.withLock { $0 = value } + } + } + + private let isLoopActiveMirror = OSAllocatedUnfairLock(initialState: false) + nonisolated var isLoopActiveAtomic: Bool { + isLoopActiveMirror.withLock { $0 } + } + + private let hasParentCycleActionMirror = OSAllocatedUnfairLock(initialState: false) + nonisolated var hasParentCycleActionAtomic: Bool { + hasParentCycleActionMirror.withLock { $0 } + } private lazy var triggerKeyTimeoutTimer = TriggerKeyTimeoutTimer( closeCallback: { [weak self] forceClose in @@ -46,7 +62,7 @@ final class LoopManager { } }, checkIfLoopOpen: { [weak self] in - self?.isLoopActive ?? false + self?.isLoopActiveAtomic ?? false } ) @@ -61,7 +77,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( @@ -81,9 +97,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() { @@ -103,6 +119,19 @@ final class LoopManager { } } } + + func shutdown() { + accessibilityCheckerTask?.cancel() + accessibilityCheckerTask = nil + + keybindTrigger.stop() + middleClickTrigger.stop() + mouseInteractionObserver.stop() + triggerKeyTimeoutTimer.cancel() + + isLoopActive = false + hasParentCycleActionMirror.withLock { $0 = false } + } } // MARK: - Opening/Closing Loop @@ -134,6 +163,9 @@ extension LoopManager { return } + isLoopActive = true + hasParentCycleActionMirror.withLock { $0 = 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 @@ -163,7 +195,6 @@ extension LoopManager { indicatorService.openAndUpdate(context: resizeContext) - isLoopActive = true await changeAction(startingAction, disableHapticFeedback: true) triggerKeyTimeoutTimer.start() @@ -175,6 +206,7 @@ extension LoopManager { indicatorService.closeAll() isLoopActive = false + hasParentCycleActionMirror.withLock { $0 = false } triggerKeyTimeoutTimer.cancel() mouseInteractionObserver.stop() @@ -312,7 +344,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 @@ -327,7 +359,7 @@ extension LoopManager { height: currentFrame.height / adjustedBounds.height ) - resizeContext.setAction( + setResizeAction( to: .init( .custom, keybind: [], @@ -344,7 +376,7 @@ extension LoopManager { ) } } else { - resizeContext.setAction(to: .init(.center), parent: nil) + setResizeAction(to: .init(.center), parent: nil) } } @@ -352,7 +384,7 @@ extension LoopManager { 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] { @@ -377,7 +409,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() } @@ -459,6 +491,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. diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index 2ff10c15..914c90a7 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -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 { diff --git a/Loop/Core/WindowDragManager.swift b/Loop/Core/WindowDragManager.swift index 38dd446b..29564f9f 100644 --- a/Loop/Core/WindowDragManager.swift +++ b/Loop/Core/WindowDragManager.swift @@ -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], diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 8631a5f8..440f8017 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -21,25 +21,11 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { private(set) var isEnabled: Bool = false deinit { - if isEnabled { - stop() - } - - // Clean up run loop source and event tap - if let runLoop, let runLoopSource { - CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes) - self.runLoopSource = nil - } - - if let eventTap { - CFMachPortInvalidate(eventTap) - self.eventTap = nil - } + tearDownEventTap() } func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) { - // Runloop is already running here. In the future, we can investigate running the mach port on another thread. - let runLoop = CFRunLoopGetMain() + let runLoop = EventTapThread.shared.runLoop self.readableIdentifier = readableIdentifier if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { @@ -47,6 +33,7 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { self.runLoop = runLoop self.runLoopSource = runLoopSource CFRunLoopAddSource(runLoop, runLoopSource, .commonModes) + CFRunLoopWakeUp(runLoop) } } @@ -64,7 +51,7 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { } func stop() { - guard let eventTap else { return } + guard eventTap != nil else { return } if let readableIdentifier { log.info("Stopping BaseEventTapMonitor '\(readableIdentifier)'") @@ -72,11 +59,58 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { log.info("Stopping BaseEventTapMonitor with ID \(id)") } - CGEvent.tapEnable(tap: eventTap, enable: false) - isEnabled = false + tearDownEventTap() } static func == (lhs: BaseEventTapMonitor, rhs: BaseEventTapMonitor) -> Bool { lhs.id == rhs.id } + + private func tearDownEventTap() { + guard eventTap != nil || runLoopSource != nil else { return } + + let eventTap = eventTap + let runLoop = runLoop + let runLoopSource = runLoopSource + + self.eventTap = nil + self.runLoop = nil + self.runLoopSource = nil + isEnabled = false + + guard let runLoop else { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + } + return + } + + let cleanup = { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + + if let runLoopSource { + CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes) + } + + if let eventTap { + CFMachPortInvalidate(eventTap) + } + } + + if CFRunLoopGetCurrent() == runLoop { + cleanup() + return + } + + let finished = DispatchSemaphore(value: 0) + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + cleanup() + finished.signal() + } + CFRunLoopWakeUp(runLoop) + finished.wait() + } } diff --git a/Loop/Utilities/Event Monitoring/EventTapThread.swift b/Loop/Utilities/Event Monitoring/EventTapThread.swift new file mode 100644 index 00000000..f5dcf283 --- /dev/null +++ b/Loop/Utilities/Event Monitoring/EventTapThread.swift @@ -0,0 +1,61 @@ +// +// EventTapThread.swift +// Loop +// +// Created by Kai Azim on 2026-05-06. +// + +import CoreFoundation +import Foundation + +/// Owns the run loop used by global event taps. +final class EventTapThread: Thread { + static let shared = EventTapThread(name: "Loop.EventTapThread") + + private let startLock = NSLock() + private let runLoopReady = DispatchGroup() + private var hasStarted = false + private var eventTapRunLoop: CFRunLoop? + + var runLoop: CFRunLoop { + startLock.lock() + if !hasStarted { + hasStarted = true + start() + } + startLock.unlock() + + runLoopReady.wait() + + guard let eventTapRunLoop else { + preconditionFailure("EventTapThread failed to publish its run loop") + } + + return eventTapRunLoop + } + + private init(name: String) { + runLoopReady.enter() + super.init() + self.name = name + qualityOfService = .userInteractive + } + + override func main() { + eventTapRunLoop = CFRunLoopGetCurrent() + + var sourceContext = CFRunLoopSourceContext() + let keepAliveSource = CFRunLoopSourceCreate( + kCFAllocatorDefault, + 0, + &sourceContext + ) + + if let eventTapRunLoop, let keepAliveSource { + CFRunLoopAddSource(eventTapRunLoop, keepAliveSource, .commonModes) + } + + runLoopReady.leave() + CFRunLoopRun() + } +} From 34a6434621689d92e4f323acb488cf191326e8d4 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 22:18:37 -0600 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/App/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 61d35b92..9fde451c 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -164,7 +164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func runStashShutdownWithTimeout(_ duration: Duration) async -> Bool { - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in let reply = OneShotContinuation(continuation) let shutdownTask = Task { @MainActor in From 68ef7f1391d458e0d02bb4aa767e05ed92811b9d Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 22:22:09 -0600 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20Close=20all=20on=20indicatorS?= =?UTF-8?q?ervice=20in=20LoopManager.shutdown()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 8e89b429..23743026 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -124,6 +124,8 @@ final class LoopManager { accessibilityCheckerTask?.cancel() accessibilityCheckerTask = nil + indicatorService.closeAll() + keybindTrigger.stop() middleClickTrigger.stop() mouseInteractionObserver.stop() From 5f2d668535c5e21cfbc2cfd09a9007242dd23bef Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 22:26:02 -0600 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9E=20Fix=20stash=20not=20preser?= =?UTF-8?q?ving=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 +- Loop/Stashing/StashManager.swift | 10 +++++----- Loop/Stashing/StashedWindowInfo.swift | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 23743026..4a368008 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -177,7 +177,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 { diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index 21ca900b..afb04db8 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -137,8 +137,8 @@ final class StashManager { return true } - func getRevealedFrameForStashedWindow(id: CGWindowID) -> CGRect? { - store.stashed[id]?.computeRevealedFrame() + func getRevealedFrameForStashedWindow(id: CGWindowID) async -> CGRect? { + await store.stashed[id]?.computeRevealedFrame() } } @@ -277,7 +277,7 @@ private extension StashManager { } } - let frame = window.computeRevealedFrame() + let frame = await window.computeRevealedFrame() if shiftFocusWhenStashed { Task { @MainActor in @@ -500,7 +500,7 @@ private extension StashManager { private func shouldHide(window: StashedWindowInfo, for location: CGPoint) async -> Bool { // Hide the window if the cursor is neither over the revealedFrame nor the stashedFrame. let tolerance: CGFloat = 15 - let revealedFrame = window.computeRevealedFrame().insetBy(dx: -tolerance, dy: -tolerance) + let revealedFrame = await window.computeRevealedFrame().insetBy(dx: -tolerance, dy: -tolerance) let stashedFrame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) return !revealedFrame.contains(location) && !stashedFrame.contains(location) } @@ -524,7 +524,7 @@ private extension StashManager { /// If there is not enough space, the stashed window will be unstashed (i.e., made fully visible and removed from the stash) /// and replaced by `windowToStash` func unstashOverlappingWindows(_ windowToStash: StashedWindowInfo) async { - let newFrame = windowToStash.computeRevealedFrame() + let newFrame = await windowToStash.computeRevealedFrame() for (id, stashedWindow) in store.stashed { // windowToStash is already managed by StashManager. Can't overlap with itself. diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 937948d8..bd321727 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -47,9 +47,10 @@ struct StashedWindowInfo: Equatable { return frame } - func computeRevealedFrame() -> CGRect { + func computeRevealedFrame() async -> CGRect { let context = ResizeContext(window: window, screen: screen) context.setAction(to: action, parent: nil) + await context.refreshResolvedState() return context.getTargetFrame().padded } } From 998ee76e10a266afa8504495c5c08490d01f39a0 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 22:44:52 -0600 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20Rename=20EventTapThread=20by?= =?UTF-8?q?=20bundle=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Utilities/Event Monitoring/EventTapThread.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Utilities/Event Monitoring/EventTapThread.swift b/Loop/Utilities/Event Monitoring/EventTapThread.swift index f5dcf283..6718ecf0 100644 --- a/Loop/Utilities/Event Monitoring/EventTapThread.swift +++ b/Loop/Utilities/Event Monitoring/EventTapThread.swift @@ -10,7 +10,7 @@ import Foundation /// Owns the run loop used by global event taps. final class EventTapThread: Thread { - static let shared = EventTapThread(name: "Loop.EventTapThread") + static let shared = EventTapThread(name: "\(Bundle.main.bundleID).EventTapThread") private let startLock = NSLock() private let runLoopReady = DispatchGroup() From 23449303a5538bb82b0bb7969394acc60cd57e3c Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 22:54:04 -0600 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=9E=20Fix=20reentrant=20Loop=20o?= =?UTF-8?q?pen=20during=20context=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 4a368008..4b97c2fe 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -26,6 +26,12 @@ final class LoopManager { private var accessibilityCheckerTask: Task<(), Never>? + /// 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 @@ -131,6 +137,9 @@ final class LoopManager { mouseInteractionObserver.stop() triggerKeyTimeoutTimer.cancel() + isLoopOpening = false + pendingOpeningAction = nil + shouldCancelOpening = false isLoopActive = false hasParentCycleActionMirror.withLock { $0 = false } } @@ -144,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. @@ -165,9 +181,17 @@ extension LoopManager { return } - isLoopActive = true + 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 @@ -191,18 +215,27 @@ extension LoopManager { ) await resizeContext.refreshResolvedState() + guard !shouldCancelOpening else { + return + } + if !Defaults[.disableCursorInteraction] { mouseInteractionObserver.start(initialMousePosition: resizeContext.initialMousePosition) } + isLoopActive = true indicatorService.openAndUpdate(context: resizeContext) - 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))") From 13e2ffc9edad528691d668cfdc9b3af46d720919 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 7 May 2026 23:17:29 -0600 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9A=A1=20Add=20timeout=20to=20event=20?= =?UTF-8?q?tap=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BaseEventTapMonitor.swift | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 440f8017..31e549c5 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -12,6 +12,8 @@ import Scribe /// Base class to share common functionality. DO NOT USE DIRECTLY! @Loggable class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { + private static let teardownTimeout: DispatchTimeInterval = .milliseconds(250) + let id = UUID() private var eventTap: CFMachPort? @@ -72,45 +74,57 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { let eventTap = eventTap let runLoop = runLoop let runLoopSource = runLoopSource + let readableIdentifier = readableIdentifier self.eventTap = nil self.runLoop = nil self.runLoopSource = nil isEnabled = false - guard let runLoop else { - if let eventTap { - CGEvent.tapEnable(tap: eventTap, enable: false) - CFMachPortInvalidate(eventTap) - } - return - } - let cleanup = { - if let eventTap { + if let eventTap, CFMachPortIsValid(eventTap) { CGEvent.tapEnable(tap: eventTap, enable: false) } - if let runLoopSource { + if let runLoop, let runLoopSource, CFRunLoopSourceIsValid(runLoopSource) { CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes) } - if let eventTap { + if let eventTap, CFMachPortIsValid(eventTap) { CFMachPortInvalidate(eventTap) } } + guard let runLoop else { + cleanup() + return + } + if CFRunLoopGetCurrent() == runLoop { cleanup() return } let finished = DispatchSemaphore(value: 0) + let monitor = self CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { cleanup() + + // Keep callback userInfo valid until the tap is torn down + _ = monitor + finished.signal() } CFRunLoopWakeUp(runLoop) - finished.wait() + + if finished.wait(timeout: .now() + Self.teardownTimeout) == .timedOut { + if let eventTap, CFMachPortIsValid(eventTap) { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + } + + let identifier = readableIdentifier ?? id.uuidString + log.warn("Timed out while tearing down event tap '\(identifier)'. Invalidated it from the caller thread.") + } } } From c5ce0239c610bda8fa40e25f1f8c3e6617490a36 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 8 May 2026 16:21:29 -0600 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=A8=20Stash=20performance=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Stashing/StashManager.swift | 179 +++++++++++++++------ Loop/Stashing/StashedWindowInfo.swift | 47 +++++- Loop/Stashing/StashedWindowStore.swift | 45 ++++-- Loop/Window Management/Window/Window.swift | 9 +- 4 files changed, 210 insertions(+), 70 deletions(-) diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index afb04db8..59b73e40 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -46,7 +46,7 @@ final class StashManager { } /// How many pixels of the window should be visible when stashed - private var stashedWindowVisiblePadding: CGFloat { + var stashedWindowVisiblePadding: CGFloat { Defaults[.stashedWindowVisiblePadding] } @@ -75,11 +75,14 @@ final class StashManager { private var mouseMonitor: PassiveEventMonitor? private var frontmostAppMonitor: Task<(), Never>? private var mouseMovedTask: Task<(), Never>? + private var transitionIDs: [CGWindowID: UUID] = [:] // MARK: - Public methods func start() { - store.restore() + Task { + await store.restore() + } } func onWindowManipulated(_ id: CGWindowID) { @@ -95,14 +98,15 @@ final class StashManager { } func onConfigurationChanged() async { - await withTaskGroup(of: Void.self) { group in - for stashedWindow in store.stashed.values { - group.addTask { - let frame = await stashedWindow.computeStashedFrame(peekSize: self.stashedWindowVisiblePadding) - // Don't animate when configuration changes - await stashedWindow.window.setFrame(frame) - } - } + let stashedWindows = Array(store.stashed.values) + + for stashedWindow in stashedWindows { + let updated = await stashedWindow.updatingStashedFrame(peekSize: stashedWindowVisiblePadding) + + store.setStashedWindow(cgWindowID: updated.window.cgWindowID, to: updated) + + // Don't animate when configuration changes + await updated.window.setFrame(updated.stashedFrame) } } @@ -138,7 +142,7 @@ final class StashManager { } func getRevealedFrameForStashedWindow(id: CGWindowID) async -> CGRect? { - await store.stashed[id]?.computeRevealedFrame() + store.stashed[id]?.revealedFrame } } @@ -165,7 +169,12 @@ extension StashManager { log.info("Attempting to stash window on the \(edge.debugDescription) edge, but \(screen.localizedName) is not the \(edge.debugDescription)most screen. Redirecting to the correct screen.") await onWindowResized(action: action, window: window, screen: screenForEdge) } else { - let windowToStash = StashedWindowInfo(window: window, screen: screen, action: action) + let windowToStash = await StashedWindowInfo.create( + window: window, + screen: screen, + action: action, + peekSize: stashedWindowVisiblePadding + ) await stash(windowToStash) } @@ -184,10 +193,14 @@ extension StashManager { // Grow, shrink, or adjustSize actions won't work for predefined stash actions, since they have a custom size. // If the window’s frame is updated while it’s stashed and hidden, the update will cause the window to move back on-screen - // without adding its id to `store.revealed`. Whe need to add it back so the hide animation can be triggered. - if isManaged(window.cgWindowID) { - // If the window frame is fully on screen while the window ID is not in the `store.reveal` set, we add it. - let isWindowFullyOnScreen = screen.cgSafeScreenFrame.contains(window.frame) + // without adding its id to `store.revealed`. We need to add it back so the hide animation can be triggered. + if let stashedWindow = store.stashed[window.cgWindowID] { + let currentScreen = ScreenUtility.screenContaining(window) ?? screen + let updated = await stashedWindow.updatingFrames(screen: currentScreen, peekSize: stashedWindowVisiblePadding) + store.setStashedWindow(cgWindowID: window.cgWindowID, to: updated) + + // If the window frame is fully on screen while the window ID is not in the `store.revealed` set, we add it. + let isWindowFullyOnScreen = currentScreen.cgSafeScreenFrame.contains(window.frame) if isWindowFullyOnScreen, !store.isWindowRevealed(window.cgWindowID) { store.markWindowAsRevealed(window.cgWindowID) @@ -211,7 +224,7 @@ extension StashManager { await unstashOverlappingWindows(windowToStash) store.setStashedWindow(cgWindowID: windowToStash.window.cgWindowID, to: windowToStash) - await hideWindow(windowToStash) + await hideWindow(windowToStash, allowUnrevealed: true, shouldThrottle: false) startListeningToRevealTriggers() } @@ -263,11 +276,14 @@ extension StashManager { private extension StashManager { /// Reveals a stashed window by moving it to its reveal frame. func revealWindow(_ window: StashedWindowInfo) async { - guard !store.isWindowRevealed(window.window.cgWindowID) else { return } - guard !shouldThrottle(windowID: window.window.cgWindowID) else { return } + let windowID = window.window.cgWindowID + + guard !store.isWindowRevealed(windowID) else { return } + guard !shouldThrottle(windowID: windowID) else { return } // Keep only one window as revealed for revealedWindowId in store.revealed { + guard revealedWindowId != windowID else { continue } guard let revealedWindow = store.stashed[revealedWindowId] else { break } // Run on another thread to prevent this window's reveal from delaying @@ -277,7 +293,8 @@ private extension StashManager { } } - let frame = await window.computeRevealedFrame() + let transitionID = beginTransition(windowID: windowID, revealed: true) + let frame = window.revealedFrame if shiftFocusWhenStashed { Task { @MainActor in @@ -285,40 +302,71 @@ private extension StashManager { } } - if animate { - try? await window.window.setFrameAnimated( - frame, - bounds: .zero - ) - } else { - await window.window.setFrame(frame) + do { + if animate { + try await window.window.setFrameAnimated( + frame, + bounds: .zero + ) + } else { + await window.window.setFrame(frame) + } + } catch is CancellationError { + cancelTransition(windowID: windowID, transitionID: transitionID, fallbackRevealed: false) + return + } catch { + cancelTransition(windowID: windowID, transitionID: transitionID, fallbackRevealed: false) + log.error("Failed to revealWindow \(window.window.description): \(error.localizedDescription)") + return } - store.markWindowAsRevealed(window.window.cgWindowID) - log.info("revealWindow \(window.window.description)") + if finishTransition(windowID: windowID, transitionID: transitionID) { + log.info("revealWindow \(window.window.description)") + } } /// Hides a stashed window by moving it to its stashed frame. - func hideWindow(_ window: StashedWindowInfo, shouldUnfocus: Bool = true) async { - guard !shouldThrottle(windowID: window.window.cgWindowID) else { return } + func hideWindow(_ window: StashedWindowInfo, shouldUnfocus: Bool = true, allowUnrevealed: Bool = false, shouldThrottle: Bool = true) async { + let windowID = window.window.cgWindowID + + guard allowUnrevealed || store.isWindowRevealed(windowID) else { + log.warn("Skipping hideWindow because window is not revealed: \(window.window.description)") + return + } + + guard !shouldThrottle || !self.shouldThrottle(windowID: windowID) else { + log.warn("Skipping hideWindow because transition is throttled: \(window.window.description)") + return + } - let frame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let transitionID = beginTransition(windowID: windowID, revealed: false) + let frame = window.stashedFrame if shouldUnfocus { - unfocus(window.window.cgWindowID) + unfocus(windowID) } - if animate { - try? await window.window.setFrameAnimated( - frame, - bounds: .zero - ) - } else { - await window.window.setFrame(frame) + do { + if animate { + try await window.window.setFrameAnimated( + frame, + bounds: .zero + ) + } else { + await window.window.setFrame(frame) + } + } catch is CancellationError { + cancelTransition(windowID: windowID, transitionID: transitionID, fallbackRevealed: true) + return + } catch { + cancelTransition(windowID: windowID, transitionID: transitionID, fallbackRevealed: true) + log.error("Failed to hideWindow \(window.window.description): \(error.localizedDescription)") + return } - store.markWindowAsHidden(window.window.cgWindowID) - log.info("hideWindow \(window.window.description)") + if finishTransition(windowID: windowID, transitionID: transitionID) { + log.info("hideWindow \(window.window.description)") + } } /// Checks if the window reveal / hide should be throttled based on the last reveal time. @@ -500,15 +548,14 @@ private extension StashManager { private func shouldHide(window: StashedWindowInfo, for location: CGPoint) async -> Bool { // Hide the window if the cursor is neither over the revealedFrame nor the stashedFrame. let tolerance: CGFloat = 15 - let revealedFrame = await window.computeRevealedFrame().insetBy(dx: -tolerance, dy: -tolerance) - let stashedFrame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let revealedFrame = window.revealedFrame.insetBy(dx: -tolerance, dy: -tolerance) + let stashedFrame = window.stashedFrame return !revealedFrame.contains(location) && !stashedFrame.contains(location) } /// Checks if the mouse is currently hovering over the stashed frame of a window. private func isMouseOverStashed(window: StashedWindowInfo, location: CGPoint) async -> Bool { - let stashedFrame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) - return stashedFrame.contains(location) + window.stashedFrame.contains(location) } } @@ -524,7 +571,7 @@ private extension StashManager { /// If there is not enough space, the stashed window will be unstashed (i.e., made fully visible and removed from the stash) /// and replaced by `windowToStash` func unstashOverlappingWindows(_ windowToStash: StashedWindowInfo) async { - let newFrame = await windowToStash.computeRevealedFrame() + let newFrame = windowToStash.revealedFrame for (id, stashedWindow) in store.stashed { // windowToStash is already managed by StashManager. Can't overlap with itself. @@ -538,7 +585,7 @@ private extension StashManager { log.info("Trying to stash a window in the same place as another one. Replacing…") await unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } else { - let currentFrame = await stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let currentFrame = stashedWindow.stashedFrame let tolerance = minimumVisibleSizeToKeepWindowStacked if !isThereEnoughNonOverlappingSpace(between: newFrame, and: currentFrame, edge: windowToStash.action.stashEdge, tolerance: tolerance) { @@ -633,6 +680,7 @@ private extension StashManager { store.setStashedWindow(cgWindowID: windowID, to: nil) store.markWindowAsRevealed(windowID) lastRevealTime.removeValue(forKey: windowID) + transitionIDs.removeValue(forKey: windowID) if store.stashed.isEmpty { stopListeningToRevealTriggers() @@ -652,4 +700,39 @@ private extension StashManager { currentScreen.bottommostScreenInSameColumn(overlapThreshold: threshold) } } + + func beginTransition(windowID: CGWindowID, revealed: Bool) -> UUID { + let transitionID = UUID() + transitionIDs[windowID] = transitionID + + if revealed { + store.markWindowAsRevealed(windowID) + } else { + store.markWindowAsHidden(windowID) + } + + return transitionID + } + + @discardableResult + func finishTransition(windowID: CGWindowID, transitionID: UUID) -> Bool { + guard transitionIDs[windowID] == transitionID else { + return false + } + + transitionIDs.removeValue(forKey: windowID) + return true + } + + func cancelTransition(windowID: CGWindowID, transitionID: UUID, fallbackRevealed: Bool) { + guard finishTransition(windowID: windowID, transitionID: transitionID) else { + return + } + + if fallbackRevealed { + store.markWindowAsRevealed(windowID) + } else { + store.markWindowAsHidden(windowID) + } + } } diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index bd321727..64299f0d 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -14,11 +14,52 @@ struct StashedWindowInfo: Equatable { let window: Window let screen: NSScreen let action: WindowAction + let revealedFrame: CGRect + let stashedFrame: CGRect // MARK: - Frame computation + static func create(window: Window, screen: NSScreen, action: WindowAction, peekSize: CGFloat) async -> StashedWindowInfo { + let revealedFrame = await computeRevealedFrame(window: window, screen: screen, action: action) + let stashedFrame = await computeStashedFrame(window: window, screen: screen, action: action, peekSize: peekSize) + + return StashedWindowInfo( + window: window, + screen: screen, + action: action, + revealedFrame: revealedFrame, + stashedFrame: stashedFrame + ) + } + + func updatingStashedFrame(peekSize: CGFloat) async -> StashedWindowInfo { + let stashedFrame = await Self.computeStashedFrame( + window: window, + screen: screen, + action: action, + peekSize: peekSize + ) + + return StashedWindowInfo( + window: window, + screen: screen, + action: action, + revealedFrame: revealedFrame, + stashedFrame: stashedFrame + ) + } + + func updatingFrames(screen: NSScreen, peekSize: CGFloat) async -> StashedWindowInfo { + await Self.create( + window: window, + screen: screen, + action: action, + peekSize: peekSize + ) + } + /// Computes the frame for a stashed window. - func computeStashedFrame(peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { + private static func computeStashedFrame(window: Window, screen: NSScreen, action: WindowAction, peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { let bounds = screen.cgSafeScreenFrame var frame = await WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) @@ -41,13 +82,13 @@ struct StashedWindowInfo: Equatable { frame.origin.y = bounds.maxY - clampedPeekSize case .none: - log.warn("Trying to compute the stash frame for a non-stash related action.") + break } return frame } - func computeRevealedFrame() async -> CGRect { + private static func computeRevealedFrame(window: Window, screen: NSScreen, action: WindowAction) async -> CGRect { let context = ResizeContext(window: window, screen: screen) context.setAction(to: action, parent: nil) await context.refreshResolvedState() diff --git a/Loop/Stashing/StashedWindowStore.swift b/Loop/Stashing/StashedWindowStore.swift index 867fb25b..0948868a 100644 --- a/Loop/Stashing/StashedWindowStore.swift +++ b/Loop/Stashing/StashedWindowStore.swift @@ -11,6 +11,7 @@ import Scribe import SwiftUI protocol StashedWindowsStoreDelegate: AnyObject { + var stashedWindowVisiblePadding: CGFloat { get } func onStashedWindowsRestored() } @@ -25,12 +26,12 @@ final class StashedWindowsStore { /// Hold data from `Defaults[.stashManagerStashedWindows]` for windows that failed to be restored. private var failedToRestore: [CGWindowID: WindowAction] = [:] - private var spaceObserver: NSObjectProtocol? + private var spaceObserverTask: Task<(), Never>? // MARK: - Public methods - func restore() { - restoreStashedWindows() + func restore() async { + await restoreStashedWindows() } func isWindowRevealed(_ id: CGWindowID) -> Bool { @@ -63,13 +64,13 @@ final class StashedWindowsStore { // MARK: Private methods - private func restoreStashedWindows() { + private func restoreStashedWindows() async { let windows = WindowUtility.windowList() let defaultStashedWindows = Defaults[.stashManagerStashedWindows] var restoredStashedWindows: [CGWindowID: StashedWindowInfo] = [:] for (windowId, direction) in defaultStashedWindows { - guard let stashedWindow = getStashedWindow(for: windowId, in: windows, action: direction) else { + guard let stashedWindow = await getStashedWindow(for: windowId, in: windows, action: direction) else { failedToRestore[windowId] = direction continue } @@ -88,20 +89,27 @@ final class StashedWindowsStore { // Window restoration usually fail because the window is on another space and will // not be returned by WindowEngine.windowList until the user goes to that space. - let notification = NSWorkspace.activeSpaceDidChangeNotification - spaceObserver = NSWorkspace.shared.notificationCenter - .addObserver(forName: notification, object: nil, queue: .main, using: onSpaceChanged) + spaceObserverTask = Task { [weak self] in + let notifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.activeSpaceDidChangeNotification + ) + + for await _ in notifications { + guard !Task.isCancelled else { return } + await self?.onSpaceChanged() + } + } } } - private func onSpaceChanged(_: Notification) { + private func onSpaceChanged() async { let windows = WindowUtility.windowList() var restored = 0 log.info("Space changed. Attempting to restore windows.") for (windowId, direction) in failedToRestore { - guard let stashedWindow = getStashedWindow(for: windowId, in: windows, action: direction) else { + guard let stashedWindow = await getStashedWindow(for: windowId, in: windows, action: direction) else { continue } @@ -114,15 +122,22 @@ final class StashedWindowsStore { delegate?.onStashedWindowsRestored() } - if let spaceObserver, failedToRestore.isEmpty { - NSWorkspace.shared.notificationCenter.removeObserver(spaceObserver) + if failedToRestore.isEmpty { + spaceObserverTask?.cancel() + spaceObserverTask = nil } } - private func getStashedWindow(for windowId: CGWindowID, in windows: [Window], action: WindowAction) -> StashedWindowInfo? { + private func getStashedWindow(for windowId: CGWindowID, in windows: [Window], action: WindowAction) async -> StashedWindowInfo? { guard let window = windows.first(where: { $0.cgWindowID == windowId }) else { return nil } guard let screen = ScreenUtility.screenContaining(window) ?? NSScreen.main else { return nil } - - return StashedWindowInfo(window: window, screen: screen, action: action) + guard let peekSize = delegate?.stashedWindowVisiblePadding else { return nil } + + return await StashedWindowInfo.create( + window: window, + screen: screen, + action: action, + peekSize: peekSize + ) } } diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index 97a79c3d..035f577a 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -496,6 +496,11 @@ final class Window { log.info("\(appName ?? "This app")'s enhanced UI will be temporarily disabled while resizing.") enhancedUserInterface = false } + defer { + if enhancedUI { + enhancedUserInterface = true + } + } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(), Error>) in let animation = WindowTransformAnimation( @@ -513,10 +518,6 @@ final class Window { animation.start() } - - if enhancedUI { - enhancedUserInterface = true - } } } From aa1f04c9183e5b47b58d897fb77373f6e8e2e33e Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 8 May 2026 16:44:48 -0600 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=9A=9B=20Move=20stashed=20window=20?= =?UTF-8?q?frame=20calculations=20into=20WindowFrameResolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 13 +++-- Loop/Stashing/StashedWindowInfo.swift | 48 ++---------------- .../Radial Menu/RadialMenuViewModel.swift | 2 +- .../Window Action/WindowAction.swift | 5 +- .../WindowFrameResolver.swift | 50 +++++++++++++++++++ 5 files changed, 63 insertions(+), 55 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 4b97c2fe..6faf7918 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -287,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 @@ -296,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 diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 64299f0d..539c6a53 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -20,8 +20,8 @@ struct StashedWindowInfo: Equatable { // MARK: - Frame computation static func create(window: Window, screen: NSScreen, action: WindowAction, peekSize: CGFloat) async -> StashedWindowInfo { - let revealedFrame = await computeRevealedFrame(window: window, screen: screen, action: action) - let stashedFrame = await computeStashedFrame(window: window, screen: screen, action: action, peekSize: peekSize) + let revealedFrame = await WindowFrameResolver.getRevealedFrame(for: action, window: window, screen: screen) + let stashedFrame = await WindowFrameResolver.getStashedFrame(for: action, window: window, screen: screen, peekSize: peekSize) return StashedWindowInfo( window: window, @@ -33,12 +33,7 @@ struct StashedWindowInfo: Equatable { } func updatingStashedFrame(peekSize: CGFloat) async -> StashedWindowInfo { - let stashedFrame = await Self.computeStashedFrame( - window: window, - screen: screen, - action: action, - peekSize: peekSize - ) + let stashedFrame = await WindowFrameResolver.getStashedFrame(for: action, window: window, screen: screen, peekSize: peekSize) return StashedWindowInfo( window: window, @@ -57,41 +52,4 @@ struct StashedWindowInfo: Equatable { peekSize: peekSize ) } - - /// Computes the frame for a stashed window. - private static func computeStashedFrame(window: Window, screen: NSScreen, action: WindowAction, peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { - let bounds = screen.cgSafeScreenFrame - var frame = await WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) - - let minPeekSize: CGFloat = 1 - - switch action.stashEdge { - case .left, .right: - let maxPeekSize = frame.width * maxPeekPercent - let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) - - if action.stashEdge == .left { - frame.origin.x = bounds.minX - frame.width + clampedPeekSize - } else { - frame.origin.x = bounds.maxX - clampedPeekSize - } - - case .bottom: - let maxPeekSize = frame.height * maxPeekPercent - let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) - frame.origin.y = bounds.maxY - clampedPeekSize - - case .none: - break - } - - return frame - } - - private static func computeRevealedFrame(window: Window, screen: NSScreen, action: WindowAction) async -> CGRect { - let context = ResizeContext(window: window, screen: screen) - context.setAction(to: action, parent: nil) - await context.refreshResolvedState() - return context.getTargetFrame().padded - } } diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index c4054674..fd1974ec 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -68,7 +68,7 @@ final class RadialMenuViewModel: ObservableObject { } // Otherwise, default to the action's settings - return currentAction.direction.hasRadialMenuAngle != true || currentAction.direction.isCustomizable == true + return currentAction.direction.hasRadialMenuAngle != true || (currentAction.direction.isCustomizable == true && currentAction.direction != .stash) } var radialMenuImage: Image? { diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 8df21d0f..8af9f308 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -191,10 +191,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// - Parameter context: the resize context containing the pre-computed target frame. /// - Returns: the angle to show in the radial menu, or `nil` if the action does not have a radial menu angle. func radialMenuAngle(context: ResizeContext) -> Angle? { - guard - direction.frameMultiplyValues != nil, - direction.hasRadialMenuAngle - else { + guard direction.hasRadialMenuAngle else { return nil } diff --git a/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift index b6f37da4..bfb5ef69 100644 --- a/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift +++ b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift @@ -73,6 +73,56 @@ enum WindowFrameResolver { return (result, sidesToAdjust) } + + static func getRevealedFrame(resizeContext: ResizeContext) -> CGRect { + resizeContext.getTargetFrame().padded + } + + static func getRevealedFrame(for action: WindowAction, window: Window, screen: NSScreen) async -> CGRect { + let context = ResizeContext(window: window, screen: screen, action: action) + await context.refreshResolvedState() + return getRevealedFrame(resizeContext: context) + } + + static func getStashedFrame(for action: WindowAction, window: Window, screen: NSScreen, peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { + let bounds = screen.cgSafeScreenFrame + let revealedFrame = await getFrame(for: action, window: window, bounds: bounds) + + return getStashedFrame( + for: action, + revealedFrame: revealedFrame, + bounds: bounds, + peekSize: peekSize, + maxPeekPercent: maxPeekPercent + ) + } + + static func getStashedFrame(for action: WindowAction, revealedFrame: CGRect, bounds: CGRect, peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) -> CGRect { + var frame = revealedFrame + let minPeekSize: CGFloat = 1 + + switch action.stashEdge { + case .left, .right: + let maxPeekSize = frame.width * maxPeekPercent + let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) + + if action.stashEdge == .left { + frame.origin.x = bounds.minX - frame.width + clampedPeekSize + } else { + frame.origin.x = bounds.maxX - clampedPeekSize + } + + case .bottom: + let maxPeekSize = frame.height * maxPeekPercent + let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) + frame.origin.y = bounds.maxY - clampedPeekSize + + case .none: + break + } + + return frame + } } // MARK: - Calculators