diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e43834..2fb9f46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ no SDK needed). ## Running the checks -Build both platforms if you can; CI builds macOS and Windows on every PR. There is no unit-test suite (the app is a thin shell over OS window APIs); manual checks are described in each platform README. +Build both platforms if you can; CI builds macOS and Windows on every PR. Run the macOS fullscreen regression checks with `bash macos/test.sh`. Manual checks are described in each platform README. ## Ground rules diff --git a/macos/README.md b/macos/README.md index 2e09489..a4972d6 100644 --- a/macos/README.md +++ b/macos/README.md @@ -123,6 +123,21 @@ build.sh build + bundle + sign (+ --install --run) between the front app's windows stay bright. - Rebuilding requires re-granting Accessibility (ad-hoc signature changes). +## Fullscreen regression checks + +Run `bash macos/test.sh` to check fullscreen detection, display selection, and +that the overlay cannot join a native fullscreen Space as an auxiliary window. +Overlay lifecycle checks require a logged-in macOS graphical session. +These checks do not replace a real window-server test. + +After building, check the following manually with Accessibility enabled: + +1. A normal or maximized window stays bright and its background is dimmed. +2. Enter native fullscreen with the green button: the fullscreen window stays bright. +3. Exit fullscreen: background dimming resumes. +4. Switch repeatedly between a dimmed desktop and a fullscreen app. Fullscreen + must appear bright immediately, without a dim-then-fade-out transition. + ## Uninstall Quit from the menu bar, delete `~/Applications/OpenHaze.app`, and remove the diff --git a/macos/Sources/OpenHaze/FocusTracker.swift b/macos/Sources/OpenHaze/FocusTracker.swift index 24cc723..f0f1754 100644 --- a/macos/Sources/OpenHaze/FocusTracker.swift +++ b/macos/Sources/OpenHaze/FocusTracker.swift @@ -15,6 +15,7 @@ func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePoin /// - Without: the engine's poll timer picks up changes from the window list. final class FocusTracker { var onChange: (() -> Void)? + var onSpaceChange: (() -> Void)? var onFnChanged: ((Bool) -> Void)? private(set) var fnHeld = false @@ -39,13 +40,17 @@ final class FocusTracker { NSWorkspace.didActivateApplicationNotification, NSWorkspace.didHideApplicationNotification, NSWorkspace.didUnhideApplicationNotification, - NSWorkspace.activeSpaceDidChangeNotification, ] for name in names { nc.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.frontMayHaveChanged() } } + nc.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, queue: .main) { [weak self] _ in + self?.onSpaceChange?() + self?.frontMayHaveChanged() + } nc.addObserver(forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main) { [weak self] note in guard let self else { return } @@ -118,8 +123,9 @@ final class FocusTracker { appElement = nil } - /// CGWindowID of the focused window of the front app, if Accessibility can tell us. - func focusedWindowID() -> CGWindowID? { + /// Read the window ID and native fullscreen state from the same AX window. + /// The ID can be unavailable while switching Spaces; fullscreen is still useful. + func focusedWindow() -> (id: CGWindowID?, isFullscreen: Bool)? { guard AXIsProcessTrusted() else { return nil } let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier ?? -1 guard pid > 0 else { return nil } @@ -129,9 +135,13 @@ final class FocusTracker { let value, CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } let windowEl = unsafeDowncast(value as AnyObject, to: AXUIElement.self) + var fullscreenValue: CFTypeRef? + let isFullscreen = AXUIElementCopyAttributeValue(windowEl, "AXFullScreen" as CFString, + &fullscreenValue) == .success + && (fullscreenValue as? Bool == true) var windowID: CGWindowID = 0 - guard _AXUIElementGetWindow(windowEl, &windowID) == .success, windowID != 0 else { return nil } - return windowID + let hasID = _AXUIElementGetWindow(windowEl, &windowID) == .success && windowID != 0 + return (hasID ? windowID : nil, isFullscreen) } } diff --git a/macos/Sources/OpenHaze/FullscreenDetection.swift b/macos/Sources/OpenHaze/FullscreenDetection.swift new file mode 100644 index 0000000..a5fb0ff --- /dev/null +++ b/macos/Sources/OpenHaze/FullscreenDetection.swift @@ -0,0 +1,42 @@ +import AppKit + +/// Keep fullscreen detection independent of the window's compositing layer. +/// Native fullscreen windows can remain at the ordinary layer (0). +enum FullscreenDetection { + static func screenIDs(windows: [WindowInfo], frontPID: pid_t?, + focusedWindowID: CGWindowID?, focusedWindowIsFullscreen: Bool, + screens: [CGDirectDisplayID: CGRect], + excluding excluded: Set) -> Set { + guard let pid = frontPID else { return [] } + // Preserve the window-list fallback when Accessibility is unavailable. + var result = Set(screens.compactMap { id, frame in + let covering = windows.contains { + $0.pid == pid && $0.layer != 0 && !excluded.contains($0.id) && + area($0.bounds.intersection(frame)) >= area(frame) * 0.9 + } + return covering ? id : nil + }) + if focusedWindowIsFullscreen { + // Prefer the exact AX window. Its CG layer can still be zero. + let focused = windows.first { + $0.id == focusedWindowID && $0.pid == pid && !excluded.contains($0.id) + } ?? windows.first { + $0.pid == pid && $0.layer == 0 && $0.alpha > 0.01 && !excluded.contains($0.id) + } + if let focused, + let screen = screens.max(by: { + area(focused.bounds.intersection($0.value)) < area(focused.bounds.intersection($1.value)) + }), area(focused.bounds.intersection(screen.value)) > 0 { + result.insert(screen.key) + } else if screens.count == 1, let id = screens.keys.first { + // CGWindowList may briefly omit the window during a Space transition. + result.insert(id) + } + } + return result + } + + private static func area(_ rect: CGRect) -> CGFloat { + max(0, rect.width) * max(0, rect.height) + } +} diff --git a/macos/Sources/OpenHaze/HazeEngine.swift b/macos/Sources/OpenHaze/HazeEngine.swift index e19fbd1..f92454c 100644 --- a/macos/Sources/OpenHaze/HazeEngine.swift +++ b/macos/Sources/OpenHaze/HazeEngine.swift @@ -4,6 +4,7 @@ import Combine /// What a given display's haze should be doing right now. enum ScreenPlan: Equatable { case hidden // no dimming on this display + case fullscreen // remove haze immediately; never fade over fullscreen case fullDim // dim everything on this display (inactive display / desktop mode) case below(CGWindowID) // dim everything behind this window } @@ -41,6 +42,18 @@ final class ScreenState { overlayA.orderOut(nil) overlayB.orderOut(nil) } + + /// A Space switch must not carry the previous desktop's haze into fullscreen. + /// Invalidate both crossfade completions and remove both windows synchronously. + func suspendImmediately(plan newPlan: ScreenPlan = .hidden) { + transitionGeneration &+= 1 + settledGeneration = transitionGeneration + plan = newPlan + for overlay in [overlayA, overlayB] { + overlay.alphaValue = 0 + overlay.orderOut(nil) + } + } } /// Core engine: decides, for every display, which window stays bright and keeps @@ -65,6 +78,12 @@ final class HazeEngine { rebuildScreens() tracker.onChange = { [weak self] in self?.scheduleRecompute() } + tracker.onSpaceChange = { [weak self] in + guard let self else { return } + // Clear stale overlays before the deferred focus/window-list refresh. + for state in self.states.values { state.suspendImmediately() } + self.scheduleRecompute() + } tracker.onFnChanged = { [weak self] _ in guard let self, self.settings.fnReveal else { return } self.fastTransition = true @@ -178,7 +197,8 @@ final class HazeEngine { // The focused window: exact answer via Accessibility when available, // otherwise the front app's topmost normal window from the window list. var focused: WindowInfo? - if let axID = tracker.focusedWindowID(), let w = normals.first(where: { $0.id == axID }) { + let axFocused = tracker.focusedWindow() + if let axID = axFocused?.id, let w = normals.first(where: { $0.id == axID }) { focused = w } else if let pid = frontPID { focused = normals.first { $0.pid == pid } @@ -190,24 +210,19 @@ final class HazeEngine { focused = normals.first } - // Screens covered by a non-normal-layer fullscreen window of the front - // app (native fullscreen). Never dim those. - var fullscreenScreens = Set() - if let pid = frontPID { - for (id, state) in states { - let covering = snapshot.windows.contains { - $0.pid == pid && $0.layer != 0 && !excluded.contains($0.id) && - $0.bounds.intersection(state.cgFrame).area >= state.cgFrame.area * 0.9 - } - if covering { fullscreenScreens.insert(id) } - } - } + let fullscreenScreens = FullscreenDetection.screenIDs( + windows: snapshot.windows, + frontPID: frontPID, + focusedWindowID: axFocused?.id, + focusedWindowIsFullscreen: axFocused?.isFullscreen ?? false, + screens: states.mapValues { $0.cgFrame }, + excluding: excluded) guard let focused else { // Desktop focused (or nothing we can see, e.g. a fullscreen space). for (id, _) in states { if fullscreenScreens.contains(id) { - plans[id] = .hidden + plans[id] = .fullscreen } else { plans[id] = settings.desktopMode == .dimAll ? .fullDim : .hidden } @@ -220,7 +235,7 @@ final class HazeEngine { for (id, state) in states { if fullscreenScreens.contains(id) { - plans[id] = .hidden + plans[id] = .fullscreen continue } let onScreen = normals.filter { $0.isOn(cgScreenFrame: state.cgFrame) } @@ -258,7 +273,11 @@ final class HazeEngine { // MARK: - Applying plans - private func apply(_ plan: ScreenPlan, to state: ScreenState, snapshot: WindowSnapshot, duration: TimeInterval) { + func apply(_ plan: ScreenPlan, to state: ScreenState, snapshot: WindowSnapshot, duration: TimeInterval) { + if plan == .fullscreen { + if state.plan != .fullscreen { state.suspendImmediately(plan: .fullscreen) } + return + } let intensity = CGFloat(settings.currentIntensity) let color = settings.currentColor for overlay in [state.overlayA, state.overlayB] where overlay.backgroundColor != color { @@ -283,6 +302,8 @@ final class HazeEngine { let gen = state.transitionGeneration switch plan { + case .fullscreen: + break // handled synchronously above case .hidden: let outgoing = state.active animateAlpha(outgoing, to: 0, duration: duration) { [weak state] in diff --git a/macos/Sources/OpenHaze/OverlayWindow.swift b/macos/Sources/OpenHaze/OverlayWindow.swift index f041622..3739b37 100644 --- a/macos/Sources/OpenHaze/OverlayWindow.swift +++ b/macos/Sources/OpenHaze/OverlayWindow.swift @@ -13,7 +13,11 @@ final class OverlayWindow: NSWindow { hasShadow = false ignoresMouseEvents = true level = .normal - collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] + // Keep haze on its desktop during Space animations instead of pinning it + // over every Space. Reordering below a normal window can move it to the + // active desktop; fullscreen plans never order it in. Transient windows + // are hidden by Mission Control rather than appearing as desktop tiles. + collectionBehavior = [.moveToActiveSpace, .transient, .ignoresCycle] animationBehavior = .none isReleasedWhenClosed = false isExcludedFromWindowsMenu = true diff --git a/macos/Tests/main.swift b/macos/Tests/main.swift new file mode 100644 index 0000000..8301c3e --- /dev/null +++ b/macos/Tests/main.swift @@ -0,0 +1,89 @@ +import AppKit + +var failures = 0 +func check(_ name: String, _ actual: Set, _ expected: Set) { + if actual == expected { print("PASS: \(name)") } + else { failures += 1; print("FAIL: \(name): expected \(expected.sorted()), got \(actual.sorted())") } +} +let left = CGRect(x: 0, y: 0, width: 1440, height: 900) +let right = CGRect(x: 1440, y: 0, width: 1440, height: 900) +func window(_ id: CGWindowID = 42, pid: pid_t = 123, layer: Int = 0, + bounds: CGRect = left) -> WindowInfo { + WindowInfo(id: id, pid: pid, layer: layer, bounds: bounds, alpha: 1) +} +func detect(_ windows: [WindowInfo], fullscreen: Bool, + focused: CGWindowID? = 42, pid: pid_t? = 123, + screens: [CGDirectDisplayID: CGRect] = [1: left], + excluding: Set = []) -> Set { + FullscreenDetection.screenIDs(windows: windows, frontPID: pid, + focusedWindowID: focused, focusedWindowIsFullscreen: fullscreen, + screens: screens, excluding: excluding) +} +check("native fullscreen at layer 0", detect([window()], fullscreen: true), [1]) +check("ordinary maximized window stays dimmable", detect([window()], fullscreen: false), []) +check("ordinary floating window stays dimmable", detect([window(bounds: CGRect(x:100,y:100,width:900,height:600))], fullscreen: false), []) +check("fullscreen only suspends its own display", detect([window(bounds:right)], fullscreen:true, screens:[1:left,2:right]), [2]) +check("window-list fallback preserves nonzero-layer fullscreen", detect([window(layer:3)], fullscreen:false), [1]) +check("overlay cannot count as fullscreen", detect([window(layer:3)], fullscreen:false, excluding:[42]), []) +check("background app cannot count as fullscreen", detect([window(pid:456,layer:3)], fullscreen:false), []) +check("no front app", detect([window()], fullscreen:true, pid:nil), []) +check("fullscreen without a CG window during transition", detect([], fullscreen:true, focused:nil), [1]) +check("missing CG window does not guess across displays", detect([], fullscreen:true, focused:nil, screens:[1:left,2:right]), []) +check("fullscreen can use a visible front-app window without its AX ID", detect([window(bounds:right)], fullscreen:true, focused:nil, screens:[1:left,2:right]), [2]) +check("exit fullscreen resumes ordinary dimming", detect([window()], fullscreen:false), []) +let app = NSApplication.shared +let overlay = OverlayWindow(screenFrame: left) +if overlay.collectionBehavior.contains(.fullScreenAuxiliary) { + failures += 1; print("FAIL: overlay must not join native fullscreen as an auxiliary window") +} else { print("PASS: overlay does not join native fullscreen as an auxiliary window") } +overlay.orderOut(nil) + +func expect(_ name: String, _ condition: Bool) { + if condition { print("PASS: \(name)") } + else { failures += 1; print("FAIL: \(name)") } +} +expect("overlay is not shared across all Spaces", !overlay.collectionBehavior.contains(.canJoinAllSpaces)) +expect("overlay does not stay stationary over Space animations", !overlay.collectionBehavior.contains(.stationary)) +expect("overlay can move to the active desktop after planning", overlay.collectionBehavior.contains(.moveToActiveSpace)) +if let screen = NSScreen.screens.first { + let state = ScreenState(screen: screen) + let engine = HazeEngine(settings: Settings.shared) + state.plan = .below(42) + state.transitionGeneration = 3 + state.settledGeneration = 2 + state.overlayA.alphaValue = 0.65 + state.overlayB.alphaValue = 0.45 + state.overlayA.orderFrontRegardless() + state.overlayB.orderFrontRegardless() + expect("crossfade fixture starts with both windows visible", + state.overlayA.isVisible && state.overlayB.isVisible) + engine.apply(.fullscreen, to: state, snapshot: WindowSnapshot(), duration: 5) + expect("fullscreen immediately clears both crossfade alpha values", + state.overlayA.alphaValue == 0 && state.overlayB.alphaValue == 0) + expect("fullscreen removes both overlay windows", + !state.overlayA.isVisible && !state.overlayB.isVisible) + expect("fullscreen invalidates pending crossfade generations", + state.transitionGeneration > 3 && state.settledGeneration == state.transitionGeneration) + expect("fullscreen uses its own immediate plan", state.plan == .fullscreen) + let generation = state.transitionGeneration + engine.apply(.fullscreen, to: state, snapshot: WindowSnapshot(), duration: 5) + expect("repeated fullscreen refresh does not restart transitions", + state.transitionGeneration == generation) + state.overlayA.alphaValue = 0.65 + state.overlayB.alphaValue = 0.45 + state.suspendImmediately() + expect("Space change immediately clears both overlays before focus refresh", + state.plan == .hidden && state.overlayA.alphaValue == 0 && state.overlayB.alphaValue == 0) + engine.apply(.fullDim, to: state, snapshot: WindowSnapshot(), duration: 2) + engine.apply(.fullscreen, to: state, snapshot: WindowSnapshot(), duration: 2) + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + expect("pending fade-in cannot reshow a fullscreen overlay", + !state.overlayA.isVisible && !state.overlayB.isVisible) + expect("pending fade-in cannot restore fullscreen haze opacity", + state.overlayA.alphaValue == 0 && state.overlayB.alphaValue == 0) + state.tearDown() +} else { + failures += 1 + print("FAIL: overlay-transition checks require a logged-in macOS graphical session") +} +exit(failures == 0 ? 0 : 1) diff --git a/macos/test.sh b/macos/test.sh new file mode 100644 index 0000000..1d4559a --- /dev/null +++ b/macos/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p build +swiftc -module-cache-path build/test-module-cache -o build/fullscreen-tests Sources/OpenHaze/WindowSnapshot.swift Sources/OpenHaze/FullscreenDetection.swift Sources/OpenHaze/OverlayWindow.swift Sources/OpenHaze/Settings.swift Sources/OpenHaze/FocusTracker.swift Sources/OpenHaze/HazeEngine.swift Tests/main.swift +build/fullscreen-tests