// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package atspi import ( "context" "errors" "fmt" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "log/slog" "runtime/cgo" "sync" "sync/atomic" "time" "unsafe" ) /* #cgo pkg-config: atspi-2 gobject-2.0 #include "adapter_native.h" */ import "C" const eventQueueCapacity = 64 const ( FocusEvent = "focus:" FocusedStateEvent = "object:state-changed:focused" WindowActivatedEvent = "window:activate" WindowDeactivatedEvent = "window:deactivate" WindowDestroyedEvent = "window:destroy" WindowCreateEvent = "window:create" CaretEvent = "object:text-caret-moved" MouseButtonEvent = "mouse:button" TextSelectionChangedEvent = "object:text-selection-changed" TextInsertedEvent = "object:text-changed:insert" TextDeletedEvent = "object:text-changed:delete" ValueChangedEvent = "object:value-changed" AccessibleValueEvent = "object:property-change:accessible-value" AccessibleNameEvent = "object:property-change:accessible-name" ActiveDescendantEvent = "object:active-descendant-changed" SelectionChangedEvent = "object:selection-changed" SelectedStateEvent = "object:state-changed:selected" ChildrenChangedEvent = "object:children-changed" ChildrenAddedEvent = "object:children-changed:add" ChildrenRemovedEvent = "object:children-changed:remove" DefunctStateEvent = "object:state-changed:defunct" BoundsChangedEvent = "object:bounds-changed" VisibleStateEvent = "object:state-changed:visible" ShowingStateEvent = "object:state-changed:showing" TextBoundsChangedEvent = "object:text-bounds-changed" TextAttributesChangedEvent = "object:text-attributes-changed" DocumentReloadEvent = "document:reload" DocumentLoadCompleteEvent = "document:load-complete" DocumentLoadStoppedEvent = "document:load-stopped" BusyStateEvent = "object:state-changed:busy" AnnouncementEvent = "object:announcement" ) var eventTypes = []string{ FocusEvent, FocusedStateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, WindowCreateEvent, CaretEvent, TextSelectionChangedEvent, TextInsertedEvent, TextDeletedEvent, ValueChangedEvent, AccessibleValueEvent, AccessibleNameEvent, ActiveDescendantEvent, SelectionChangedEvent, ChildrenChangedEvent, DefunctStateEvent, BoundsChangedEvent, VisibleStateEvent, ShowingStateEvent, TextBoundsChangedEvent, TextAttributesChangedEvent, DocumentReloadEvent, DocumentLoadCompleteEvent, DocumentLoadStoppedEvent, BusyStateEvent, AnnouncementEvent, "object:state-changed:checked", "object:state-changed:expanded", "object:state-changed:indeterminate", "object:state-changed:invalid-entry", "object:state-changed:pressed", "object:state-changed:read-only", "object:state-changed:editable", "object:state-changed:required", SelectedStateEvent, "object:state-changed:sensitive", "object:state-changed:visited", MouseButtonEvent, } type Adapter struct { captureLogger atomic.Pointer[slog.Logger] libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop applicationProcesses map[string]string // GLib owner; bounded provider identity cache webNotificationRoots map[ObjectID]bool // copied identities only; no native references deferredLiveRegions deferredLiveRegionStore // GLib owner; copied identities only foregroundReader *xorg.ForegroundReader // GLib thread only foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client foreground atomic.Pointer[ForegroundState] preferencesPID atomic.Int64 // Owned preferences helper; zero when closed. echoFocus atomic.Pointer[echoFocus] eventListener *C.AtspiEventListener // GLib owner; released with subscriptions. foregroundChanges chan struct{} foregroundTitle atomic.Pointer[ForegroundTitle] titleChanges chan struct{} foregroundWindow ObjectID // GLib thread: correlated accessible for current X client promptRefreshWindow ObjectID // GLib thread: frame activated before its prompt label appeared promptScannedWindow ObjectID // GLib thread: avoid rescanning repeated activations promptScannedEpoch uint64 foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI recoveringForeground bool events chan Event treeRequests chan treeCaptureRequest overflow atomic.Uint64 treeInvalidations atomic.Uint64 backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries secureMutex sync.Mutex secureContext context.Context secureCancel context.CancelFunc secureEpoch atomic.Uint64 secure atomic.Bool secureReset atomic.Bool secureResync atomic.Bool captureSound atomic.Bool captureTextAttributes atomic.Bool running atomic.Bool ready atomic.Bool loopDone chan struct{} loopError error // published by closing loopDone stopRequested bool // stopMutex; keep Stop retries from queuing stale quit sources stopMutex sync.Mutex hungMutex sync.Mutex hungBuses map[string]time.Time treeMutex sync.Mutex tree *AccessibleTree documentTree *AccessibleTree documentTextTree *AccessibleTree // The active document slots above serve one document root at a time. // Recently captured documents are stashed by root (bounded, LRU) so // returning to a tab reuses its tree and retained native sources // instead of snapshotting the whole document cold. Guarded by // treeMutex; stashed sources are released only on the GLib thread. documentSlotRoot ObjectID stashedDocuments map[ObjectID]stashedDocument stashedDocumentIDs []ObjectID nativeDocument map[ObjectID]*C.AtspiAccessible documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects nativeReview map[ObjectID]*C.AtspiAccessible nativeReviewBus string navigationEcho map[ObjectID]uint8 navigationCaretOffset int navigationSelectionOffsets map[int]bool nativeFocus []nativeFocusReference // owned only by the GLib thread mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown mouseApplicationPID int documentWindows map[ObjectID]nativeDocumentWindow documentWindowIDs []ObjectID selectedTabWindow ObjectID activeDocument ObjectID callbackHandle cgo.Handle callbackData *C.cthulhu_callback_data } type nativeFocusReference struct { id ObjectID source *C.AtspiAccessible // Only programmatic browse positions carry this proof. A subsequent native // focus event replaces the entry and clears it, even for the same object. navigationDocument ObjectReference } type nativeDocumentWindow struct { id ObjectID source *C.AtspiAccessible } type treeCaptureResult struct { soundGeometry [2]SoundGeometry item TransferItem actions ActionSet point PointReview ocr OCRGeometry attributes CharacterAttributes content DocumentContent tree *AccessibleTree owner DocumentContext caret DocumentCaretText err error } type treeCaptureRequest struct { readFocusedSnapshot bool soundObjects [2]ObjectReference transferItem bool typeText bool insertionText string listActions bool actionTarget *ActionTarget actionName string pointReview bool pointX, pointY int pointPID int ocrGeometry bool ocrZone *Rectangle readFocusedText bool readFocusedLine bool readAttributes bool setFocusedCaret bool brailleRoute *BrailleRoute ctx context.Context reviewFocus ObjectID document bool documentText bool contextOnly bool position ObjectReference caretObject ObjectReference caretOffset int caretUnit DocumentCaretUnit positionOffset int usePositionOffset bool activate bool result chan treeCaptureResult } // PointReview is copied metadata for the accessible at a screen point. // Text ranges are optional and never include password contents. type PointReview struct { Snapshot Snapshot Window ObjectID Word, Line string LineStart int Offset int // native text offset at the pointer, or -1 } func (adapter *Adapter) ReviewAtPoint(ctx context.Context, x, y, pid int) (PointReview, error) { if ctx == nil { return PointReview{}, errors.New("review pointer: nil context") } ctx, cancel := adapter.secureRequestContext(ctx) defer cancel() adapter.stopMutex.Lock() defer adapter.stopMutex.Unlock() if !adapter.ready.Load() || adapter.callbackData == nil { return PointReview{}, errors.New("review pointer: adapter is not running") } request := treeCaptureRequest{ctx: ctx, pointReview: true, pointX: x, pointY: y, pointPID: pid, result: make(chan treeCaptureResult, 1)} select { case adapter.treeRequests <- request: case <-ctx.Done(): return PointReview{}, ctx.Err() } if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 { <-adapter.treeRequests return PointReview{}, errors.New("schedule pointer review") } select { case result := <-request.result: return result.point, result.err case <-ctx.Done(): return PointReview{}, ctx.Err() } } func New() *Adapter { adapter := &Adapter{ events: make(chan Event, eventQueueCapacity), foregroundChanges: make(chan struct{}, 1), titleChanges: make(chan struct{}, 1), treeRequests: make(chan treeCaptureRequest, 1), } // Direct adapter users historically receive geometry. The application // narrows this before Start when object-sound spatialization is disabled. adapter.captureSound.Store(true) return adapter } // SetCaptureSoundGeometry controls synchronous component and ancestor queries // performed while copying event snapshots. It is safe to change while the // adapter is running; the next callback observes the new setting. func (adapter *Adapter) SetCaptureSoundGeometry(enabled bool) { adapter.captureSound.Store(enabled) } // SetCaptureTextAttributes enables bounded native run capture for braille. // Disabled by default to avoid extra provider queries on ordinary focus events. func (adapter *Adapter) SetCaptureTextAttributes(enabled bool) { adapter.captureTextAttributes.Store(enabled) } func (adapter *Adapter) Events() <-chan Event { return adapter.events } func (adapter *Adapter) Overflow() uint64 { return adapter.overflow.Load() } func (adapter *Adapter) TreeInvalidations() uint64 { return adapter.treeInvalidations.Load() } // CaptureDesktop copies the current AT-SPI desktop into the adapter's owned // tree on the native event-loop thread. Calls are serialized with shutdown so // callback data cannot be released while a capture is pending. func (adapter *Adapter) CaptureDesktop(ctx context.Context) (*AccessibleTree, error) { return adapter.capture(ctx, ObjectID{}, "desktop", false, false) } // CaptureReviewWindow copies the focused application's tree into the owned // model. Flat review needs only the focused frame or dialog, so excluding // unrelated applications keeps capture bounded on busy desktops. func (adapter *Adapter) CaptureReviewWindow(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { if !focus.Valid() { return nil, errors.New("capture AT-SPI review window: invalid focus") } return adapter.capture(ctx, focus, "review window", false, false) } // CaptureDocument captures semantics without flat review's viewport text work. // Its independent tree cannot replace text required by an active flat cursor. func (adapter *Adapter) CaptureDocument(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { if !focus.Valid() { return nil, errors.New("capture AT-SPI document: invalid focus") } return adapter.capture(ctx, focus, "document", true, false) } // CaptureDocumentText also captures visible text and range geometry. Line // assembly needs those native details; structural navigation does not. func (adapter *Adapter) CaptureDocumentText(ctx context.Context, focus ObjectID) (*AccessibleTree, error) { if !focus.Valid() { return nil, errors.New("capture AT-SPI document text: invalid focus") } return adapter.capture(ctx, focus, "document text", true, true) } // SetCaptureLogger enables capture diagnostics when the logger accepts debug // records. It can be updated without touching the GLib thread or native state. func (adapter *Adapter) SetCaptureLogger(logger *slog.Logger) { adapter.captureLogger.Store(logger) } const providerHungCooldown = 2 * time.Second // markProviderHung records a capture deadline for one application bus. // Orca similarly remembers hung objects briefly so later commands skip // straight past a provider that just stalled. func (adapter *Adapter) markProviderHung(busName string, at time.Time) { if busName == "" { return } adapter.hungMutex.Lock() defer adapter.hungMutex.Unlock() if adapter.hungBuses == nil { adapter.hungBuses = make(map[string]time.Time) } adapter.hungBuses[busName] = at } func (adapter *Adapter) providerHung(busName string, at time.Time) bool { adapter.hungMutex.Lock() defer adapter.hungMutex.Unlock() since, ok := adapter.hungBuses[busName] if !ok { return false } if at.Sub(since) > providerHungCooldown { delete(adapter.hungBuses, busName) return false } return true } func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captureName string, document, documentText bool) (tree *AccessibleTree, err error) { if ctx == nil { return nil, fmt.Errorf("capture AT-SPI %s: nil context", captureName) } ctx, cancel := adapter.secureRequestContext(ctx) defer cancel() if document && adapter.providerHung(reviewFocus.BusName, time.Now()) { return nil, fmt.Errorf("%w: bus %q", ErrProviderHung, reviewFocus.BusName) } var trace *captureDiagnostics if reviewFocus.Valid() { ctx, trace = new_capture_diagnostics(ctx, adapter.captureLogger.Load(), reviewFocus) if trace != nil && document { trace.kind = "document" } defer func() { trace.finish(err) }() } if err := ctx.Err(); err != nil { return nil, err } adapter.stopMutex.Lock() defer adapter.stopMutex.Unlock() if !adapter.ready.Load() || adapter.callbackData == nil { return nil, fmt.Errorf("capture AT-SPI %s: adapter is not running", captureName) } if err := ctx.Err(); err != nil { return nil, err } request := treeCaptureRequest{ ctx: ctx, reviewFocus: reviewFocus, document: document, documentText: documentText, result: make(chan treeCaptureResult, 1), } trace.begin_stage("queue-wait", reviewFocus, "") select { case adapter.treeRequests <- request: case <-ctx.Done(): return nil, ctx.Err() } if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 { <-adapter.treeRequests return nil, fmt.Errorf("schedule AT-SPI %s capture", captureName) } select { case result := <-request.result: if document && errors.Is(result.err, context.DeadlineExceeded) { adapter.markProviderHung(reviewFocus.BusName, time.Now()) } return result.tree, result.err case <-ctx.Done(): if document && errors.Is(ctx.Err(), context.DeadlineExceeded) { adapter.markProviderHung(reviewFocus.BusName, time.Now()) } return nil, ctx.Err() } } func (adapter *Adapter) installTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) { return adapter.installCapturedTree(root, nodes, false, false) } const maxStashedDocuments = 3 type stashedDocument struct { tree, textTree *AccessibleTree sources map[ObjectID]*C.AtspiAccessible } // Source membership survives tree invalidation until the next capture, so it // can identify the owner of a removed focus without guessing from its bus. func (adapter *Adapter) retainedDocumentRoot(id ObjectID) ObjectID { adapter.treeMutex.Lock() defer adapter.treeMutex.Unlock() if _, retained := adapter.nativeDocument[id]; retained { return adapter.documentSlotRoot } for _, root := range adapter.stashedDocumentIDs { if _, retained := adapter.stashedDocuments[root].sources[id]; retained { return root } } return ObjectID{} } // rotateDocumentSlots makes root's retained trees and native sources the // active document slots, stashing the previously active document. Capture // calls this on the GLib thread once the capture root is known, before // preparing the incremental-refresh cache. func (adapter *Adapter) rotateDocumentSlots(root ObjectID) { adapter.rotateDocumentSlotsAndRelease(root, releaseDocumentSources) } func releaseDocumentSources(document stashedDocument) { for _, source := range document.sources { C.g_object_unref(C.gpointer(source)) } } // libatspi disposal synchronously emits defunct events. Publish the new slots // and unlock before releasing references, so reentrant invalidation sees a // consistent cache and can acquire treeMutex. func (adapter *Adapter) rotateDocumentSlotsAndRelease(root ObjectID, release func(stashedDocument)) { if !root.Valid() { return } var retired []stashedDocument adapter.treeMutex.Lock() defer func() { adapter.treeMutex.Unlock() for _, document := range retired { release(document) } }() if root == adapter.documentSlotRoot { return } // Remove the requested slot before stashing the active one: at capacity, // stashing first could evict the very document we are returning to. stashed := adapter.stashedDocuments[root] delete(adapter.stashedDocuments, root) for index, id := range adapter.stashedDocumentIDs { if id == root { adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...) break } } if adapter.documentSlotRoot.Valid() && (adapter.documentTree != nil || adapter.documentTextTree != nil) { retired = adapter.stashDocumentLocked(adapter.documentSlotRoot, stashedDocument{ tree: adapter.documentTree, textTree: adapter.documentTextTree, sources: adapter.nativeDocument, }) } else if len(adapter.nativeDocument) != 0 { retired = append(retired, stashedDocument{sources: adapter.nativeDocument}) } adapter.documentTree = stashed.tree adapter.documentTextTree = stashed.textTree adapter.nativeDocument = stashed.sources adapter.documentSlotRoot = root } func (adapter *Adapter) stashDocumentLocked(root ObjectID, document stashedDocument) []stashedDocument { var retired []stashedDocument if adapter.stashedDocuments == nil { adapter.stashedDocuments = make(map[ObjectID]stashedDocument) } if old, ok := adapter.stashedDocuments[root]; ok { retired = append(retired, old) for index, id := range adapter.stashedDocumentIDs { if id == root { adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...) break } } } adapter.stashedDocuments[root] = document adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs, root) for len(adapter.stashedDocumentIDs) > maxStashedDocuments { oldest := adapter.stashedDocumentIDs[0] retired = append(retired, adapter.stashedDocuments[oldest]) delete(adapter.stashedDocuments, oldest) adapter.stashedDocumentIDs = adapter.stashedDocumentIDs[1:] } return retired } // clearStashedDocuments releases every stashed document's native sources. // Callers run on the GLib thread (secure reset, shutdown). func (adapter *Adapter) clearStashedDocuments() { adapter.treeMutex.Lock() stashed := adapter.stashedDocuments adapter.stashedDocuments = nil adapter.stashedDocumentIDs = nil adapter.documentSlotRoot = ObjectID{} adapter.treeMutex.Unlock() for _, document := range stashed { for _, source := range document.sources { C.g_object_unref(C.gpointer(source)) } } } func (adapter *Adapter) ownedTrees() []*AccessibleTree { adapter.treeMutex.Lock() defer adapter.treeMutex.Unlock() return adapter.ownedTreesLocked() } func (adapter *Adapter) ownedTreesLocked() []*AccessibleTree { trees := make([]*AccessibleTree, 0, 3+2*len(adapter.stashedDocuments)) for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} { if tree != nil { trees = append(trees, tree) } } for _, id := range adapter.stashedDocumentIDs { stashed := adapter.stashedDocuments[id] if stashed.tree != nil { trees = append(trees, stashed.tree) } if stashed.textTree != nil { trees = append(trees, stashed.textTree) } } return trees } func (adapter *Adapter) installCapturedTree(root ObjectID, nodes []TreeNode, document, documentText bool) (*AccessibleTree, error) { adapter.treeMutex.Lock() defer adapter.treeMutex.Unlock() owned := &adapter.tree if documentText { owned = &adapter.documentTextTree } else if document { owned = &adapter.documentTree } if *owned == nil { tree, err := NewAccessibleTree(root, nodes) if err != nil { return nil, err } *owned = tree return tree, nil } if err := (*owned).Replace(root, nodes); err != nil { return nil, err } return *owned, nil } func (adapter *Adapter) invalidateTree() { adapter.treeMutex.Lock() defer adapter.treeMutex.Unlock() for _, tree := range adapter.ownedTreesLocked() { root, err := tree.Root() if err == nil { adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID()))) } } }