diff --git a/Makefile b/Makefile index 54362c3..2bf13e4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test package clean +.PHONY: build test test-swift package clean MACOSX_DEPLOYMENT_TARGET ?= 12.0 @@ -11,13 +11,24 @@ build: build-swift: aw_watcher_window/aw-watcher-window-macos -aw_watcher_window/aw-watcher-window-macos: aw_watcher_window/macos.swift +aw_watcher_window/aw-watcher-window-macos: aw_watcher_window/macos.swift aw_watcher_window/macos_state.swift swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" $^ -o $@ test: poetry run aw-watcher-window --help # Ensures that it at least starts poetry run python -m pytest tests/ make typecheck + if [ "$(shell uname)" = "Darwin" ]; then \ + make test-swift; \ + fi + +test-swift: + tmpdir=$$(mktemp -d) && \ + trap 'rm -rf "$$tmpdir"' EXIT && \ + swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" \ + aw_watcher_window/macos_state.swift tests/macos_state_tests.swift \ + -o "$$tmpdir/macos-state-tests" && \ + "$$tmpdir/macos-state-tests" typecheck: poetry run mypy aw_watcher_window/ --ignore-missing-imports diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 83a29ac..ba8acba 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -192,18 +192,32 @@ let researchBrowserApps = Set([ let main = MainThing() var oldHeartbeat: Heartbeat? -let encoder = JSONEncoder() -let formatter = ISO8601DateFormatter() -formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - -encoder.dateEncodingStrategy = .custom({ date, encoder in - var container = encoder.singleValueContainer() - let dateString = formatter.string(from: date) - try container.encode(dateString) -}) - -start() -RunLoop.main.run() +let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom({ date, encoder in + var container = encoder.singleValueContainer() + let dateString = formatter.string(from: date) + try container.encode(dateString) + }) + return encoder +}() + +// File-level `let`s above are declarations (legal with @main). Executable +// entry (start / RunLoop) lives in main() because compiling macos.swift +// together with macos_state.swift is not a script compilation unit. +@main +struct ActivityWatchMacOSWatcher { + static func main() { + start() + RunLoop.main.run() + } +} func compileExcludeTitlePattern(_ pattern: String) -> NSRegularExpression { do { @@ -389,12 +403,12 @@ func start() { // listen for changes in focused application NSWorkspace.shared.notificationCenter.addObserver( main, - selector: #selector(main.focusedAppChanged), + selector: #selector(main.focusedAppChanged(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil ) - main.focusedAppChanged() + main.reconcileCurrentForegroundApp(source: "startup") // Start the polling timer main.pollingTimer = Timer.scheduledTimer(timeInterval: 10.0, target: main, selector: #selector(main.pollActiveWindow), userInfo: nil, repeats: true) @@ -488,9 +502,15 @@ func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws class MainThing { var observer: AXObserver? var observedApp: AXUIElement? + var foregroundApplication: NSRunningApplication? var oldWindow: AXUIElement? + var titleNotificationRegistered = false var pollingTimer: Timer? + var trackedPID: pid_t? { + return foregroundApplication?.processIdentifier + } + // list of chrome equivalent browsers let CHROME_BROWSERS = [ "Google Chrome", @@ -552,149 +572,49 @@ class MainThing { return nil } - @objc func pollActiveWindow() { - debug("Polling active window") - - guard let observer = observer else { - debug("Polling skipped: no accessibility observer") - return - } - - guard let frontmost = NSWorkspace.shared.frontmostApplication else { - log("Failed to get frontmost application from polling") - return - } - - let pid = frontmost.processIdentifier - let focusedApp = AXUIElementCreateApplication(pid) - - var focusedWindow: AnyObject? - AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow) + func elementPID(_ element: AXUIElement) -> pid_t? { + var pid: pid_t = 0 + return AXUIElementGetPid(element, &pid) == .success ? pid : nil + } - if let focusedWindow = axElement(focusedWindow) { - focusedWindowChanged(observer, window: focusedWindow) - } + @objc func pollActiveWindow() { + reconcileCurrentForegroundApp(source: "poll") } - deinit { - pollingTimer?.invalidate() - tearDownObserver() + @objc func focusedAppChanged(_ notification: Notification) { + let notificationApplication = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + if let application = notificationApplication, !application.isTerminated { + reconcileForegroundApp(application: application, source: "activation") + } else { + log("Activation notification lacked a live application; falling back to current foreground app") + reconcileCurrentForegroundApp(source: "activation-fallback") + } } - func windowTitleChanged( - _ axObserver: AXObserver, - axElement: AXUIElement, - notification: CFString - ) { - guard let frontmost = NSWorkspace.shared.frontmostApplication else { - log("Failed to get frontmost application from window title notification") + func reconcileCurrentForegroundApp(source: String) { + guard let application = NSWorkspace.shared.frontmostApplication, !application.isTerminated else { + log("Failed to get a live foreground application from \(source)") return } + reconcileForegroundApp(application: application, source: source) + } - // calculate now before executing any scripting since that can take some time - let nowTime = Date.now - - var windowTitle: AnyObject? - AXUIElementCopyAttributeValue(axElement, kAXTitleAttribute as CFString, &windowTitle) - - let applicationName = frontmost.localizedName ?? frontmost.bundleIdentifier ?? "" - var data = NetworkMessage(app: applicationName, title: axString(windowTitle) ?? "") - - if CHROME_BROWSERS.contains(applicationName) { - debug("Chrome browser detected, extracting URL and title") - - guard let bundleIdentifier = frontmost.bundleIdentifier else { - log("Failed to get bundle identifier from frontmost application, which was recognized to be Chrome") - return - } - let chromeObject: ChromeProtocol = SBApplication.init(bundleIdentifier: bundleIdentifier)! - - guard let windows = chromeObject.windows, - let frontWindow = windows().first else { - log("Failed to get chrome front window") - return - } - guard let activeTab = frontWindow.activeTab else { - log("Failed to get chrome active tab") - return - } - - if frontWindow.mode == "incognito" { - data = NetworkMessage(app: "", title: "") - } else { - data.url = activeTab.URL - - // the tab title is more accurate and often different than the window title - // however, in some cases the binary does not have the right permissions to read - // the title properly and will return a blank string - - if let tabTitle = activeTab.title { - if(tabTitle != "" && data.title != tabTitle) { - error("tab title diff: \(tabTitle), window title: \(data.title)") - data.title = tabTitle - } - } - } - } else if frontmost.localizedName == "Safari" { - debug("Safari browser detected, extracting URL and title") - - guard let bundleIdentifier = frontmost.bundleIdentifier else { - log("Failed to get bundle identifier from frontmost application, which was recognized to be Safari") - return - } - let safariObject: SafariApplication = SBApplication.init(bundleIdentifier: bundleIdentifier)! - - guard let windows = safariObject.windows, - let frontWindow = windows().first else { - log("Failed to get safari front window") - return - } - guard let activeTab = frontWindow.currentTab else { - log("Failed to get safari active tab") - return - } - - // Safari doesn't allow incognito mode to be inspected, so we do not know if we should hide the url - data.url = activeTab.URL - - // comment above applies here as well - if let tabTitle = activeTab.name { - if tabTitle != "" && data.title != tabTitle { - error("tab title diff: \(tabTitle), window title: \(data.title)") - data.title = tabTitle - } - } - } else if FIREFOX_BROWSERS.contains(applicationName) { - debug("Firefox-based browser detected, extracting URL from accessibility tree") - - // note: private windows are not hidden here (unlike the Chrome incognito - // branch) — Gecko does not mark them in the accessibility tree, and their - // window titles carry a "Private Browsing" suffix for rules to match - data.url = geckoURL(window: axElement) - - if data.url == nil { - // Newer Gecko builds instantiate their accessibility engine lazily and - // no longer treat plain tree walks as an assistive client, leaving the - // window's AX tree without any web content. Requesting - // AXEnhancedUserInterface (as VoiceOver does) turns the engine on; the - // call may report an error while the engine spins up, but the tree is - // populated for subsequent polls and stays on for the browser session. - let axApp = AXUIElementCreateApplication(frontmost.processIdentifier) - AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) - } - } + func reconcileForegroundApp(application: NSRunningApplication, source: String) { + let pid = application.processIdentifier + let action = foregroundReconciliationAction( + trackedPID: trackedPID, + observerAvailable: observer != nil, + candidatePID: pid + ) - if researchEnabled { - data = applyResearchFilter(data) - } else if excludeTitle || titleShouldBeExcluded(data.title ?? "") { - data.title = "excluded" - // the URL identifies the page at least as precisely as the title does, - // so an excluded window must not report it either - data.url = nil + if action == .rebuildObserver { + debug("Rebuilding AX observer for pid \(pid) from \(source)") + rebuildObserver(for: application) } - - let heartbeat = Heartbeat(timestamp: nowTime, data: data) - sendHeartbeat(heartbeat) + // Always bind identity here, including when observer install failed: + // heartbeats must still track the live foreground app. + foregroundApplication = application + refreshFocusedWindow(for: application) } func tearDownObserver() { @@ -719,86 +639,260 @@ class MainThing { oldWindow = nil observedApp = nil observer = nil + titleNotificationRegistered = false } - @objc func focusedWindowChanged(_ observer: AXObserver, window: AXUIElement) { - debug("Focused window changed") - - if let oldWindow = oldWindow { - AXObserverRemoveNotification(observer, oldWindow, kAXTitleChangedNotification as CFString) - } - - let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - AXObserverAddNotification(observer, window, kAXTitleChangedNotification as CFString, selfPtr) - - windowTitleChanged( - observer, axElement: window, notification: kAXTitleChangedNotification as CFString) - - oldWindow = window - } - - @objc func focusedAppChanged() { - debug("Focused app changed") + func rebuildObserver(for application: NSRunningApplication) { tearDownObserver() - guard let frontmost = NSWorkspace.shared.frontmostApplication else { - log("Failed to get frontmost application from app change notification") - return - } - - let pid = frontmost.processIdentifier + let pid = application.processIdentifier let focusedApp = AXUIElementCreateApplication(pid) - var newObserver: AXObserver? - AXObserverCreate( + let createResult = AXObserverCreate( pid, { ( - _ axObserver: AXObserver, + axObserver: AXObserver, axElement: AXUIElement, notification: CFString, userData: UnsafeMutableRawPointer? ) -> Void in guard let userData = userData else { - log("Missing userData") + log("Missing AX observer userData") return } - let application = Unmanaged.fromOpaque(userData).takeUnretainedValue() - if notification == kAXFocusedWindowChangedNotification as CFString { - application.focusedWindowChanged(axObserver, window: axElement) - } else { - application.windowTitleChanged( - axObserver, - axElement: axElement, - notification: notification - ) - } - }, &newObserver) + let watcher = Unmanaged.fromOpaque(userData).takeUnretainedValue() + watcher.handleAXNotification( + observer: axObserver, + element: axElement, + notification: notification + ) + }, + &newObserver + ) - guard let newObserver = newObserver else { - log("Failed to create accessibility observer") + guard createResult == .success, let newObserver = newObserver else { + log("Failed to create AX observer for pid \(pid): \(createResult.rawValue)") return } - observer = newObserver - observedApp = focusedApp - let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - AXObserverAddNotification( - newObserver, focusedApp, kAXFocusedWindowChangedNotification as CFString, selfPtr) + let addResult = AXObserverAddNotification( + newObserver, + focusedApp, + kAXFocusedWindowChangedNotification as CFString, + selfPtr + ) + guard addResult == .success || addResult == .notificationAlreadyRegistered else { + log("Failed to observe focused-window changes for pid \(pid): \(addResult.rawValue)") + // Never installed into the run loop, so do not CFRunLoopRemoveSource. + // AXObserver is Swift-ARC-managed (see the Create-overwrite warning + // on tearDownObserver). Parking it on `observer` and nilling is the + // release; CFRelease would double-free. + observer = newObserver + observer = nil + return + } + observer = newObserver + observedApp = focusedApp CFRunLoopAddSource( RunLoop.current.getCFRunLoop(), AXObserverGetRunLoopSource(newObserver), CFRunLoopMode.defaultMode ) + } + + func refreshFocusedWindow(for application: NSRunningApplication) { + guard trackedPID == application.processIdentifier else { + debug("Ignoring focused-window refresh for stale pid \(application.processIdentifier)") + return + } + + let focusedApp = AXUIElementCreateApplication(application.processIdentifier) + var focusedWindowValue: AnyObject? + let result = AXUIElementCopyAttributeValue( + focusedApp, + kAXFocusedWindowAttribute as CFString, + &focusedWindowValue + ) + let focusedWindow: AXUIElement? = (result == .success) ? axElement(focusedWindowValue) : nil + updateFocusedWindow(focusedWindow, for: application) + } + + func updateFocusedWindow(_ window: AXUIElement?, for application: NSRunningApplication) { + guard trackedPID == application.processIdentifier else { + debug("Ignoring focused-window update for stale pid \(application.processIdentifier)") + return + } + + var windowChanged = oldWindow == nil || window == nil + if let oldWindow = oldWindow, let window = window { + windowChanged = !CFEqual(oldWindow, window) + } else if oldWindow == nil && window == nil { + windowChanged = false + } + + let attemptTitleRegistration = shouldAttemptTitleNotificationRegistration( + hasObserver: observer != nil, + windowPresent: window != nil, + windowChanged: windowChanged, + titleNotificationRegistered: titleNotificationRegistered + ) + + if attemptTitleRegistration, let observer = observer { + if windowChanged, let previous = oldWindow { + AXObserverRemoveNotification(observer, previous, kAXTitleChangedNotification as CFString) + } + if let window = window { + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + let addResult = AXObserverAddNotification( + observer, + window, + kAXTitleChangedNotification as CFString, + selfPtr + ) + if addResult == .success || addResult == .notificationAlreadyRegistered { + titleNotificationRegistered = true + } else { + log("Failed to observe title changes for pid \(application.processIdentifier): \(addResult.rawValue)") + titleNotificationRegistered = false + } + } else { + titleNotificationRegistered = false + } + } + + oldWindow = window + emitHeartbeat(application: application, window: window) + } + + func handleAXNotification( + observer callbackObserver: AXObserver, + element: AXUIElement, + notification: CFString + ) { + guard let application = foregroundApplication, + let currentObserver = observer, + CFEqual(callbackObserver, currentObserver), + axCallbackBelongsToForeground(trackedPID: trackedPID, elementPID: elementPID(element)) else { + debug("Ignoring stale AX callback") + return + } + + if notification == kAXFocusedWindowChangedNotification as CFString { + refreshFocusedWindow(for: application) + } else if notification == kAXTitleChangedNotification as CFString { + // Title notifications are registered on one window at a time. A queued + // callback from the previous window of this PID still matches the + // observer+PID guard above; reject it unless it is the focused window. + let elementIsFocusedWindow = oldWindow.map { CFEqual($0, element) } ?? false + guard axTitleCallbackBelongsToFocusedWindow( + hasFocusedWindow: oldWindow != nil, + elementIsFocusedWindow: elementIsFocusedWindow + ) else { + debug("Ignoring stale title-change callback from a previous window") + return + } + emitHeartbeat(application: application, window: element) + } + } - var focusedWindow: AnyObject? - AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow) + func emitHeartbeat(application: NSRunningApplication, window: AXUIElement?) { + guard trackedPID == application.processIdentifier else { + debug("Ignoring heartbeat for stale pid \(application.processIdentifier)") + return + } - if let focusedWindow = axElement(focusedWindow) { - focusedWindowChanged(newObserver, window: focusedWindow) + // Calculate now before optional browser scripting, which may take time. + let nowTime = Date.now + var windowTitle: AnyObject? + if let window = window { + AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &windowTitle) } + + let applicationName = application.localizedName ?? application.bundleIdentifier ?? "" + var data = NetworkMessage(app: applicationName, title: axString(windowTitle) ?? "") + + if CHROME_BROWSERS.contains(applicationName) { + debug("Chrome browser detected, extracting URL and title") + if let bundleIdentifier = application.bundleIdentifier, + let chromeObject: ChromeProtocol = SBApplication.init(bundleIdentifier: bundleIdentifier), + let windows = chromeObject.windows, + let frontWindow = windows().first, + let activeTab = frontWindow.activeTab { + if frontWindow.mode == "incognito" { + data = NetworkMessage(app: "", title: "") + } else { + data.url = activeTab.URL + if let tabTitle = activeTab.title, tabTitle != "", data.title != tabTitle { + data.title = tabTitle + } + } + } else { + // ScriptingBridge is the only incognito detector. Keep app identity so + // foreground tracking stays coherent, but drop AX title/URL. + let fallback = browserHeartbeatAfterContextFailure(app: applicationName) + log("Failed to read Chrome context; emitting foreground heartbeat without title or URL") + data = NetworkMessage(app: fallback.app, title: fallback.title, url: fallback.url) + } + } else if applicationName == "Safari" { + debug("Safari browser detected, extracting URL and title") + if let bundleIdentifier = application.bundleIdentifier, + let safariObject: SafariApplication = SBApplication.init(bundleIdentifier: bundleIdentifier), + let windows = safariObject.windows, + let frontWindow = windows().first, + let activeTab = frontWindow.currentTab { + // Safari doesn't allow incognito mode to be inspected, so we do not know if we should hide the url + data.url = activeTab.URL + if let tabTitle = activeTab.name, tabTitle != "", data.title != tabTitle { + data.title = tabTitle + } + } else { + // Same fallback as Chrome: keep app identity, drop AX title/URL. + // Safari private windows still expose page titles via AX, and SB + // cannot tell us whether this window is private. + let fallback = browserHeartbeatAfterContextFailure(app: applicationName) + log("Failed to read Safari context; emitting foreground heartbeat without title or URL") + data = NetworkMessage(app: fallback.app, title: fallback.title, url: fallback.url) + } + } else if FIREFOX_BROWSERS.contains(applicationName), let window = window { + debug("Firefox-based browser detected, extracting URL from accessibility tree") + + // Private windows are intentionally not blanked here (unlike Chrome + // incognito). Gecko does not mark them in the accessibility tree; the + // window title's "Private Browsing" suffix is the signal exclude-title + // rules match. geckoURL == nil means the AX tree is not ready yet, not + // that the window is private — dropping the title would hide that suffix. + data.url = geckoURL(window: window) + + if data.url == nil { + // Newer Gecko builds instantiate their accessibility engine lazily and + // no longer treat plain tree walks as an assistive client, leaving the + // window's AX tree without any web content. Requesting + // AXEnhancedUserInterface (as VoiceOver does) turns the engine on; the + // call may report an error while the engine spins up, but the tree is + // populated for subsequent polls and stays on for the browser session. + let axApp = AXUIElementCreateApplication(application.processIdentifier) + AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) + } + } + + if researchEnabled { + data = applyResearchFilter(data) + } else if excludeTitle || titleShouldBeExcluded(data.title ?? "") { + data.title = "excluded" + // the URL identifies the page at least as precisely as the title does, + // so an excluded window must not report it either + data.url = nil + } + + sendHeartbeat(Heartbeat(timestamp: nowTime, data: data)) + } + + deinit { + pollingTimer?.invalidate() + tearDownObserver() } } diff --git a/aw_watcher_window/macos_state.swift b/aw_watcher_window/macos_state.swift new file mode 100644 index 0000000..b52c864 --- /dev/null +++ b/aw_watcher_window/macos_state.swift @@ -0,0 +1,68 @@ +import Foundation + +enum ForegroundReconciliationAction: Equatable { + case rebuildObserver + case refreshWindow +} + +struct BrowserFallbackHeartbeat: Equatable { + let app: String + let title: String + let url: String? +} + +func foregroundReconciliationAction( + trackedPID: pid_t?, + observerAvailable: Bool, + candidatePID: pid_t +) -> ForegroundReconciliationAction { + if trackedPID != candidatePID || !observerAvailable { + return .rebuildObserver + } + return .refreshWindow +} + +func axCallbackBelongsToForeground(trackedPID: pid_t?, elementPID: pid_t?) -> Bool { + guard let trackedPID = trackedPID, let elementPID = elementPID else { + return false + } + return trackedPID == elementPID +} + +/// Title-change callbacks are per-window. A queued event from a previous +/// window of the same PID must not overwrite the current focused window. +func axTitleCallbackBelongsToFocusedWindow( + hasFocusedWindow: Bool, + elementIsFocusedWindow: Bool +) -> Bool { + guard hasFocusedWindow else { + return false + } + return elementIsFocusedWindow +} + +/// Title-change notifications are registered on the focused window. If that +/// add fails once (window not ready yet), later polls must retry instead of +/// treating the window as already observed. +func shouldAttemptTitleNotificationRegistration( + hasObserver: Bool, + windowPresent: Bool, + windowChanged: Bool, + titleNotificationRegistered: Bool +) -> Bool { + guard hasObserver else { + return false + } + if windowChanged { + return true + } + return windowPresent && !titleNotificationRegistered +} + +/// Browser ScriptingBridge is URL/title enrichment, and for Chrome the only +/// incognito detector. Safari cannot expose private-browsing state at all. +/// If that lookup fails, keep the app identity so foreground tracking stays +/// coherent, but drop title and URL so a private page cannot leak via AX. +func browserHeartbeatAfterContextFailure(app: String) -> BrowserFallbackHeartbeat { + return BrowserFallbackHeartbeat(app: app, title: "", url: nil) +} diff --git a/tests/macos_state_tests.swift b/tests/macos_state_tests.swift new file mode 100644 index 0000000..edb93b2 --- /dev/null +++ b/tests/macos_state_tests.swift @@ -0,0 +1,120 @@ +import Foundation + +@main +struct MacOSStateTests { + static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("FAIL: \(message)\n", stderr) + exit(1) + } + } + + static func main() { + expect( + foregroundReconciliationAction(trackedPID: 100, observerAvailable: true, candidatePID: 200) == .rebuildObserver, + "activation/poll PID change must rebuild the observer" + ) + expect( + foregroundReconciliationAction(trackedPID: 200, observerAvailable: true, candidatePID: 200) == .refreshWindow, + "same PID with a live observer must only refresh the focused window" + ) + expect( + foregroundReconciliationAction(trackedPID: 200, observerAvailable: false, candidatePID: 200) == .rebuildObserver, + "a missing observer must be repaired even when the PID is unchanged" + ) + expect( + !axCallbackBelongsToForeground(trackedPID: 200, elementPID: 100), + "a stale AX callback must not overwrite the current foreground app" + ) + expect( + axCallbackBelongsToForeground(trackedPID: 200, elementPID: 200), + "an AX callback for the tracked PID must be accepted" + ) + expect( + !axCallbackBelongsToForeground(trackedPID: nil, elementPID: 200), + "an AX callback must be rejected when no foreground PID is tracked" + ) + expect( + !axTitleCallbackBelongsToFocusedWindow( + hasFocusedWindow: true, + elementIsFocusedWindow: false + ), + "a queued title-change from a previous window of the same PID must be dropped" + ) + expect( + axTitleCallbackBelongsToFocusedWindow( + hasFocusedWindow: true, + elementIsFocusedWindow: true + ), + "a title-change for the focused window must be accepted" + ) + expect( + !axTitleCallbackBelongsToFocusedWindow( + hasFocusedWindow: false, + elementIsFocusedWindow: false + ), + "a title-change must be rejected when no focused window is tracked" + ) + expect( + shouldAttemptTitleNotificationRegistration( + hasObserver: true, + windowPresent: true, + windowChanged: true, + titleNotificationRegistered: false + ), + "a new focused window must attempt title-notification registration" + ) + expect( + !shouldAttemptTitleNotificationRegistration( + hasObserver: true, + windowPresent: true, + windowChanged: false, + titleNotificationRegistered: true + ), + "an already-registered unchanged window must not re-register" + ) + expect( + shouldAttemptTitleNotificationRegistration( + hasObserver: true, + windowPresent: true, + windowChanged: false, + titleNotificationRegistered: false + ), + "a failed title-notification registration must be retried on the next poll" + ) + expect( + shouldAttemptTitleNotificationRegistration( + hasObserver: true, + windowPresent: false, + windowChanged: true, + titleNotificationRegistered: true + ), + "clearing the focused window must still run so the previous title notification can be removed" + ) + expect( + !shouldAttemptTitleNotificationRegistration( + hasObserver: false, + windowPresent: true, + windowChanged: true, + titleNotificationRegistered: false + ), + "title registration requires a live AX observer" + ) + expect( + browserHeartbeatAfterContextFailure(app: "Google Chrome") + == BrowserFallbackHeartbeat(app: "Google Chrome", title: "", url: nil), + "Chrome context failure must keep app identity and drop title/URL" + ) + expect( + browserHeartbeatAfterContextFailure(app: "Brave Browser").title.isEmpty + && browserHeartbeatAfterContextFailure(app: "Brave Browser").url == nil, + "Chrome-equivalent context failure must never emit AX title or URL" + ) + expect( + browserHeartbeatAfterContextFailure(app: "Safari") + == BrowserFallbackHeartbeat(app: "Safari", title: "", url: nil), + "Safari context failure must keep app identity and drop AX title/URL" + ) + print("macOS foreground state tests passed") + } +}