diff --git a/internal/core/infra/axobserver/axobserver.go b/internal/core/infra/axobserver/axobserver.go new file mode 100644 index 000000000..4135cc06b --- /dev/null +++ b/internal/core/infra/axobserver/axobserver.go @@ -0,0 +1,60 @@ +package axobserver + +import ( + "go.uber.org/zap" +) + +// The observer is process-wide: one application is watched at a time, through +// one OS-level observer slot and one registered change callback. The slot, the +// watched pid, and the run-loop thread all live in the platform layer; this +// package is the thin Go face over them. +var observerLogger = zap.NewNop() + +// Init installs the change callback and the logger. onChange fires each time +// the watched application's UI changes; it runs on the observer's callback +// thread, so it must be cheap and must not call Watch or Unwatch, which would +// deadlock against a concurrent teardown. Call Init once at startup, before +// Watch; a later Init replaces the callback. logger may be nil. +func Init(onChange func(), logger *zap.Logger) { + if logger == nil { + logger = zap.NewNop() + } + + observerLogger = logger + platformSetChangeHandler(newChangeHandler(onChange, logger)) +} + +// Watch makes pid the watched application, replacing whatever was watched +// before; the platform tears the previous observer down as part of the switch. +// Watching the pid already watched is a no-op. After a failed watch nothing is +// watched, so a later Watch of the same pid retries from a clean state. +func Watch(pid int) { + if !platformWatch(pid) { + observerLogger.Debug("observer watch failed", zap.Int("pid", pid)) + } +} + +// Unwatch stops watching the current application. It is a no-op when nothing +// is watched. +func Unwatch() { + platformUnwatch() +} + +// Supported reports whether this platform has an observer backend. Where it +// returns false, Watch always fails and no change is ever reported. +func Supported() bool { + return observerSupported +} + +// newChangeHandler builds the function the platform invokes on every observed +// notification: it logs the notification name and forwards to the caller's +// onChange. +func newChangeHandler(onChange func(), logger *zap.Logger) func(notif string) { + return func(notif string) { + logger.Debug("ax notification", zap.String("notif", notif)) + + if onChange != nil { + onChange() + } + } +} diff --git a/internal/core/infra/axobserver/axobserver_test.go b/internal/core/infra/axobserver/axobserver_test.go new file mode 100644 index 000000000..fd967eee1 --- /dev/null +++ b/internal/core/infra/axobserver/axobserver_test.go @@ -0,0 +1,24 @@ +package axobserver + +import ( + "testing" + + "go.uber.org/zap" +) + +func TestChangeHandlerForwardsToOnChange(t *testing.T) { + fired := 0 + handler := newChangeHandler(func() { fired++ }, zap.NewNop()) + + handler("AXCreated") + + if fired != 1 { + t.Fatalf("onChange fired %d times, want 1", fired) + } +} + +func TestChangeHandlerToleratesNilOnChange(t *testing.T) { + handler := newChangeHandler(nil, zap.NewNop()) + + handler("AXCreated") +} diff --git a/internal/core/infra/axobserver/doc.go b/internal/core/infra/axobserver/doc.go new file mode 100644 index 000000000..e02ba6fe2 --- /dev/null +++ b/internal/core/infra/axobserver/doc.go @@ -0,0 +1,12 @@ +// Package axobserver watches a macOS accessibility (AX) tree for structural +// changes and reports when the watched application's UI changed. +// +// The observer is process-wide: at most one application is watched at a time, +// the pid last passed to Watch. The set of notifications the observer +// subscribes to is fixed and defined in the darwin backend. Changes are +// reported through the callback installed with Init. +// +// The package only observes; it does not decide what to do on a change. The +// caller wires the change callback to its own refresh logic. Until Watch is +// called with a valid pid, nothing is armed and no OS resources are held. +package axobserver diff --git a/internal/core/infra/axobserver/main_test.go b/internal/core/infra/axobserver/main_test.go new file mode 100644 index 000000000..675a17b9f --- /dev/null +++ b/internal/core/infra/axobserver/main_test.go @@ -0,0 +1,11 @@ +package axobserver + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/internal/core/infra/axobserver/platform_darwin.go b/internal/core/infra/axobserver/platform_darwin.go new file mode 100644 index 000000000..e1d13c5e3 --- /dev/null +++ b/internal/core/infra/axobserver/platform_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin + +package axobserver + +import ( + "github.com/y3owk1n/neru/internal/core/infra/platform/darwin" +) + +const observerSupported = true + +// observerMessagingTimeoutSeconds bounds the observer's synchronous AX calls to +// the app it watches, so a wedged app cannot hang the observer thread. It is set +// on that app's element only, never process-wide, so hint scanning keeps the +// default timeout. +const observerMessagingTimeoutSeconds = 0.25 + +// The darwin bridge owns the observer, the watched process, and the run-loop +// thread lifecycle: the thread runs only while a process is watched, so an idle +// neru has no observer thread and no background cost. + +func platformWatch(pid int) bool { + return darwin.WatchObserver(pid, observerMessagingTimeoutSeconds) +} + +func platformUnwatch() { + darwin.UnwatchObserver() +} + +func platformSetChangeHandler(handler func(notif string)) { + if handler == nil { + darwin.SetAXObserverHandler(nil) + + return + } + + darwin.SetAXObserverHandler(func(notif string) { + handler(notif) + }) +} diff --git a/internal/core/infra/axobserver/platform_other.go b/internal/core/infra/axobserver/platform_other.go new file mode 100644 index 000000000..1d8bc2165 --- /dev/null +++ b/internal/core/infra/axobserver/platform_other.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package axobserver + +const observerSupported = false + +// Platforms without an AX observer implementation get no-op entry points: every +// watch fails, so nothing is ever watched and no change is ever reported. Hints +// still work, they just do not auto-refresh. +// +// To add push-based auto-refresh for a platform, replace these with a +// build-tagged file (for example platform_linux.go) implementing them against +// the OS accessibility API, mirroring platform_darwin.go. + +func platformWatch(_ int) bool { return false } + +func platformUnwatch() {} + +func platformSetChangeHandler(_ func(notif string)) {} diff --git a/internal/core/infra/axobserver/soak_integration_darwin_test.go b/internal/core/infra/axobserver/soak_integration_darwin_test.go new file mode 100644 index 000000000..165e85215 --- /dev/null +++ b/internal/core/infra/axobserver/soak_integration_darwin_test.go @@ -0,0 +1,114 @@ +//go:build integration && darwin + +package axobserver + +import ( + "os" + "testing" + + "github.com/y3owk1n/neru/internal/core/infra/platform/darwin" +) + +// TestObserverSoakWatchUnwatch drives the real observer bridge through many +// watch/unwatch cycles and asserts that every cycle returns to the idle +// invariant: no Core Foundation object is retained and the run-loop thread is +// stopped. +// +// Registration only succeeds when the test process is accessibility-trusted; a +// plain `go test` process is not, so AXObserverAddNotification is refused and +// the watch tears itself down inside the bridge. The idle invariant must hold +// on that failure path too, which is what this soak pins. When the process is +// trusted, it additionally checks the live-object counts while a process is +// watched. +func TestObserverSoakWatchUnwatch(t *testing.T) { + pid := os.Getpid() + watchedAtLeastOnce := false + + const iterations = 1000 + for i := range iterations { + if darwin.WatchObserver(pid, 0.25) { + watchedAtLeastOnce = true + + if obs, appEl := darwin.ObserverLiveCounts(); obs < 1 || appEl < 1 { + t.Fatalf("iteration %d: watching but live counts obs=%d appEl=%d, want >= 1 each", + i, obs, appEl) + } + + if !darwin.ObserverThreadRunning() { + t.Fatalf("iteration %d: watching but run-loop thread not running", i) + } + + // Re-watching the watched pid is a success no-op: no new objects. + obsBefore, appElBefore := darwin.ObserverLiveCounts() + + if !darwin.WatchObserver(pid, 0.25) { + t.Fatalf("iteration %d: re-watching the watched pid should succeed", i) + } + + if obs, appEl := darwin.ObserverLiveCounts(); obs != obsBefore || appEl != appElBefore { + t.Fatalf("iteration %d: re-watch created objects obs=%d->%d appEl=%d->%d", + i, obsBefore, obs, appElBefore, appEl) + } + } + + darwin.UnwatchObserver() + + if obs, appEl := darwin.ObserverLiveCounts(); obs != 0 || appEl != 0 { + t.Fatalf( + "iteration %d: live counts after unwatch obs=%d appEl=%d, want 0", + i, + obs, + appEl, + ) + } + + if darwin.ObserverThreadRunning() { + t.Fatalf("iteration %d: run-loop thread still running at idle", i) + } + } + + if watchedAtLeastOnce { + t.Logf( + "soak done: %d cycles, including the watched live-object balance assertion", + iterations, + ) + } else { + t.Logf("soak done: %d cycles of teardown and thread lifecycle only; the watched "+ + "live-object balance was NOT checked because this process is not "+ + "accessibility-trusted, so run under a trusted binary to cover it", iterations) + } +} + +// TestWatchRejectsInvalidPID confirms a watch for a pid that cannot name a +// process fails and leaks nothing. +func TestWatchRejectsInvalidPID(t *testing.T) { + if darwin.WatchObserver(0, 0.25) { + t.Error("watch with pid 0 should fail") + } + + if darwin.WatchObserver(-1, 0.25) { + t.Error("watch with a negative pid should fail") + } + + if obs, appEl := darwin.ObserverLiveCounts(); obs != 0 || appEl != 0 { + t.Errorf("rejected watch live counts obs=%d appEl=%d, want 0", obs, appEl) + } + + if darwin.ObserverThreadRunning() { + t.Error("run-loop thread should not be running after rejected watches") + } +} + +// TestObserverTeardownWhenIdle confirms unwatch is safe to call with nothing +// watched and no thread running. +func TestObserverTeardownWhenIdle(t *testing.T) { + darwin.UnwatchObserver() + + if obs, appEl := darwin.ObserverLiveCounts(); obs != 0 || appEl != 0 { + t.Fatalf("live counts at idle obs=%d appEl=%d, want 0", obs, appEl) + } + + if darwin.ObserverThreadRunning() { + t.Fatal("run-loop thread should not be running at idle") + } +} diff --git a/internal/core/infra/platform/darwin/axobserver.go b/internal/core/infra/platform/darwin/axobserver.go new file mode 100644 index 000000000..6f33b7fac --- /dev/null +++ b/internal/core/infra/platform/darwin/axobserver.go @@ -0,0 +1,63 @@ +//go:build darwin + +package darwin + +/* +#include "axobserver.h" +*/ +import "C" + +// AXObserverNotificationHandler receives the name of the accessibility +// notification that fired (for debug logging). +type AXObserverNotificationHandler func(notif string) + +var axObserverHandlerSlot cgoSlot[AXObserverNotificationHandler] + +// SetAXObserverHandler registers the process-global observer callback. Passing +// nil clears it and drops any in-flight callback via the slot's generation. +func SetAXObserverHandler(handler AXObserverNotificationHandler) { + axObserverHandlerSlot.Set(handler) +} + +// ObserverThreadRunning reports whether the observer run-loop thread is running. +func ObserverThreadRunning() bool { + return C.NeruObserverThreadRunning() != 0 +} + +// WatchObserver makes pid the watched process: it arms an AXObserver on pid for +// the fixed accessibility notification set, tearing down whatever was watched +// before, and reports whether the watch succeeded. On failure nothing is +// watched afterward, so a later call retries from a clean state. +// messagingTimeout (seconds, ignored when <= 0) bounds this observer's +// synchronous AX calls to the target app; it is scoped to that app's element, +// not process-wide. +func WatchObserver(pid int, messagingTimeout float64) bool { + return C.NeruObserverWatch(C.int(pid), C.float(messagingTimeout)) != 0 +} + +// UnwatchObserver stops watching: it tears down the watched observer, if any, +// and stops the run-loop thread. Safe to call when nothing is watched. +func UnwatchObserver() { + C.NeruObserverUnwatch() +} + +// ObserverLiveCounts returns the created-minus-released counts of AXObserver and +// application-element refs, for leak-balance assertions in tests. Both are zero +// at idle. +func ObserverLiveCounts() (observers, appElements int64) { + return int64(C.NeruObserverLiveObserverCount()), + int64(C.NeruObserverLiveAppElementCount()) +} + +//export handleAXObserverNotification +func handleAXObserverNotification(notif *C.char) { + dispatchAXObserverNotification(C.GoString(notif)) +} + +// dispatchAXObserverNotification routes a notification name through the +// registered handler. +func dispatchAXObserverNotification(notif string) { + axObserverHandlerSlot.withValid(func(handler AXObserverNotificationHandler) { + handler(notif) + }) +} diff --git a/internal/core/infra/platform/darwin/axobserver.h b/internal/core/infra/platform/darwin/axobserver.h new file mode 100644 index 000000000..de13c04f5 --- /dev/null +++ b/internal/core/infra/platform/darwin/axobserver.h @@ -0,0 +1,53 @@ +// +// axobserver.h +// Neru +// +// Copyright © 2025 Neru. All rights reserved. +// +// Push-based accessibility change notifications for a single watched +// application. A dedicated CFRunLoop thread services the observer's callbacks; +// this layer owns the observer, the application element, and the thread +// lifecycle. Watch, unwatch, and every AX create/register/release are +// marshalled onto the run-loop thread, so they are serialized against the +// observer's own callbacks (the run loop services one thing at a time). That +// is what makes it safe to release an AXObserver — a callback for it can never +// be running concurrently — and keeps a synchronous AX call that hangs on the +// observer thread, never on a caller's lock. +// + +#ifndef AXOBSERVER_H +#define AXOBSERVER_H + +#pragma mark - Watch / unwatch + +// Watch pid: start the run-loop thread if needed, create an AXObserver for pid, +// register the fixed notification set (defined in axobserver_darwin.m) on the +// application element, and make it the watched process, tearing down whatever +// was watched before. Watching the pid already watched is a success no-op. +// Returns non-zero on success. On failure nothing is watched afterward and the +// thread is stopped, so a later watch retries from a clean state. +// +// messagingTimeout (seconds, ignored when <= 0) bounds the synchronous AX calls +// this observer makes to the target app, so a wedged app cannot hang the +// observer thread indefinitely. It is set on this app's element only, never +// process-wide, so unrelated accessibility work keeps the default timeout. +int NeruObserverWatch(int pid, float messagingTimeout); + +// Stop watching: tear down the watched observer, if any, and stop the run-loop +// thread. Safe to call when nothing is watched. +void NeruObserverUnwatch(void); + +#pragma mark - Test hooks + +// Report whether the observer run-loop thread is running. +int NeruObserverThreadRunning(void); + +// Live count of created-minus-released AXObserver refs. Zero at idle; a +// non-zero idle value is a leak. +long NeruObserverLiveObserverCount(void); + +// Live count of created-minus-released application AXUIElement refs. Zero at +// idle; a non-zero idle value is a leak. +long NeruObserverLiveAppElementCount(void); + +#endif /* AXOBSERVER_H */ diff --git a/internal/core/infra/platform/darwin/axobserver_bridge_test.go b/internal/core/infra/platform/darwin/axobserver_bridge_test.go new file mode 100644 index 000000000..9fb56dc6f --- /dev/null +++ b/internal/core/infra/platform/darwin/axobserver_bridge_test.go @@ -0,0 +1,40 @@ +//go:build darwin + +package darwin_test + +import ( + "testing" + + "github.com/y3owk1n/neru/internal/core/infra/platform/darwin" +) + +func TestAXObserverHandlerDispatch(t *testing.T) { + gotNotif := "" + calls := 0 + + darwin.SetAXObserverHandler(func(notif string) { + gotNotif = notif + calls++ + }) + t.Cleanup(func() { darwin.SetAXObserverHandler(nil) }) + + darwin.HandleAXObserverNotification("AXCreated") + + if gotNotif != "AXCreated" || calls != 1 { + t.Fatalf("handler got notif=%q calls=%d, want notif=AXCreated calls=1", gotNotif, calls) + } +} + +func TestAXObserverHandlerClearedDropsCallback(t *testing.T) { + calls := 0 + + darwin.SetAXObserverHandler(func(string) { calls++ }) + darwin.HandleAXObserverNotification("AXCreated") + + darwin.SetAXObserverHandler(nil) + darwin.HandleAXObserverNotification("AXCreated") + + if calls != 1 { + t.Fatalf("handler fired %d times, want 1 (the callback after clear must be dropped)", calls) + } +} diff --git a/internal/core/infra/platform/darwin/axobserver_darwin.m b/internal/core/infra/platform/darwin/axobserver_darwin.m new file mode 100644 index 000000000..d39658130 --- /dev/null +++ b/internal/core/infra/platform/darwin/axobserver_darwin.m @@ -0,0 +1,392 @@ +// +// axobserver_darwin.m +// Neru +// +// Copyright © 2025 Neru. All rights reserved. +// + +#import "axobserver.h" + +#import +#import +#include +#include +#include +#include +#include +#include + +#pragma mark - Go bridge + +// Forward one notification to Go. Declared as a //export in axobserver.go. Runs +// on the observer run-loop thread, so it must do O(1) work only — no AX calls, +// no blocking. notif is the notification name, for debug logging. +extern void handleAXObserverNotification(const char *notif); + +#pragma mark - The watched application + +// The one watched process. This layer watches a single application at a time: +// NeruObserverWatch installs a new observer here and tears down whatever was +// watched before, NeruObserverUnwatch empties it. All reads and writes happen +// on the run-loop thread (marshalled through neruRunOnLoop), except the +// occupancy checks in NeruObserverWatch/NeruObserverUnwatch, which run on the +// caller thread strictly after the marshalled block completed. +typedef struct { + AXObserverRef observer; + AXUIElementRef appElement; + int pid; +} NeruWatchedApp; + +static NeruWatchedApp gWatchedApp; + +#pragma mark - Leak counters + +static _Atomic long gLiveObservers = 0; +static _Atomic long gLiveAppElements = 0; + +long NeruObserverLiveObserverCount(void) { return atomic_load(&gLiveObservers); } + +long NeruObserverLiveAppElementCount(void) { return atomic_load(&gLiveAppElements); } + +#pragma mark - Run-loop thread + +static pthread_t gThread; +static CFRunLoopRef gRunLoop = NULL; // retained while the thread runs +static CFRunLoopSourceRef gKeepAlive = NULL; // keeps the loop from exiting when idle +static CFRunLoopObserverRef gEntryObserver = NULL; // signals gReady on loop entry +static dispatch_semaphore_t gReady = NULL; +static int gRunning = 0; + +// Run block on the run-loop thread and wait for it to finish. Every AX +// create/register/release and run-loop source mutation goes through here, so +// they are serialized against the observer callbacks. If the loop is not up yet, +// or the caller already is the run-loop thread, the block runs inline. +static void neruRunOnLoop(void (^block)(void)) { + if (gRunLoop == NULL || CFRunLoopGetCurrent() == gRunLoop) { + block(); + + return; + } + + dispatch_semaphore_t done = dispatch_semaphore_create(0); + CFRunLoopPerformBlock(gRunLoop, kCFRunLoopDefaultMode, ^{ + block(); + dispatch_semaphore_signal(done); + }); + CFRunLoopWakeUp(gRunLoop); + dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); +} + +// A no-op source that keeps CFRunLoopRun blocked even when no observer sources +// are attached, so the thread stays alive between watches and exits only on an +// explicit CFRunLoopStop. +static void neruKeepAlivePerform(void *info) { (void)info; } + +// Fires once, when the run loop is actually entered, so the thread start +// returns only after the loop is genuinely running. +static void neruLoopEntered(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { + (void)observer; + (void)activity; + (void)info; + dispatch_semaphore_signal(gReady); +} + +static void *neruThreadMain(void *arg) { + (void)arg; + + @autoreleasepool { + pthread_setname_np("com.neru.axobserver"); + + CFRunLoopRef rl = CFRunLoopGetCurrent(); + + CFRunLoopSourceContext ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.perform = neruKeepAlivePerform; + gKeepAlive = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &ctx); + CFRunLoopAddSource(rl, gKeepAlive, kCFRunLoopDefaultMode); + + // Publish the run loop (retained) so other threads can add/remove sources + // and stop it; CFRunLoopGetCurrent returns a non-owned reference. + gRunLoop = (CFRunLoopRef)CFRetain(rl); + + gEntryObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopEntry, false, 0, neruLoopEntered, NULL); + CFRunLoopAddObserver(rl, gEntryObserver, kCFRunLoopDefaultMode); + + CFRunLoopRun(); + + CFRunLoopRemoveObserver(rl, gEntryObserver, kCFRunLoopDefaultMode); + CFRelease(gEntryObserver); + gEntryObserver = NULL; + CFRunLoopRemoveSource(rl, gKeepAlive, kCFRunLoopDefaultMode); + CFRelease(gKeepAlive); + gKeepAlive = NULL; + } + + return NULL; +} + +// Start the run-loop thread if it is not already running, blocking until the +// loop is live so a subsequent watch has a loop to attach to. +static void neruStartThreadIfNeeded(void) { + if (gRunning) { + return; + } + + gReady = dispatch_semaphore_create(0); + gRunning = 1; + + if (pthread_create(&gThread, NULL, neruThreadMain, NULL) != 0) { + // The thread never starts, so gReady would never be signaled. Leave the + // subsystem stopped so NeruObserverWatch fails cleanly instead of blocking + // forever on the wait below. + gRunning = 0; + gReady = NULL; + + return; + } + + dispatch_semaphore_wait(gReady, DISPATCH_TIME_FOREVER); + gReady = NULL; +} + +// Stop and join the run-loop thread when nothing is watched. Runs on the caller +// thread, never inside a neruRunOnLoop block: stopping joins the run-loop +// thread, and a join issued from that thread would deadlock against itself. +static void neruStopThreadIfIdle(void) { + if (!gRunning || gWatchedApp.observer != NULL) { + return; + } + + CFRunLoopStop(gRunLoop); + CFRunLoopWakeUp(gRunLoop); + pthread_join(gThread, NULL); + + CFRelease(gRunLoop); + gRunLoop = NULL; + gRunning = 0; +} + +int NeruObserverThreadRunning(void) { return gRunning; } + +#pragma mark - Callback (runs on the run-loop thread) + +static void neruObserverCallback( + AXObserverRef observer, AXUIElementRef element, CFStringRef notification, CFDictionaryRef info, void *refcon) { + (void)observer; + (void)element; + (void)info; + (void)refcon; + + char nameBuf[128]; + const char *name = ""; + if (notification != NULL && CFStringGetCString(notification, nameBuf, sizeof(nameBuf), kCFStringEncodingUTF8)) { + name = nameBuf; + } + + handleAXObserverNotification(name); +} + +#pragma mark - Watched notifications + +// The notifications every observer registers on the application element; +// descendant elements' notifications bubble up to it. The set covers the +// structural changes that mean the UI actually changed (an element or window +// appeared, moved, or vanished; a page finished loading; a menu opened or +// closed; focus moved), plus the signals browsers post for web content, where +// Chromium and Firefox emit no plain "created" notification (a live region +// updating or being created, a disclosure or row expanding or collapsing, a +// busy flag clearing). Value-change notifications such as AXValueChanged must +// stay out of this list: they fire on every value update (a ticking clock, a +// progress bar) and would wake the observer continuously. +// +// The names are written as their literal string values so the array can be a +// compile-time constant (the SDK's kAX* symbols are runtime-initialized externs +// a static initializer cannot reference). The standard names are the values of +// the kAX*Notification constants Apple defines in AXNotificationConstants.h: +// https://developer.apple.com/documentation/applicationservices/axnotificationconstants_h/miscellaneous_defines +// AXLoadComplete, AXLiveRegionChanged, AXLiveRegionCreated, and AXExpandedChanged +// have no public constant; they are the strings browser engines post. +static const CFStringRef gNotificationNames[] = { + CFSTR("AXCreated"), CFSTR("AXUIElementDestroyed"), + CFSTR("AXLayoutChanged"), CFSTR("AXWindowCreated"), + CFSTR("AXWindowMoved"), CFSTR("AXWindowResized"), + CFSTR("AXLoadComplete"), CFSTR("AXMenuOpened"), + CFSTR("AXMenuClosed"), CFSTR("AXFocusedUIElementChanged"), + CFSTR("AXLiveRegionChanged"), CFSTR("AXLiveRegionCreated"), + CFSTR("AXExpandedChanged"), CFSTR("AXRowExpanded"), + CFSTR("AXRowCollapsed"), CFSTR("AXElementBusyChanged"), +}; + +static const int gNotificationNameCount = (int)(sizeof(gNotificationNames) / sizeof(gNotificationNames[0])); + +// Report whether pid still names a live process, so teardown can skip the +// notification-unregister IPC to a process that has already exited. kill with +// signal 0 delivers no signal at all: it only performs the existence and +// permission check, so nothing is killed or disturbed. +static int neruProcessAlive(int pid) { return kill(pid, 0) == 0 || errno != ESRCH; } + +#pragma mark - Watch / unwatch (watched-app mutations run on the run-loop thread) + +// Tear down one observer: remove its run-loop source, unregister the watched +// notifications when the process is still alive, and release everything. The +// unregister loop walks the same fixed name list registration offers; a name +// the app never accepted returns a harmless not-registered error. It bails on +// the first error that means the app is gone or wedged, so a beachballing app +// costs at most one messaging timeout here instead of one per name. +static void neruTeardownOnLoop(NeruWatchedApp watched) { + CFRunLoopSourceRef src = AXObserverGetRunLoopSource(watched.observer); + if (gRunLoop != NULL && src != NULL) { + CFRunLoopRemoveSource(gRunLoop, src, kCFRunLoopDefaultMode); + } + + if (neruProcessAlive(watched.pid)) { + for (int i = 0; i < gNotificationNameCount; i++) { + AXError removeErr = + AXObserverRemoveNotification(watched.observer, watched.appElement, gNotificationNames[i]); + if (removeErr == kAXErrorInvalidUIElement || removeErr == kAXErrorCannotComplete) { + break; + } + } + } + + CFRelease(watched.observer); + atomic_fetch_sub(&gLiveObservers, 1); + CFRelease(watched.appElement); + atomic_fetch_sub(&gLiveAppElements, 1); +} + +// Switch the watched application to pid: build and register the new observer first, install +// it, then tear down the previously watched one, so the run loop never sits +// with zero observers mid-switch. On failure nothing is watched afterward and the +// previous observer is torn down too, so a later watch of the same pid retries +// from a clean state. +static int neruWatchOnLoop(int pid, float messagingTimeout) { + // Watching the pid already watched is a success no-op, so a caller can + // re-point the observer on every refresh without tearing down and rebuilding + // the observer each time. + if (gWatchedApp.observer != NULL && gWatchedApp.pid == pid) { + return 1; + } + + NeruWatchedApp prev = gWatchedApp; + int hadPrev = gWatchedApp.observer != NULL; + + gWatchedApp.observer = NULL; + gWatchedApp.appElement = NULL; + gWatchedApp.pid = 0; + + AXUIElementRef appEl = AXUIElementCreateApplication((pid_t)pid); + if (appEl == NULL) { + if (hadPrev) { + neruTeardownOnLoop(prev); + } + + return 0; + } + atomic_fetch_add(&gLiveAppElements, 1); + + // Bound this app's synchronous AX calls (the registrations below and the + // teardown's unregistrations) so a wedged app cannot hang the observer + // thread. Scoped to this element, so unrelated accessibility work keeps the + // default timeout. + if (messagingTimeout > 0) { + AXUIElementSetMessagingTimeout(appEl, messagingTimeout); + } + + AXObserverRef observer = NULL; + AXError createErr = AXObserverCreateWithInfoCallback((pid_t)pid, neruObserverCallback, &observer); + if (createErr != kAXErrorSuccess || observer == NULL) { + CFRelease(appEl); + atomic_fetch_sub(&gLiveAppElements, 1); + + if (hadPrev) { + neruTeardownOnLoop(prev); + } + + return 0; + } + atomic_fetch_add(&gLiveObservers, 1); + + int registered = 0; + int fatal = 0; + for (int i = 0; i < gNotificationNameCount; i++) { + AXError addErr = AXObserverAddNotification(observer, appEl, gNotificationNames[i], NULL); + if (addErr == kAXErrorSuccess || addErr == kAXErrorNotificationAlreadyRegistered) { + registered++; + } else if (addErr == kAXErrorInvalidUIElement || addErr == kAXErrorCannotComplete) { + // The app is gone or wedged. Abort the whole watch rather than + // registering a partial, unreliable set. + fatal = 1; + break; + } + // kAXErrorNotificationUnsupported and other soft failures: an app that + // does not emit this notification is still worth observing for the rest. + } + + if (fatal || registered == 0) { + CFRelease(observer); + atomic_fetch_sub(&gLiveObservers, 1); + CFRelease(appEl); + atomic_fetch_sub(&gLiveAppElements, 1); + + if (hadPrev) { + neruTeardownOnLoop(prev); + } + + return 0; + } + + CFRunLoopAddSource(gRunLoop, AXObserverGetRunLoopSource(observer), kCFRunLoopDefaultMode); + + gWatchedApp.observer = observer; + gWatchedApp.appElement = appEl; + gWatchedApp.pid = pid; + + if (hadPrev) { + neruTeardownOnLoop(prev); + } + + return 1; +} + +int NeruObserverWatch(int pid, float messagingTimeout) { + if (pid <= 0) { + return 0; + } + + neruStartThreadIfNeeded(); + + if (!gRunning || gRunLoop == NULL) { + return 0; + } + + __block int ok = 0; + neruRunOnLoop(^{ + ok = neruWatchOnLoop(pid, messagingTimeout); + }); + + neruStopThreadIfIdle(); + + return ok; +} + +void NeruObserverUnwatch(void) { + if (!gRunning) { + return; + } + + neruRunOnLoop(^{ + if (gWatchedApp.observer == NULL) { + return; + } + + NeruWatchedApp watched = gWatchedApp; + gWatchedApp.observer = NULL; + gWatchedApp.appElement = NULL; + gWatchedApp.pid = 0; + + neruTeardownOnLoop(watched); + }); + + neruStopThreadIfIdle(); +} diff --git a/internal/core/infra/platform/darwin/axobserver_export_test.go b/internal/core/infra/platform/darwin/axobserver_export_test.go new file mode 100644 index 000000000..67fc054dd --- /dev/null +++ b/internal/core/infra/platform/darwin/axobserver_export_test.go @@ -0,0 +1,10 @@ +//go:build darwin + +package darwin + +// HandleAXObserverNotification synthesizes an observer notification, +// dispatching it through the registered handler. It lets the callback wiring be +// tested without a live accessibility notification. +func HandleAXObserverNotification(notif string) { + dispatchAXObserverNotification(notif) +}