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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions internal/core/infra/axobserver/axobserver.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
24 changes: 24 additions & 0 deletions internal/core/infra/axobserver/axobserver_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
12 changes: 12 additions & 0 deletions internal/core/infra/axobserver/doc.go
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions internal/core/infra/axobserver/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package axobserver

import (
"testing"

"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
39 changes: 39 additions & 0 deletions internal/core/infra/axobserver/platform_darwin.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
19 changes: 19 additions & 0 deletions internal/core/infra/axobserver/platform_other.go
Original file line number Diff line number Diff line change
@@ -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)) {}
114 changes: 114 additions & 0 deletions internal/core/infra/axobserver/soak_integration_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
63 changes: 63 additions & 0 deletions internal/core/infra/platform/darwin/axobserver.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading