// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi /* #include "adapter_native.h" */ import "C" import ( "context" "errors" "fmt" "git.stormux.org/storm/cthulhu/internal/platform/session" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "runtime" "runtime/cgo" "sync" "unsafe" ) // libatspi and its default GLib loop are process globals. Adapters may take // turns owning them, but must never run concurrently. atspi_exit is reserved // for process exit: its caches do not support an exit/init cycle. var nativeAdapterOwner sync.Mutex var nativeAdapterInit sync.Once var nativeAdapterInitError error func (adapter *Adapter) Start(ctx context.Context) error { adapter.stopMutex.Lock() defer adapter.stopMutex.Unlock() if !adapter.running.CompareAndSwap(false, true) { return errors.New("AT-SPI adapter already started") } if !nativeAdapterOwner.TryLock() { adapter.running.Store(false) return errors.New("another AT-SPI adapter is running") } adapter.echoFocus.Store(nil) adapter.loopError = nil adapter.stopRequested = false adapter.loopDone = make(chan struct{}) setupDone := make(chan error, 1) go func() { runtime.LockOSThread() defer runtime.UnlockOSThread() defer close(adapter.loopDone) defer nativeAdapterOwner.Unlock() if err := adapter.initialize(); err != nil { setupDone <- err return } // Xwayland only describes X clients. Its empty or stale foreground // must not veto native Wayland windows; use AT-SPI ownership there. if !session.Wayland() { adapter.foregroundReader = xorg.OpenForegroundReader() } adapter.promptRefreshWindow = ObjectID{} adapter.promptScannedWindow = ObjectID{} adapter.promptScannedEpoch = 0 adapter.refreshForeground() adapter.refreshWindowTitle() timer := C.start_foreground_timer(C.gpointer(adapter.callbackData)) setupDone <- nil C.atspi_event_main() if timer != 0 { C.g_source_remove(timer) } // Deregistration can dispatch queued D-Bus events synchronously. Keep // foreground state and retained accessibles alive until it finishes. adapter.loopError = adapter.releaseNativeCallbacks() adapter.foregroundReader.Close() adapter.foregroundReader = nil for _, focus := range adapter.nativeFocus { C.g_object_unref(C.gpointer(focus.source)) } adapter.nativeFocus = nil adapter.echoFocus.Store(nil) adapter.libreOfficeHeaders.reset() for _, known := range adapter.documentWindows { C.g_object_unref(C.gpointer(known.source)) } adapter.documentWindows = nil adapter.documentWindowIDs = nil adapter.clearStashedDocuments() adapter.clearNativeDocument() adapter.deferredLiveRegions = nil adapter.clearNativeReview() adapter.clearMouseApplication() }() select { case err := <-setupDone: if err != nil { <-adapter.loopDone adapter.running.Store(false) return err } if err := ctx.Err(); err != nil { C.cthulhu_request_event_loop_quit() <-adapter.loopDone adapter.running.Store(false) return err } adapter.ready.Store(true) return nil case <-ctx.Done(): err := <-setupDone if err != nil { <-adapter.loopDone adapter.running.Store(false) return err } C.cthulhu_request_event_loop_quit() <-adapter.loopDone adapter.running.Store(false) return ctx.Err() } } func (adapter *Adapter) initialize() error { nativeAdapterInit.Do(func() { if result := C.atspi_init(); result > 1 { nativeAdapterInitError = fmt.Errorf("initialize AT-SPI: result %d", int(result)) } }) if nativeAdapterInitError != nil { return nativeAdapterInitError } // libatspi's ordinary default is 800 ms, but its startup grace period is // much longer. Keep both bounded so a newly connected or wedged provider // cannot indefinitely hold the event-loop thread during owned-tree capture. C.atspi_set_timeout(800, 800) callbackHandle := cgo.NewHandle(adapter) callbackData := C.cthulhu_callback_data_new(C.uintptr_t(callbackHandle)) if callbackData == nil { callbackHandle.Delete() return errors.New("allocate AT-SPI callback data") } adapter.callbackHandle = callbackHandle adapter.callbackData = callbackData // Own one listener for all subscriptions, as Orca does. The callback // convenience API lost mouse delivery when mixed with object subscriptions // in the private native probe. adapter.eventListener = C.atspi_event_listener_new( C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), C.gpointer(callbackData), nil) if adapter.eventListener == nil { C.cthulhu_callback_data_free(callbackData) adapter.callbackHandle = 0 adapter.callbackData = nil callbackHandle.Delete() return errors.New("allocate AT-SPI event listener") } for _, eventType := range eventTypes { if err := adapter.registerEvent(eventType); err != nil { if cleanupErr := adapter.releaseNativeCallbacks(); cleanupErr != nil { err = errors.Join(err, cleanupErr) } return err } } return nil } func (adapter *Adapter) registerEvent(eventType string) error { nativeEventType := C.CString(eventType) var registerError *C.GError registered := C.atspi_event_listener_register(adapter.eventListener, nativeEventType, ®isterError) C.free(unsafe.Pointer(nativeEventType)) if registerError != nil { err := nativeError("register AT-SPI event listener "+eventType, registerError) C.g_error_free(registerError) return err } if registered == 0 { return errors.New("register AT-SPI event listener " + eventType) } return nil } func (adapter *Adapter) deregisterEvents() error { var stopErr error for _, eventType := range eventTypes { nativeEventType := C.CString(eventType) var deregisterError *C.GError C.atspi_event_listener_deregister(adapter.eventListener, nativeEventType, &deregisterError) C.free(unsafe.Pointer(nativeEventType)) if deregisterError != nil { stopErr = errors.Join(stopErr, nativeError("deregister AT-SPI event listener "+eventType, deregisterError)) C.g_error_free(deregisterError) } } if adapter.eventListener != nil { C.g_object_unref(C.gpointer(adapter.eventListener)) adapter.eventListener = nil } return stopErr } // Called only by the GLib owner, after dispatch has stopped and request callers // have been excluded. Destroy pending idle captures before releasing their // callback data/handle; deregistration prevents later adapters reaching us. func (adapter *Adapter) releaseNativeCallbacks() error { err := adapter.deregisterEvents() C.cthulhu_callback_data_free(adapter.callbackData) adapter.callbackData = nil if adapter.callbackHandle != 0 { adapter.callbackHandle.Delete() adapter.callbackHandle = 0 } for len(adapter.treeRequests) != 0 { request := <-adapter.treeRequests request.result <- treeCaptureResult{err: errors.New("AT-SPI adapter stopped")} } return err } func (adapter *Adapter) Stop(ctx context.Context) error { adapter.stopMutex.Lock() defer adapter.stopMutex.Unlock() if !adapter.running.Load() { return nil } adapter.ready.Store(false) var stopErr error // AT-SPI documents event-loop quit as an event-handler operation. Schedule // it on the default GLib context owned by atspi_event_main instead of calling // it synchronously from this arbitrary shutdown goroutine. if !adapter.stopRequested { if C.cthulhu_request_event_loop_quit() == 0 { stopErr = errors.New("request AT-SPI event loop quit") } else { adapter.stopRequested = true } } select { case <-adapter.loopDone: case <-ctx.Done(): return errors.Join(stopErr, fmt.Errorf("wait for AT-SPI event loop: %w", ctx.Err())) } stopErr = errors.Join(stopErr, adapter.loopError) adapter.running.Store(false) adapter.invalidateTree() return stopErr }