411 lines
14 KiB
Go
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())))
|
|
}
|
|
}
|
|
}
|