// 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" "unsafe" ) func (adapter *Adapter) Start(ctx context.Context) error { if !adapter.running.CompareAndSwap(false, true) { return errors.New("AT-SPI adapter already started") } adapter.loopDone = make(chan struct{}) setupDone := make(chan error, 1) go func() { runtime.LockOSThread() defer runtime.UnlockOSThread() 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) } adapter.foregroundReader.Close() adapter.foregroundReader = nil for _, focus := range adapter.nativeFocus { C.g_object_unref(C.gpointer(focus.source)) } adapter.nativeFocus = nil for _, known := range adapter.documentWindows { C.g_object_unref(C.gpointer(known.source)) } adapter.documentWindows = nil adapter.documentWindowIDs = nil adapter.clearNativeDocument() adapter.clearNativeReview() adapter.clearMouseApplication() close(adapter.loopDone) }() select { case err := <-setupDone: if err != nil { adapter.running.Store(false) return err } if err := ctx.Err(); err != nil { C.atspi_event_quit() <-adapter.loopDone adapter.running.Store(false) return err } adapter.ready.Store(true) return nil case <-ctx.Done(): err := <-setupDone if err != nil { adapter.running.Store(false) return err } C.atspi_event_quit() <-adapter.loopDone adapter.running.Store(false) return ctx.Err() } } func (adapter *Adapter) initialize() error { if result := C.atspi_init(); result != 0 { return fmt.Errorf("initialize AT-SPI: result %d", int(result)) } // 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() C.atspi_exit() return errors.New("allocate AT-SPI callback data") } adapter.callbackHandle = callbackHandle adapter.callbackData = callbackData for _, eventType := range eventTypes { if err := adapter.registerEvent(eventType); err != nil { if cleanupErr := adapter.deregisterEvents(); cleanupErr != nil { err = errors.Join(err, cleanupErr) } C.cthulhu_callback_data_free(callbackData) adapter.callbackHandle = 0 adapter.callbackData = nil C.atspi_exit() 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_from_callback( C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), unsafe.Pointer(adapter.callbackData), nil, 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_from_callback( C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), unsafe.Pointer(adapter.callbackData), 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) } } return stopErr } 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 C.cthulhu_request_event_loop_quit() == 0 { stopErr = errors.New("request AT-SPI event loop quit") } select { case <-adapter.loopDone: case <-ctx.Done(): return errors.Join(stopErr, fmt.Errorf("wait for AT-SPI event loop: %w", ctx.Err())) } if result := C.atspi_exit(); result != 0 { stopErr = errors.Join(stopErr, fmt.Errorf("exit AT-SPI: result %d", int(result))) } C.cthulhu_callback_data_free(adapter.callbackData) adapter.callbackData = nil adapter.callbackHandle.Delete() adapter.callbackHandle = 0 adapter.running.Store(false) adapter.invalidateTree() return stopErr }