diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 9701971e..9fde451c 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 { + 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..6faf7918 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,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(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 +68,7 @@ final class LoopManager { } }, checkIfLoopOpen: { [weak self] in - self?.isLoopActive ?? false + self?.isLoopActiveAtomic ?? false } ) @@ -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( @@ -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() { @@ -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 @@ -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. @@ -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 @@ -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 { @@ -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() @@ -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 @@ -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 @@ -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 @@ -327,7 +397,7 @@ extension LoopManager { height: currentFrame.height / adjustedBounds.height ) - resizeContext.setAction( + setResizeAction( to: .init( .custom, keybind: [], @@ -344,7 +414,7 @@ extension LoopManager { ) } } else { - resizeContext.setAction(to: .init(.center), parent: nil) + setResizeAction(to: .init(.center), parent: nil) } } @@ -352,7 +422,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 +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() } @@ -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. 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/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index 21ca900b..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) } } @@ -137,8 +141,8 @@ final class StashManager { return true } - func getRevealedFrameForStashedWindow(id: CGWindowID) -> CGRect? { - store.stashed[id]?.computeRevealedFrame() + func getRevealedFrameForStashedWindow(id: CGWindowID) async -> CGRect? { + 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 = 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 = 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 = 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 937948d8..539c6a53 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -14,42 +14,42 @@ struct StashedWindowInfo: Equatable { let window: Window let screen: NSScreen let action: WindowAction + let revealedFrame: CGRect + let stashedFrame: CGRect // MARK: - Frame computation - /// Computes the frame for a stashed window. - func computeStashedFrame(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 + static func create(window: Window, screen: NSScreen, action: WindowAction, peekSize: CGFloat) async -> StashedWindowInfo { + 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, + screen: screen, + action: action, + revealedFrame: revealedFrame, + stashedFrame: stashedFrame + ) + } - case .none: - log.warn("Trying to compute the stash frame for a non-stash related action.") - } + func updatingStashedFrame(peekSize: CGFloat) async -> StashedWindowInfo { + let stashedFrame = await WindowFrameResolver.getStashedFrame(for: action, window: window, screen: screen, peekSize: peekSize) - return frame + return StashedWindowInfo( + window: window, + screen: screen, + action: action, + revealedFrame: revealedFrame, + stashedFrame: stashedFrame + ) } - func computeRevealedFrame() -> CGRect { - let context = ResizeContext(window: window, screen: screen) - context.setAction(to: action, parent: nil) - return context.getTargetFrame().padded + func updatingFrames(screen: NSScreen, peekSize: CGFloat) async -> StashedWindowInfo { + await Self.create( + window: window, + screen: screen, + action: action, + peekSize: peekSize + ) } } 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/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 8631a5f8..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? @@ -21,25 +23,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 +35,7 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { self.runLoop = runLoop self.runLoopSource = runLoopSource CFRunLoopAddSource(runLoop, runLoopSource, .commonModes) + CFRunLoopWakeUp(runLoop) } } @@ -64,7 +53,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 +61,70 @@ 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 + let readableIdentifier = readableIdentifier + + self.eventTap = nil + self.runLoop = nil + self.runLoopSource = nil + isEnabled = false + + let cleanup = { + if let eventTap, CFMachPortIsValid(eventTap) { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + + if let runLoop, let runLoopSource, CFRunLoopSourceIsValid(runLoopSource) { + CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes) + } + + 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) + + 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.") + } + } } diff --git a/Loop/Utilities/Event Monitoring/EventTapThread.swift b/Loop/Utilities/Event Monitoring/EventTapThread.swift new file mode 100644 index 00000000..6718ecf0 --- /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: "\(Bundle.main.bundleID).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() + } +} 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 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..035f577a 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 } @@ -494,28 +496,27 @@ 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 - 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() } - } - if enhancedUI { - enhancedUserInterface = true + animation.start() } } }