diff --git a/Sources/DefiDaemon/DaemonCommands.swift b/Sources/DefiDaemon/DaemonCommands.swift index 257fc1d..8026213 100644 --- a/Sources/DefiDaemon/DaemonCommands.swift +++ b/Sources/DefiDaemon/DaemonCommands.swift @@ -202,6 +202,7 @@ extension Daemon { } func enqueueHotKey(_ invocation: HotKeyInvocation) { + guard desktopSessionActive else { return } guard pendingHotKeyCommands.count < 64 else { return } pendingHotKeyCommands.append(invocation) processPendingHotKeys() diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index f13a7a3..c9a7c4f 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -39,6 +39,8 @@ extension Daemon { forceApplicationInventoryRefresh: Bool = false, consumePeriodicWindowRefresh: Bool = false ) { + guard desktopSessionActive else { return } + let sessionGeneration = desktopSessionGeneration let nativeFocusWasPending = platform.hasPendingNativeFocusEvent if desktopSnapshotInFlight { supersededDesktopSnapshotRequest = ( @@ -56,7 +58,22 @@ extension Daemon { forceWindowListRefresh: forceWindowListRefresh, forceApplicationInventoryRefresh: forceApplicationInventoryRefresh ) { [weak self] snapshot in - self?.applyDesktopSnapshot( + guard let self else { return } + guard desktopSessionActive, + desktopSessionGeneration == sessionGeneration + else { + desktopSnapshotInFlight = false + supersededDesktopSnapshotRequest = nil + if desktopSessionActive { + synchronizeDesktop( + forceFullWindowRefresh: true, + forceWindowListRefresh: true, + forceApplicationInventoryRefresh: true + ) + } + return + } + applyDesktopSnapshot( snapshot, nativeFocusWasPending: nativeFocusWasPending, forceFullWindowRefresh: forceFullWindowRefresh, diff --git a/Sources/DefiDaemon/DaemonStatusLifecycle.swift b/Sources/DefiDaemon/DaemonStatusLifecycle.swift index 8b552d8..4bcacf2 100644 --- a/Sources/DefiDaemon/DaemonStatusLifecycle.swift +++ b/Sources/DefiDaemon/DaemonStatusLifecycle.swift @@ -168,7 +168,7 @@ extension Daemon { "\($0.id.rawValue):\(Int($0.frame.width))x\(Int($0.frame.height))" }.joined(separator: ",") return - "running monitors=\(state.monitors.count)[\(displaySizes)] windows=\(managedCount) floating=\(floatingCount) workspace=\(workspace) focused=\(focused) fullscreenSuspended=\(state.nativeFullscreenWindowIDs.count)[\(fullscreenWindowIDs)] columnWidth=\(focusedColumnState) menuBar=\(menuBar == nil ? "missing" : "installed") hotkeys=\(hotKeyState) bindings=\(bindingCount) captured=\(capturedHotKeyCount) processed=\(processedHotKeyCount) queued=\(pendingHotKeyCommands.count) tapReenables=\(tapReenableCount) overview=\(overviewOpen ? "open" : "closed") overviewPanels=\(overviewPanels) overviewPreviewConfig=\(config.overview.windowPreviews) overviewPermission=\(overviewPermission) overviewCaptures=\(overviewCaptures) overviewPreviews=\(overviewPreviews) overviewPreviewCacheMiB=\(overviewPreviewCacheMiB) overviewPreviewFailures=\(overviewPreviewFailures) events=\(observedPlatformEventCount) focusDedup=\(ignoredRedundantNativeFocusCount) closeFocusPreserved=\(preservedWindowRemovalFocusCount) displayEvents=\(displayConfigurationEventCount) displayRetries=\(pendingDisplaySyncDeadlines.count) drift=\(targetMismatchCount)[\(driftDetails)] resize=\(resize) visibility=\(visibility) hidden=\(platform.hiddenWindowCount) borders=\(borders.visible) borderNodes=\(borders.allocated) borderDormant=\(borders.dormant) borderOpacity=\(borderOpacity) borderSurfaceMiB=\(borderSurfaceMiB) borderCapture=\(borders.captureEnabled) borderPlans=\(borders.appliedPlans) borderSkips=\(borders.skippedPlans) borderGeometry=\(borders.geometryUpdates) snapshots=\(snapshotPerformance.full)/\(snapshotPerformance.incremental)/\(snapshotPerformance.cached) appInventories=\(snapshotPerformance.applicationInventories) snapshotMs=\(snapshotMS) snapshotMaxMs=\(snapshotMaxMS) snapshotCG=\(snapshotPerformance.cgCopies)/\(snapshotCGMS)/\(snapshotCGMaxMS) axReads=\(attributeReads.batched)/\(attributeReads.fallback) parkingChecks=\(parking.checks) parkingRepairs=\(parking.repairs) initialChecks=\(initialSettlement.checks) initialRepairs=\(initialSettlement.repairs) settling=\(frameCommit.settling) deferredCommits=\(frameCommit.deferred) observedCommits=\(frameCommit.observed) observedCommitMaxMs=\(observedCommitMaxMS) slowApps=\(platform.latencySensitiveProcessCount) slowAppDetails=[\(platform.latencySensitiveProcessDescription)] axAppDetails=[\(platform.processLatencyDescription)] posWrites=\(platform.successfulPositionWriteCount) stalePos=\(platform.skippedStalePositionWriteCount) droppedFrames=\(platform.droppedPositionFrameCount) displayedRebases=\(displayedFrameRebaseCount) displayedDelta=\(displayedRebaseDelta) sizeWrites=\(platform.successfulSizeWriteCount) displayHz=\(displayHz) timerHz=\(timerHz) axPending=\(platform.hasPendingAnimatedFrameWrites) axFrameMs=\(axFrameMS) axFrameMaxMs=\(axFrameMaxMS) axSlowFrames=\(axFramePerformance.slowFrames) focusPending=\(platform.hasPendingFocusWrite) focusFast=\(focusPerformance.fastPaths) focusCancelled=\(focusPerformance.cancelled) focusRetries=\(focusPerformance.retries) focusMainMs=\(focusMainMS) focusRaiseMs=\(focusRaiseMS) focusActivateMs=\(focusActivateMS) animating=\(platform.hasPendingAnimatedFrameWrites) animationFrames=\(axFramePerformance.animationFrames) animationMs=\(coordinatorAnimationMS) commandMs=\(commandMS) frameMs=\(frameMS) focusMs=\(focusMS)" + "running session=\(desktopSessionActive ? "active" : "inactive") monitors=\(state.monitors.count)[\(displaySizes)] windows=\(managedCount) floating=\(floatingCount) workspace=\(workspace) focused=\(focused) fullscreenSuspended=\(state.nativeFullscreenWindowIDs.count)[\(fullscreenWindowIDs)] columnWidth=\(focusedColumnState) menuBar=\(menuBar == nil ? "missing" : "installed") hotkeys=\(hotKeyState) bindings=\(bindingCount) captured=\(capturedHotKeyCount) processed=\(processedHotKeyCount) queued=\(pendingHotKeyCommands.count) tapReenables=\(tapReenableCount) overview=\(overviewOpen ? "open" : "closed") overviewPanels=\(overviewPanels) overviewPreviewConfig=\(config.overview.windowPreviews) overviewPermission=\(overviewPermission) overviewCaptures=\(overviewCaptures) overviewPreviews=\(overviewPreviews) overviewPreviewCacheMiB=\(overviewPreviewCacheMiB) overviewPreviewFailures=\(overviewPreviewFailures) events=\(observedPlatformEventCount) focusDedup=\(ignoredRedundantNativeFocusCount) closeFocusPreserved=\(preservedWindowRemovalFocusCount) displayEvents=\(displayConfigurationEventCount) displayRetries=\(pendingDisplaySyncDeadlines.count) drift=\(targetMismatchCount)[\(driftDetails)] resize=\(resize) visibility=\(visibility) hidden=\(platform.hiddenWindowCount) borders=\(borders.visible) borderNodes=\(borders.allocated) borderDormant=\(borders.dormant) borderOpacity=\(borderOpacity) borderSurfaceMiB=\(borderSurfaceMiB) borderCapture=\(borders.captureEnabled) borderPlans=\(borders.appliedPlans) borderSkips=\(borders.skippedPlans) borderGeometry=\(borders.geometryUpdates) snapshots=\(snapshotPerformance.full)/\(snapshotPerformance.incremental)/\(snapshotPerformance.cached) appInventories=\(snapshotPerformance.applicationInventories) snapshotMs=\(snapshotMS) snapshotMaxMs=\(snapshotMaxMS) snapshotCG=\(snapshotPerformance.cgCopies)/\(snapshotCGMS)/\(snapshotCGMaxMS) axReads=\(attributeReads.batched)/\(attributeReads.fallback) parkingChecks=\(parking.checks) parkingRepairs=\(parking.repairs) initialChecks=\(initialSettlement.checks) initialRepairs=\(initialSettlement.repairs) settling=\(frameCommit.settling) deferredCommits=\(frameCommit.deferred) observedCommits=\(frameCommit.observed) observedCommitMaxMs=\(observedCommitMaxMS) slowApps=\(platform.latencySensitiveProcessCount) slowAppDetails=[\(platform.latencySensitiveProcessDescription)] axAppDetails=[\(platform.processLatencyDescription)] posWrites=\(platform.successfulPositionWriteCount) stalePos=\(platform.skippedStalePositionWriteCount) droppedFrames=\(platform.droppedPositionFrameCount) displayedRebases=\(displayedFrameRebaseCount) displayedDelta=\(displayedRebaseDelta) sizeWrites=\(platform.successfulSizeWriteCount) displayHz=\(displayHz) timerHz=\(timerHz) axPending=\(platform.hasPendingAnimatedFrameWrites) axFrameMs=\(axFrameMS) axFrameMaxMs=\(axFrameMaxMS) axSlowFrames=\(axFramePerformance.slowFrames) focusPending=\(platform.hasPendingFocusWrite) focusFast=\(focusPerformance.fastPaths) focusCancelled=\(focusPerformance.cancelled) focusRetries=\(focusPerformance.retries) focusMainMs=\(focusMainMS) focusRaiseMs=\(focusRaiseMS) focusActivateMs=\(focusActivateMS) animating=\(platform.hasPendingAnimatedFrameWrites) animationFrames=\(axFramePerformance.animationFrames) animationMs=\(coordinatorAnimationMS) commandMs=\(commandMS) frameMs=\(frameMS) focusMs=\(focusMS)" + " topologyObservers=\(platform.hasReliableWindowTopologyObservation) appWindowLists=\(snapshotPerformance.applicationWindowListReads)" + " appLifecycleObservers=\(platform.hasReliableApplicationLifecycleObservation) appInventoryInterval=\(Int(platform.recommendedApplicationInventoryRefreshInterval))" + " desktopObservers=\(platform.hasReliableDesktopObservation)" diff --git a/Sources/DefiDaemon/DefiDaemon.swift b/Sources/DefiDaemon/DefiDaemon.swift index db34d77..d5896a4 100644 --- a/Sources/DefiDaemon/DefiDaemon.swift +++ b/Sources/DefiDaemon/DefiDaemon.swift @@ -206,6 +206,8 @@ final class Daemon: NSObject { var processingHotKeyCommands = false var processedHotKeyCount = 0 var needsDesktopSync = true + var desktopSessionActive = true + var desktopSessionGeneration: UInt64 = 0 var desktopSnapshotInFlight = false var supersededDesktopSnapshotRequest: ( forceFullWindowRefresh: Bool, @@ -309,17 +311,24 @@ final class Daemon: NSObject { } platform.startObserving( { [weak self] in - self?.observedPlatformEventCount += 1 - self?.needsDesktopSync = true - self?.scheduleTick() + guard let self, desktopSessionActive else { return } + observedPlatformEventCount += 1 + needsDesktopSync = true + scheduleTick() + }, + desktopSessionHandler: { [weak self] active in + self?.handleDesktopSessionActivity(active) }, displayConfigurationHandler: { [weak self] in + guard self?.desktopSessionActive == true else { return } self?.scheduleDisplayReconciliation() }, mouseGestureStartedHandler: { [weak self] in + guard self?.desktopSessionActive == true else { return } self?.beginMouseGesture() }, mouseGestureHandler: { [weak self] in + guard self?.desktopSessionActive == true else { return } self?.cancelAnimationForMouseGesture() } ) @@ -329,12 +338,14 @@ final class Daemon: NSObject { userInputTracker: platform.userInputTracker, pointerMotionTracker: platform.pointerMotionTracker, pointerMotionHandler: { [weak self] invocation in + guard self?.desktopSessionActive == true else { return } self?.handlePointerMotion(invocation) }, tapReenabledHandler: { [weak self] timestamp in self?.handleEventTapReenabled(at: timestamp) }, overviewHandler: { [weak self] action in + guard self?.desktopSessionActive == true else { return } self?.overviewController?.handleKey(action) } ) { [weak self] invocation in @@ -374,7 +385,47 @@ final class Daemon: NSObject { NSApplication.shared.run() } + func handleDesktopSessionActivity(_ active: Bool) { + guard desktopSessionActive != active else { return } + desktopSessionActive = active + desktopSessionGeneration &+= 1 + guard active else { + commandGeneration &+= 1 + mouseGestureGeneration &+= 1 + pendingHotKeyCommands.removeAll(keepingCapacity: true) + pendingAnimatedFocus = nil + invalidateSubmittedCommandFocus() + pendingWorkspaceFocus = nil + invalidateSubmittedWorkspaceFocus() + displacedPointerFocusRecovery = nil + invalidatePointerFocusIntent() + scrollAnimations.removeAll(keepingCapacity: true) + finishMouseGestureTracking() + mouseReorderAnimationActive = false + frameNotificationsSuspended = false + pendingDisplaySyncDeadlines.removeAll(keepingCapacity: true) + supersededDesktopSnapshotRequest = nil + needsDesktopSync = false + followUpTickSignature = nil + followUpBackoffSteps = 0 + platform.invalidateStateForDesktopSessionChange() + setTimerFrequency(2) + log("desktop session inactive") + return + } + + needsDesktopSync = true + synchronizeDesktop( + forceFullWindowRefresh: true, + forceWindowListRefresh: true, + forceApplicationInventoryRefresh: true + ) + scheduleTick() + log("desktop session active; refreshing desktop") + } + func tick() { + guard desktopSessionActive else { return } processPendingHotKeys() finishPendingAnimatedFocusIfReady() finishPendingWorkspaceFocusIfReady() diff --git a/Sources/DefiMacOS/HotKeys.swift b/Sources/DefiMacOS/HotKeys.swift index 35afae6..72d972c 100644 --- a/Sources/DefiMacOS/HotKeys.swift +++ b/Sources/DefiMacOS/HotKeys.swift @@ -3,6 +3,8 @@ import DefiConfig import DefiModel import Foundation +let hotKeyEventTapPlacement = CGEventTapPlacement.tailAppendEventTap + @MainActor public final class HotKeyManager { public typealias Handler = @MainActor @Sendable (HotKeyInvocation) -> Void @@ -148,7 +150,7 @@ public final class HotKeyManager { guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, - place: .headInsertEventTap, + place: hotKeyEventTapPlacement, options: .defaultTap, eventsOfInterest: mask, callback: callback, @@ -160,6 +162,7 @@ public final class HotKeyManager { guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { CGEvent.tapEnable(tap: tap, enable: false) + CFMachPortInvalidate(tap) throw HotKeyError.eventTapUnavailable } context.install(tap: tap, source: source) @@ -190,7 +193,7 @@ public final class HotKeyManager { } } -private final class HotKeyTapContext: @unchecked Sendable { +final class HotKeyTapContext: @unchecked Sendable { private static let commandTabKeyCode = CGKeyCode(48) private static let closeWindowKeyCodes: Set = [12, 13] @@ -495,6 +498,7 @@ private final class HotKeyTapContext: @unchecked Sendable { guard let runLoop else { if let tap { CGEvent.tapEnable(tap: tap, enable: false) + CFMachPortInvalidate(tap) } return } @@ -504,6 +508,7 @@ private final class HotKeyTapContext: @unchecked Sendable { } if let tap { CGEvent.tapEnable(tap: tap, enable: false) + CFMachPortInvalidate(tap) } CFRunLoopStop(runLoop) } diff --git a/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift b/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift index 18d15a1..f92c4da 100644 --- a/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift +++ b/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift @@ -14,6 +14,13 @@ extension MacOSPlatform { clearFrameState() } + public func invalidateStateForDesktopSessionChange() { + invalidatePreparedAXWindowAttributes() + frameCoordinator.invalidate(reason: "desktop-session-change") + clearFrameState() + invalidateFocusStateForDisplayChange() + } + public func cancelPendingFrameWrites() { frameCoordinator.invalidate(reason: "mouse-gesture") } diff --git a/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift b/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift index 9b33d84..c00b215 100644 --- a/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift +++ b/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift @@ -22,6 +22,7 @@ extension MacOSPlatform { public func startObserving( _ handler: @escaping () -> Void, + desktopSessionHandler: @escaping (Bool) -> Void = { _ in }, displayConfigurationHandler: @escaping () -> Void = {}, mouseGestureStartedHandler: @escaping () -> Void = {}, mouseGestureHandler: @escaping () -> Void = {} @@ -139,6 +140,9 @@ extension MacOSPlatform { handler() }, userInputTracker: userInputTracker, + desktopSessionHandler: { change in + desktopSessionHandler(change == .becameActive) + }, frameHandler: { [weak self] element in guard let self else { return } self.refreshWindowBorderGeometry(for: element) diff --git a/Sources/DefiMacOS/PlatformEventMonitor.swift b/Sources/DefiMacOS/PlatformEventMonitor.swift index 436ffeb..851e6d7 100644 --- a/Sources/DefiMacOS/PlatformEventMonitor.swift +++ b/Sources/DefiMacOS/PlatformEventMonitor.swift @@ -7,12 +7,15 @@ import DefiModel final class PlatformEventMonitor { let handler: (PlatformEventKind, pid_t?) -> Void private let userInputTracker: UserInputTracker + private let desktopSessionHandler: (DesktopSessionActivityChange) -> Void + private let desktopSessionStateProvider: () -> DesktopSessionState private let frameHandler: (AXUIElement) -> Void private let liveFrameHandler: () -> Void private let borderStackingHandler: () -> Void private let mouseGestureStartedHandler: () -> Void private let windowDestroyedHandler: (AXUIElement) -> Void private var workspaceTokens: [NSObjectProtocol] = [] + private var sessionTokens: [NSObjectProtocol] = [] private var screenTokens: [NSObjectProtocol] = [] private var mouseMonitor: Any? private var mouseGestureNormalizer = MouseGestureEventNormalizer() @@ -28,10 +31,15 @@ final class PlatformEventMonitor { private var suppressedFrameProcessIDs = Set() private var suppressedFrameRequiresFullSnapshot = false private var displayCallbackRegistered = false + private var desktopSessionNormalizer = DesktopSessionEventNormalizer() + private var desktopSessionActive = true init( handler: @escaping (PlatformEventKind, pid_t?) -> Void, userInputTracker: UserInputTracker = UserInputTracker(), + desktopSessionHandler: @escaping (DesktopSessionActivityChange) -> Void = { _ in }, + desktopSessionStateProvider: @escaping () -> DesktopSessionState = + currentDesktopSessionState, frameHandler: @escaping (AXUIElement) -> Void = { _ in }, liveFrameHandler: @escaping () -> Void = {}, borderStackingHandler: @escaping () -> Void = {}, @@ -40,6 +48,8 @@ final class PlatformEventMonitor { ) { self.handler = handler self.userInputTracker = userInputTracker + self.desktopSessionHandler = desktopSessionHandler + self.desktopSessionStateProvider = desktopSessionStateProvider self.frameHandler = frameHandler self.liveFrameHandler = liveFrameHandler self.borderStackingHandler = borderStackingHandler @@ -64,6 +74,39 @@ final class PlatformEventMonitor { } } ) + for (name, event) in [ + (NSWorkspace.willSleepNotification, DesktopSessionEvent.willSleep), + (NSWorkspace.didWakeNotification, .didWake), + (NSWorkspace.screensDidSleepNotification, .screensDidSleep), + (NSWorkspace.screensDidWakeNotification, .screensDidWake), + (NSWorkspace.sessionDidResignActiveNotification, .sessionDidResignActive), + (NSWorkspace.sessionDidBecomeActiveNotification, .sessionDidBecomeActive), + ] { + sessionTokens.append( + center.addObserver(forName: name, object: nil, queue: .main) { + [weak self] _ in + MainActor.assumeIsolated { + guard let self, + let change = self.desktopSessionNormalizer.consume(event) + else { return } + self.desktopSessionActive = change == .becameActive + if !self.desktopSessionActive { + self.resetAccessibilityObservers() + } + self.desktopSessionHandler(change) + } + } + ) + } + let initialDesktopSessionState = desktopSessionStateProvider() + desktopSessionNormalizer = DesktopSessionEventNormalizer( + initialState: initialDesktopSessionState + ) + desktopSessionActive = initialDesktopSessionState.isActive + if !desktopSessionActive { + resetAccessibilityObservers() + desktopSessionHandler(.becameInactive) + } workspaceTokens.append( center.addObserver( forName: NSWorkspace.activeSpaceDidChangeNotification, @@ -134,6 +177,7 @@ final class PlatformEventMonitor { ) { [weak self] event in MainActor.assumeIsolated { guard let self else { return } + guard self.desktopSessionActive else { return } if eventStartsMouseFocusInteraction(event.type) { let rawWindowID = event.cgEvent?.getIntegerValueField( @@ -184,6 +228,7 @@ final class PlatformEventMonitor { requiredFrameWindows: [pid_t: [AXUIElement]]? = nil, transientGeometryWindows: [pid_t: [AXUIElement]] = [:] ) { + guard desktopSessionActive else { return } let activeProcessIDs = Set(applications.keys) notificationObservationFailureCounts = updatedNotificationObservationFailureCounts( notificationObservationFailureCounts, @@ -422,6 +467,26 @@ final class PlatformEventMonitor { } } + func resetAccessibilityObservers() { + for observer in observers.values { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) + } + observers.removeAll(keepingCapacity: true) + topologyObservedProcessIDs.removeAll(keepingCapacity: true) + observedWindows.removeAll(keepingCapacity: true) + topologyRequiredWindows.removeAll(keepingCapacity: true) + frameRequiredWindows.removeAll(keepingCapacity: true) + topologyObservedWindows.removeAll(keepingCapacity: true) + frameObservedWindows.removeAll(keepingCapacity: true) + frameNotificationsEnabled = true + suppressedFrameProcessIDs.removeAll(keepingCapacity: true) + suppressedFrameRequiresFullSnapshot = false + } + private func observer(for processID: pid_t) -> AXObserver? { if let observer = observers[processID] { return observer @@ -541,6 +606,10 @@ final class PlatformEventMonitor { center.removeObserver(token) } workspaceTokens.removeAll() + for token in sessionTokens { + center.removeObserver(token) + } + sessionTokens.removeAll() for token in screenTokens { NotificationCenter.default.removeObserver(token) } @@ -549,5 +618,6 @@ final class PlatformEventMonitor { NSEvent.removeMonitor(mouseMonitor) self.mouseMonitor = nil } + resetAccessibilityObservers() } } diff --git a/Sources/DefiMacOS/PlatformEventNormalization.swift b/Sources/DefiMacOS/PlatformEventNormalization.swift index e01eb5b..a95a381 100644 --- a/Sources/DefiMacOS/PlatformEventNormalization.swift +++ b/Sources/DefiMacOS/PlatformEventNormalization.swift @@ -15,6 +15,94 @@ enum PlatformEventKind: Equatable { case space } +enum DesktopSessionEvent { + case willSleep + case didWake + case screensDidSleep + case screensDidWake + case sessionDidResignActive + case sessionDidBecomeActive +} + +enum DesktopSessionActivityChange: Equatable { + case becameInactive + case becameActive +} + +struct DesktopSessionState: Equatable { + var systemAwake: Bool + var screensAwake: Bool + var sessionActive: Bool + + static let active = DesktopSessionState( + systemAwake: true, + screensAwake: true, + sessionActive: true + ) + + var isActive: Bool { + systemAwake && screensAwake && sessionActive + } +} + +struct DesktopSessionEventNormalizer { + private var state: DesktopSessionState + + init(initialState: DesktopSessionState = .active) { + state = initialState + } + + mutating func consume( + _ event: DesktopSessionEvent + ) -> DesktopSessionActivityChange? { + let wasActive = state.isActive + switch event { + case .willSleep: + state.systemAwake = false + case .didWake: + state.systemAwake = true + case .screensDidSleep: + state.screensAwake = false + case .screensDidWake: + state.screensAwake = true + case .sessionDidResignActive: + state.sessionActive = false + case .sessionDidBecomeActive: + state.sessionActive = true + } + guard state.isActive != wasActive else { return nil } + return state.isActive ? .becameActive : .becameInactive + } +} + +func currentDesktopSessionState() -> DesktopSessionState { + let session = CGSessionCopyCurrentDictionary() as NSDictionary? + let sessionActive = + (session?[kCGSessionOnConsoleKey] as? Bool ?? true) + && (session?[kCGSessionLoginDoneKey] as? Bool ?? true) + + var displayCount: UInt32 = 0 + var screensAwake = true + if CGGetOnlineDisplayList(0, nil, &displayCount) == .success { + var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(displayCount)) + if CGGetOnlineDisplayList(displayCount, &displayIDs, &displayCount) + == .success + { + screensAwake = + displayCount == 0 + || displayIDs.prefix(Int(displayCount)).contains { + CGDisplayIsAsleep($0) == 0 + } + } + } + + return DesktopSessionState( + systemAwake: true, + screensAwake: screensAwake, + sessionActive: sessionActive + ) +} + func platformEventCancelsMouseAnimation(_ kind: PlatformEventKind) -> Bool { kind == .mouse } diff --git a/Tests/DefiMacOSTests/HotKeyTests.swift b/Tests/DefiMacOSTests/HotKeyTests.swift index edfd844..9630aee 100644 --- a/Tests/DefiMacOSTests/HotKeyTests.swift +++ b/Tests/DefiMacOSTests/HotKeyTests.swift @@ -1,8 +1,21 @@ import ApplicationServices import DefiConfig +import Synchronization import Testing @testable import DefiMacOS +private final class HotKeyInvocationRecorder: Sendable { + private let invocations = Mutex<[HotKeyInvocation]>([]) + + func append(_ invocation: HotKeyInvocation) { + invocations.withLock { $0.append(invocation) } + } + + var commands: [String] { + invocations.withLock { $0.map(\.command) } + } +} + @Suite struct OverviewHotKeyTests { @Test @@ -61,6 +74,25 @@ struct HotKeyTests { "hyper": "Alt + Cmd + Ctrl" ] + private func makeContext( + bindings: [Key: String] + ) -> (HotKeyTapContext, HotKeyInvocationRecorder) { + let invocations = HotKeyInvocationRecorder() + return ( + HotKeyTapContext( + bindings: bindings, + userInputTracker: UserInputTracker(), + pointerMotionTracker: PointerMotionTracker(), + tracksPointerWindowTransitions: false, + deliver: { invocations.append($0) }, + deliverOverview: { _ in }, + deliverPointerMotion: { _ in }, + tapReenabled: { _ in } + ), + invocations + ) + } + @Test(arguments: [ ("hyper-left", CGKeyCode(123), false), ("hyper-shift-1", CGKeyCode(18), true), @@ -103,6 +135,87 @@ struct HotKeyTests { #expect(manager.bindingError == .invalidAccelerator("unknown-no-such-key")) #expect(manager.tracksPointerMotion) } + + @Test + func `Filtering tap runs after remappers`() { + #expect(hotKeyEventTapPlacement == .tailAppendEventTap) + } + + @Test + func `Captured hyper shortcut never reaches the foreground application`() throws { + let hyper = CGEventFlags([ + .maskAlternate, + .maskCommand, + .maskControl, + ]) + let boundKey = try Key(accelerator: "hyper-1", aliases: aliases) + let (context, invocations) = makeContext( + bindings: [boundKey: "workspace 1"] + ) + let event = try #require( + CGEvent(keyboardEventSource: nil, virtualKey: 18, keyDown: true) + ) + event.flags = hyper + + let forwardedEvent = context.handle(type: .keyDown, event: event) + + #expect(forwardedEvent == nil) + #expect(invocations.commands == ["workspace 1"]) + + let modifierRelease = try #require( + CGEvent(keyboardEventSource: nil, virtualKey: 59, keyDown: false) + ) + modifierRelease.type = .flagsChanged + modifierRelease.flags = [.maskAlternate, .maskCommand] + + #expect( + context.handle(type: .flagsChanged, event: modifierRelease)? + .takeUnretainedValue() === modifierRelease + ) + } + + @Test + func `Bare command shortcut reaches the foreground application unchanged`() throws { + let (context, invocations) = makeContext( + bindings: [ + try Key(accelerator: "hyper-1", aliases: aliases): "workspace 1" + ] + ) + let event = try #require( + CGEvent(keyboardEventSource: nil, virtualKey: 18, keyDown: true) + ) + event.flags = [.maskCommand] + + let forwardedEvent = context.handle(type: .keyDown, event: event) + + #expect(forwardedEvent?.takeUnretainedValue() === event) + #expect(event.flags == [.maskCommand]) + #expect(invocations.commands.isEmpty) + } + + @Test + func `Unbound hyper shortcut is not downgraded to a command shortcut`() throws { + let hyper = CGEventFlags([ + .maskAlternate, + .maskCommand, + .maskControl, + ]) + let (context, invocations) = makeContext( + bindings: [ + try Key(accelerator: "hyper-1", aliases: aliases): "workspace 1" + ] + ) + let event = try #require( + CGEvent(keyboardEventSource: nil, virtualKey: 19, keyDown: true) + ) + event.flags = hyper + + let forwardedEvent = context.handle(type: .keyDown, event: event) + + #expect(forwardedEvent?.takeUnretainedValue() === event) + #expect(event.flags == hyper) + #expect(invocations.commands.isEmpty) + } } struct HotKeyModifierReleaseTests { diff --git a/Tests/DefiMacOSTests/PlatformEventTests.swift b/Tests/DefiMacOSTests/PlatformEventTests.swift index 3b1263f..1d3eabd 100644 --- a/Tests/DefiMacOSTests/PlatformEventTests.swift +++ b/Tests/DefiMacOSTests/PlatformEventTests.swift @@ -15,6 +15,74 @@ private final class TestAXElement: @unchecked Sendable { } struct PlatformEventTests { + @Test + func desktopSessionOnlyReactivatesAfterEveryWakeSignal() { + var normalizer = DesktopSessionEventNormalizer() + + #expect(normalizer.consume(.sessionDidResignActive) == .becameInactive) + #expect(normalizer.consume(.screensDidSleep) == nil) + #expect(normalizer.consume(.screensDidWake) == nil) + #expect(normalizer.consume(.sessionDidBecomeActive) == .becameActive) + #expect(normalizer.consume(.sessionDidBecomeActive) == nil) + } + + @Test + func systemWakeDoesNotReactivateBeforeSleepingScreensWake() { + var normalizer = DesktopSessionEventNormalizer() + + #expect(normalizer.consume(.willSleep) == .becameInactive) + #expect(normalizer.consume(.screensDidSleep) == nil) + #expect(normalizer.consume(.didWake) == nil) + #expect(normalizer.consume(.screensDidWake) == .becameActive) + } + + @Test @MainActor + func workspaceSessionNotificationsUseTheNormalizedSequence() { + var changes: [DesktopSessionActivityChange] = [] + let monitor = PlatformEventMonitor( + handler: { _, _ in }, + desktopSessionHandler: { changes.append($0) }, + desktopSessionStateProvider: { .active } + ) + monitor.start() + defer { monitor.stop() } + + let center = NSWorkspace.shared.notificationCenter + center.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + center.post(name: NSWorkspace.screensDidSleepNotification, object: nil) + center.post(name: NSWorkspace.screensDidWakeNotification, object: nil) + center.post(name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) + + #expect(changes == [.becameInactive, .becameActive]) + } + + @Test @MainActor + func inactiveStartupPausesUntilTheMissingWakeSignalArrives() { + var changes: [DesktopSessionActivityChange] = [] + let monitor = PlatformEventMonitor( + handler: { _, _ in }, + desktopSessionHandler: { changes.append($0) }, + desktopSessionStateProvider: { + DesktopSessionState( + systemAwake: true, + screensAwake: false, + sessionActive: true + ) + } + ) + monitor.start() + defer { monitor.stop() } + + #expect(changes == [.becameInactive]) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.screensDidWakeNotification, + object: nil + ) + + #expect(changes == [.becameInactive, .becameActive]) + } + @Test func preparedAXRelationshipsResolveTransientOwners() { let ownerID = WindowID(rawValue: 1) diff --git a/script/build_and_run.sh b/script/build_and_run.sh index d484285..4f338ea 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -26,8 +26,12 @@ SERVICE_DOMAIN="gui/$(id -u)" cd "$ROOT_DIR" -swift build -BIN_DIR="$(swift build --show-bin-path)" +BUILD_CONFIGURATION="release" +if [[ "$MODE" == "--debug" || "$MODE" == "debug" ]]; then + BUILD_CONFIGURATION="debug" +fi +swift build -c "$BUILD_CONFIGURATION" +BIN_DIR="$(swift build -c "$BUILD_CONFIGURATION" --show-bin-path)" rm -rf "$STAGING_BUNDLE" mkdir -p "$APP_MACOS"