Files

411 lines
14 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"
"runtime/cgo"
"sync"
"sync/atomic"
"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"
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"
)
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,
"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:required",
SelectedStateEvent,
"object:state-changed:sensitive",
"object:state-changed:visited",
}
type Adapter struct {
applicationProcesses map[string]string // GLib owner; bounded provider identity cache
webNotificationRoots map[ObjectID]bool // copied identities only; no native references
foregroundReader *xorg.ForegroundReader // GLib thread only
foreground atomic.Pointer[ForegroundState]
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
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{}
stopMutex sync.Mutex
treeMutex sync.Mutex
tree *AccessibleTree
documentTree *AccessibleTree
documentTextTree *AccessibleTree
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
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
}
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 {
soundObjects [2]ObjectReference
transferItem bool
listActions bool
actionTarget *ActionTarget
actionName string
pointReview bool
pointX, pointY int
pointPID int
ocrGeometry bool
ocrZone *Rectangle
readFocusedText 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)
}
func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captureName string, document, documentText bool) (*AccessibleTree, error) {
if ctx == nil {
return nil, fmt.Errorf("capture AT-SPI %s: nil context", captureName)
}
ctx, cancel := adapter.secureRequestContext(ctx)
defer cancel()
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),
}
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:
return result.tree, result.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (adapter *Adapter) installTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) {
return adapter.installCapturedTree(root, nodes, false, false)
}
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 []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
if tree == nil {
continue
}
root, err := tree.Root()
if err == nil {
adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID())))
}
}
}