Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions macos/Sources/OpenHaze/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
}
}

Expand Down
42 changes: 42 additions & 0 deletions macos/Sources/OpenHaze/FullscreenDetection.swift
Original file line number Diff line number Diff line change
@@ -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<CGWindowID>) -> Set<CGDirectDisplayID> {
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)
}
}
53 changes: 37 additions & 16 deletions macos/Sources/OpenHaze/HazeEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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<CGDirectDisplayID>()
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
}
Expand All @@ -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) }
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion macos/Sources/OpenHaze/OverlayWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions macos/Tests/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import AppKit

var failures = 0
func check(_ name: String, _ actual: Set<CGDirectDisplayID>, _ expected: Set<CGDirectDisplayID>) {
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<CGWindowID> = []) -> Set<CGDirectDisplayID> {
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)
6 changes: 6 additions & 0 deletions macos/test.sh
Original file line number Diff line number Diff line change
@@ -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
Loading