Files

645 lines
22 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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())))
}
}
}