From acb3ecae3df26a2f489aef1b32c884074dd59e14 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 15 Sep 2026 19:43:46 +0000 Subject: [PATCH 1/6] fix(macos): rebase foreground reconciliation onto master Rebase of ActivityWatch/aw-watcher-window#141 (hawai-i) onto current master after #145. Keep PID-based foreground reconciliation and emit a heartbeat when Chrome/Safari ScriptingBridge fails, so merging does not extend the previous application across the real browser interval. Preserve #145 AX safety (axString/axElement) and observer teardown (unregister focused-window notification, track observedApp). Chrome context-failure fallback keeps app identity for tracking but drops title and URL: ScriptingBridge is the only incognito detector (Greptile P1 on #141). Swift state tests now compile with the macOS 12 deployment target (Greptile P2). Co-Authored-By: hawai-i <32040032+hawai-i@users.noreply.github.com> Git-Session-Id: 9615c06b-2a11-592d-b1ad-299a3433e323 --- Makefile | 11 +- aw_watcher_window/macos.swift | 411 +++++++++++++++------------- aw_watcher_window/macos_state.swift | 37 +++ tests/macos_state_tests.swift | 49 ++++ 4 files changed, 322 insertions(+), 186 deletions(-) create mode 100644 aw_watcher_window/macos_state.swift create mode 100644 tests/macos_state_tests.swift diff --git a/Makefile b/Makefile index 54362c3..ffe968e 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,20 @@ 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: + swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" aw_watcher_window/macos_state.swift tests/macos_state_tests.swift -o /tmp/aw-watcher-window-macos-state-tests + /tmp/aw-watcher-window-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..bed710b 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -389,12 +389,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 +488,14 @@ func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws class MainThing { var observer: AXObserver? var observedApp: AXUIElement? + var foregroundApplication: NSRunningApplication? var oldWindow: AXUIElement? var pollingTimer: Timer? + var trackedPID: pid_t? { + return foregroundApplication?.processIdentifier + } + // list of chrome equivalent browsers let CHROME_BROWSERS = [ "Google Chrome", @@ -552,149 +557,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) + } else { + foregroundApplication = application } - let heartbeat = Heartbeat(timestamp: nowTime, data: data) - sendHeartbeat(heartbeat) + refreshFocusedWindow(for: application) } func tearDownObserver() { @@ -721,84 +626,222 @@ class MainThing { observer = nil } - @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() + foregroundApplication = application - 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)") + 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 focusedWindow: AnyObject? - AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow) + 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 + } + + if windowChanged, let observer = observer { + if let oldWindow = oldWindow { + AXObserverRemoveNotification(observer, oldWindow, 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 { + log("Failed to observe title changes for pid \(application.processIdentifier): \(addResult.rawValue)") + } + } + } + + 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 { + emitHeartbeat(application: application, window: element) + } + } - if let focusedWindow = axElement(focusedWindow) { - focusedWindowChanged(newObserver, window: focusedWindow) + func emitHeartbeat(application: NSRunningApplication, window: AXUIElement?) { + guard trackedPID == application.processIdentifier else { + debug("Ignoring heartbeat for stale pid \(application.processIdentifier)") + return + } + + // 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 = chromeHeartbeatAfterContextFailure(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 { + log("Failed to read Safari context; emitting foreground heartbeat without URL") + } + } else if FIREFOX_BROWSERS.contains(applicationName), let window = window { + 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: 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..1562e2f --- /dev/null +++ b/aw_watcher_window/macos_state.swift @@ -0,0 +1,37 @@ +import Foundation + +enum ForegroundReconciliationAction: Equatable { + case rebuildObserver + case refreshWindow +} + +struct ChromeFallbackHeartbeat: 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 +} + +/// Chrome ScriptingBridge is both URL enrichment and the only incognito detector. +/// If that lookup fails, keep the app identity so foreground tracking stays +/// coherent, but drop title and URL so an incognito page cannot leak via AX. +func chromeHeartbeatAfterContextFailure(app: String) -> ChromeFallbackHeartbeat { + return ChromeFallbackHeartbeat(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..a10ceba --- /dev/null +++ b/tests/macos_state_tests.swift @@ -0,0 +1,49 @@ +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( + chromeHeartbeatAfterContextFailure(app: "Google Chrome") + == ChromeFallbackHeartbeat(app: "Google Chrome", title: "", url: nil), + "Chrome context failure must keep app identity and drop title/URL" + ) + expect( + chromeHeartbeatAfterContextFailure(app: "Brave Browser").title.isEmpty + && chromeHeartbeatAfterContextFailure(app: "Brave Browser").url == nil, + "Chrome-equivalent context failure must never emit AX title or URL" + ) + print("macOS foreground state tests passed") + } +} From 8ac81d39c2f824596bc4c8fe777d6e3207c083cb Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 15 Sep 2026 23:16:38 +0000 Subject: [PATCH 2/6] fix(macos): restore @main entry so two-file swiftc compiles Adding macos_state.swift made macos.swift a non-script compilation unit. Top-level assignments (formatter options, encoder strategy, start()) are illegal outside main.swift. Restore the #141 closure inits and @main wrapper that the rebase dropped. Git-Session-Id: ca3f0209-55b1-58ce-8820-82fb6f5bc8e0 --- aw_watcher_window/macos.swift | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index bed710b..4c6f783 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -192,18 +192,29 @@ 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 +}() + +@main +struct ActivityWatchMacOSWatcher { + static func main() { + start() + RunLoop.main.run() + } +} func compileExcludeTitlePattern(_ pattern: String) -> NSRegularExpression { do { From 9424b4f6a054588bb6ffdbc8a4da8e110cf60e86 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 11:45:01 +0000 Subject: [PATCH 3/6] fix(macos): drop stale same-window title callbacks and Safari AX titles PID-only AX guards accepted queued title-change events from a previous window of the same app. Bind title callbacks to the focused window. Safari ScriptingBridge failure kept the AX window title, unlike Chrome. Use the same keep-app / drop-title-URL fallback so private pages cannot leak via accessibility when context lookup fails. Git-Session-Id: 9a7625ed-1cac-5d98-b0d9-013b84aae02d --- aw_watcher_window/macos.swift | 20 +++++++++++++++-- aw_watcher_window/macos_state.swift | 23 ++++++++++++++----- tests/macos_state_tests.swift | 34 +++++++++++++++++++++++++---- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 4c6f783..686a991 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -761,6 +761,17 @@ class MainThing { 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) } } @@ -799,7 +810,7 @@ class MainThing { } else { // ScriptingBridge is the only incognito detector. Keep app identity so // foreground tracking stays coherent, but drop AX title/URL. - let fallback = chromeHeartbeatAfterContextFailure(app: applicationName) + 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) } @@ -816,7 +827,12 @@ class MainThing { data.title = tabTitle } } else { - log("Failed to read Safari context; emitting foreground heartbeat without URL") + // 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") diff --git a/aw_watcher_window/macos_state.swift b/aw_watcher_window/macos_state.swift index 1562e2f..0627c19 100644 --- a/aw_watcher_window/macos_state.swift +++ b/aw_watcher_window/macos_state.swift @@ -5,7 +5,7 @@ enum ForegroundReconciliationAction: Equatable { case refreshWindow } -struct ChromeFallbackHeartbeat: Equatable { +struct BrowserFallbackHeartbeat: Equatable { let app: String let title: String let url: String? @@ -29,9 +29,22 @@ func axCallbackBelongsToForeground(trackedPID: pid_t?, elementPID: pid_t?) -> Bo return trackedPID == elementPID } -/// Chrome ScriptingBridge is both URL enrichment and the only incognito detector. +/// 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 +} + +/// 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 an incognito page cannot leak via AX. -func chromeHeartbeatAfterContextFailure(app: String) -> ChromeFallbackHeartbeat { - return ChromeFallbackHeartbeat(app: app, title: "", url: nil) +/// 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 index a10ceba..057c169 100644 --- a/tests/macos_state_tests.swift +++ b/tests/macos_state_tests.swift @@ -35,15 +35,41 @@ struct MacOSStateTests { "an AX callback must be rejected when no foreground PID is tracked" ) expect( - chromeHeartbeatAfterContextFailure(app: "Google Chrome") - == ChromeFallbackHeartbeat(app: "Google Chrome", title: "", url: nil), + !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( + browserHeartbeatAfterContextFailure(app: "Google Chrome") + == BrowserFallbackHeartbeat(app: "Google Chrome", title: "", url: nil), "Chrome context failure must keep app identity and drop title/URL" ) expect( - chromeHeartbeatAfterContextFailure(app: "Brave Browser").title.isEmpty - && chromeHeartbeatAfterContextFailure(app: "Brave Browser").url == nil, + 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") } } From f9377a9dc8eb5e08c5bccbadf72cbf2fea81594c Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 13:22:17 +0000 Subject: [PATCH 4/6] fix(macos): release unused AXObserver when notification add fails rebuildObserver created an AXObserver then returned on AXObserverAddNotification failure without adopting it, leaking the Mach port. Adopt and tear down through the #139 path. Bind foreground identity in the caller so a failed install still emits a heartbeat. Git-Session-Id: 28375f79-1abe-5518-8f8c-75fcc067315a --- aw_watcher_window/macos.swift | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 686a991..2cd4072 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -208,6 +208,9 @@ let encoder: JSONEncoder = { 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() { @@ -606,10 +609,10 @@ class MainThing { if action == .rebuildObserver { debug("Rebuilding AX observer for pid \(pid) from \(source)") rebuildObserver(for: application) - } else { - foregroundApplication = application } - + // Always bind identity here, including when observer install failed: + // heartbeats must still track the live foreground app. + foregroundApplication = application refreshFocusedWindow(for: application) } @@ -639,7 +642,6 @@ class MainThing { func rebuildObserver(for application: NSRunningApplication) { tearDownObserver() - foregroundApplication = application let pid = application.processIdentifier let focusedApp = AXUIElementCreateApplication(pid) @@ -681,6 +683,13 @@ class MainThing { ) guard addResult == .success || addResult == .notificationAlreadyRegistered else { log("Failed to observe focused-window changes for pid \(pid): \(addResult.rawValue)") + // Created but never added to the run loop. Adopt then tear down so the + // Mach receive port is released through the same path as a live + // observer (#139). Caller still sets foregroundApplication so we emit + // a heartbeat; the next poll retries because observer stays nil. + observer = newObserver + observedApp = focusedApp + tearDownObserver() return } @@ -837,9 +846,11 @@ class MainThing { } else if FIREFOX_BROWSERS.contains(applicationName), let window = window { 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 + // 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 { From 90aad7fbb4139ef9aced18fbc0b1ccfa943691e0 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 13:24:57 +0000 Subject: [PATCH 5/6] fix(macos): drop unused AXObserver via ARC and isolate swift tests The add-failure path never installed the observer in the run loop, so tearDownObserver's CFRunLoopRemoveSource was a no-op. Nil the managed reference instead; CFRelease would double-free a Swift-ARC CF type. Write the Swift state-test binary into a mktemp directory so make test does not execute a predictable path in /tmp. Git-Session-Id: 28375f79-1abe-5518-8f8c-75fcc067315a --- Makefile | 8 ++++++-- aw_watcher_window/macos.swift | 11 +++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index ffe968e..2bf13e4 100644 --- a/Makefile +++ b/Makefile @@ -23,8 +23,12 @@ test: fi test-swift: - swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" aw_watcher_window/macos_state.swift tests/macos_state_tests.swift -o /tmp/aw-watcher-window-macos-state-tests - /tmp/aw-watcher-window-macos-state-tests + 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 2cd4072..dc5891c 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -683,13 +683,12 @@ class MainThing { ) guard addResult == .success || addResult == .notificationAlreadyRegistered else { log("Failed to observe focused-window changes for pid \(pid): \(addResult.rawValue)") - // Created but never added to the run loop. Adopt then tear down so the - // Mach receive port is released through the same path as a live - // observer (#139). Caller still sets foregroundApplication so we emit - // a heartbeat; the next poll retries because observer stays nil. + // 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 - observedApp = focusedApp - tearDownObserver() + observer = nil return } From fe2f5c1c7651add28b7fa1a4d2422d7842da2035 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 23 Sep 2026 02:29:12 +0000 Subject: [PATCH 6/6] fix(macos): retry title-change observer after transient AX add failure If AXObserverAddNotification for kAXTitleChangedNotification fails once, keep the focused window but leave registration retryable so the next poll can install it. Poll heartbeats already carry the current AX title; this restores live title-change callbacks after a transient add failure. Git-Session-Id: 2b8b1429-6ecf-575e-b812-491b60c68d9e --- aw_watcher_window/macos.swift | 22 +++++++++++--- aw_watcher_window/macos_state.swift | 18 ++++++++++++ tests/macos_state_tests.swift | 45 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index dc5891c..ba8acba 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -504,6 +504,7 @@ class MainThing { var observedApp: AXUIElement? var foregroundApplication: NSRunningApplication? var oldWindow: AXUIElement? + var titleNotificationRegistered = false var pollingTimer: Timer? var trackedPID: pid_t? { @@ -638,6 +639,7 @@ class MainThing { oldWindow = nil observedApp = nil observer = nil + titleNotificationRegistered = false } func rebuildObserver(for application: NSRunningApplication) { @@ -731,9 +733,16 @@ class MainThing { windowChanged = false } - if windowChanged, let observer = observer { - if let oldWindow = oldWindow { - AXObserverRemoveNotification(observer, oldWindow, kAXTitleChangedNotification as CFString) + 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()) @@ -743,9 +752,14 @@ class MainThing { kAXTitleChangedNotification as CFString, selfPtr ) - if addResult != .success && addResult != .notificationAlreadyRegistered { + 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 } } diff --git a/aw_watcher_window/macos_state.swift b/aw_watcher_window/macos_state.swift index 0627c19..b52c864 100644 --- a/aw_watcher_window/macos_state.swift +++ b/aw_watcher_window/macos_state.swift @@ -41,6 +41,24 @@ func axTitleCallbackBelongsToFocusedWindow( 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 diff --git a/tests/macos_state_tests.swift b/tests/macos_state_tests.swift index 057c169..edb93b2 100644 --- a/tests/macos_state_tests.swift +++ b/tests/macos_state_tests.swift @@ -55,6 +55,51 @@ struct MacOSStateTests { ), "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),