560 lines
18 KiB
Go
560 lines
18 KiB
Go
// 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"
|
|
"strings"
|
|
"unicode/utf8"
|
|
"unsafe"
|
|
)
|
|
|
|
func (adapter *Adapter) nativeFocusIs(id ObjectID) bool {
|
|
return id.Valid() && len(adapter.nativeFocus) != 0 && adapter.nativeFocus[len(adapter.nativeFocus)-1].id == id
|
|
}
|
|
|
|
// Native document handles are bounded by the capture's node limit. Only the
|
|
// GLib thread owns them; recapture and shutdown release every reference.
|
|
func (adapter *Adapter) clearNativeDocument() {
|
|
for _, source := range adapter.nativeDocument {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
adapter.nativeDocument = nil
|
|
clear(adapter.documentLineCache)
|
|
}
|
|
|
|
// Review handles belong to the GLib thread and are replaced atomically after
|
|
// each successful window capture. Keeping them permits dirty-node recapture.
|
|
func (adapter *Adapter) clearNativeReview() {
|
|
for _, source := range adapter.nativeReview {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
adapter.nativeReview = nil
|
|
adapter.nativeReviewBus = ""
|
|
}
|
|
|
|
func (adapter *Adapter) documentOperation(ctx context.Context, request treeCaptureRequest) (treeCaptureResult, error) {
|
|
ctx, cancel := adapter.secureRequestContext(ctx)
|
|
defer cancel()
|
|
if err := ctx.Err(); err != nil {
|
|
return treeCaptureResult{}, err
|
|
}
|
|
adapter.stopMutex.Lock()
|
|
defer adapter.stopMutex.Unlock()
|
|
if !adapter.ready.Load() || adapter.callbackData == nil {
|
|
return treeCaptureResult{}, errors.New("AT-SPI adapter is not running")
|
|
}
|
|
request.ctx, request.result = ctx, make(chan treeCaptureResult, 1)
|
|
select {
|
|
case adapter.treeRequests <- request:
|
|
case <-ctx.Done():
|
|
return treeCaptureResult{}, ctx.Err()
|
|
}
|
|
if C.cthulhu_request_tree_capture(C.gpointer(unsafe.Pointer(adapter.callbackData))) == 0 {
|
|
<-adapter.treeRequests
|
|
return treeCaptureResult{}, errors.New("schedule document operation")
|
|
}
|
|
select {
|
|
case result := <-request.result:
|
|
return result, result.err
|
|
case <-ctx.Done():
|
|
return treeCaptureResult{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) CaptureDocumentContext(ctx context.Context, focus ObjectID) (DocumentContext, error) {
|
|
result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, contextOnly: true})
|
|
return result.owner, err
|
|
}
|
|
|
|
func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position ObjectReference, focus ObjectID) error {
|
|
if !position.Valid() || !focus.Valid() {
|
|
return ErrStaleObject
|
|
}
|
|
_, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus})
|
|
return err
|
|
}
|
|
|
|
// CaptureDocumentSoundGeometry refreshes only the presented object and its
|
|
// optional linked heading target, after navigation has scrolled them into view.
|
|
// It never changes the cached document semantics or retains new native handles.
|
|
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
|
|
if !object.Valid() {
|
|
return [2]SoundGeometry{}, ErrStaleObject
|
|
}
|
|
if !adapter.captureSound.Load() {
|
|
return [2]SoundGeometry{}, nil
|
|
}
|
|
result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}})
|
|
return result.soundGeometry, err
|
|
}
|
|
|
|
func (adapter *Adapter) captureNativeDocumentSoundGeometry(ctx context.Context, objects [2]ObjectReference) ([2]SoundGeometry, error) {
|
|
var geometry [2]SoundGeometry
|
|
if !adapter.captureSound.Load() {
|
|
return geometry, nil
|
|
}
|
|
adapter.treeMutex.Lock()
|
|
tree := adapter.documentTree
|
|
adapter.treeMutex.Unlock()
|
|
if tree == nil {
|
|
return geometry, ErrStaleObject
|
|
}
|
|
for index, object := range objects {
|
|
if !object.Valid() {
|
|
continue
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return [2]SoundGeometry{}, err
|
|
}
|
|
if _, err := tree.Resolve(object); err != nil {
|
|
return [2]SoundGeometry{}, err
|
|
}
|
|
source := adapter.nativeDocument[object.ID()]
|
|
if source == nil {
|
|
return [2]SoundGeometry{}, ErrStaleObject
|
|
}
|
|
// SHOWING can still reflect the pre-scroll viewport in libatspi's cache.
|
|
C.atspi_accessible_clear_cache_single(source)
|
|
var captured C.cthulhu_snapshot
|
|
C.cthulhu_capture_sound_geometry(source, &captured)
|
|
geometry[index] = soundGeometryFromNative(captured)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return [2]SoundGeometry{}, err
|
|
}
|
|
return geometry, nil
|
|
}
|
|
|
|
// SetDocumentCaretPosition also places the browser caret at the virtual browse
|
|
// offset, so native Tab traversal starts from the announced location.
|
|
func (adapter *Adapter) SetDocumentCaretPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int) error {
|
|
if !position.Valid() || !focus.Valid() || offset < 0 {
|
|
return ErrStaleObject
|
|
}
|
|
_, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus, positionOffset: offset, usePositionOffset: true})
|
|
return err
|
|
}
|
|
|
|
func (adapter *Adapter) ActivateDocumentObject(ctx context.Context, position ObjectReference, focus ObjectID) error {
|
|
if !position.Valid() || !focus.Valid() {
|
|
return ErrStaleObject
|
|
}
|
|
_, err := adapter.documentOperation(ctx, treeCaptureRequest{position: position, reviewFocus: focus, activate: true})
|
|
return err
|
|
}
|
|
|
|
func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, position ObjectReference, focus ObjectID) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
adapter.treeMutex.Lock()
|
|
tree := adapter.documentTree
|
|
adapter.treeMutex.Unlock()
|
|
if tree == nil {
|
|
return ErrStaleObject
|
|
}
|
|
snapshot, err := tree.Resolve(position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if snapshot.Role != "link" && !snapshot.SupportsClick {
|
|
return errors.New("current document object is not activatable")
|
|
}
|
|
if err := documentPositionMatchesFocus(tree, position, focus); err != nil {
|
|
return err
|
|
}
|
|
if len(adapter.nativeFocus) > 0 {
|
|
latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id
|
|
if latest != focus {
|
|
if err := documentPositionMatchesFocus(tree, position, latest); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
source := adapter.nativeDocument[position.ID()]
|
|
if source == nil {
|
|
return ErrStaleObject
|
|
}
|
|
role := C.atspi_accessible_get_role_name(source, nil)
|
|
currentRole := sanitizeSnapshotField(C.GoString(role))
|
|
C.g_free(C.gpointer(role))
|
|
if currentRole != snapshot.Role {
|
|
return ErrStaleObject
|
|
}
|
|
window := C.cthulhu_window_ancestor(source)
|
|
if window == nil {
|
|
return ErrStaleObject
|
|
}
|
|
foreground := adapter.matchesForeground(window, nativeObjectID(window))
|
|
C.g_object_unref(C.gpointer(window))
|
|
if !foreground {
|
|
return ErrStaleObject
|
|
}
|
|
action := C.atspi_accessible_get_action_iface(source)
|
|
if action == nil {
|
|
return errors.New("current document object has no accessible action")
|
|
}
|
|
defer C.g_object_unref(C.gpointer(action))
|
|
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
|
|
count := int(C.atspi_action_get_n_actions(action, nil))
|
|
for _, wanted := range names {
|
|
for index := 0; index < count; index++ {
|
|
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
|
matched := strings.EqualFold(C.GoString(name), wanted)
|
|
C.g_free(C.gpointer(name))
|
|
if !matched {
|
|
continue
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
var nativeErr *C.GError
|
|
activated := C.atspi_action_do_action(action, C.gint(index), &nativeErr)
|
|
if nativeErr != nil {
|
|
err := nativeError("activate document object", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return err
|
|
}
|
|
if activated != 0 {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return errors.New("current document object refused activation")
|
|
}
|
|
|
|
// ReadDocumentCaret reads one bounded AT-SPI text unit on the GLib owner.
|
|
// The worker keeps the virtual offset; no native caret or focus is changed.
|
|
func (adapter *Adapter) ReadDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) {
|
|
if !position.Valid() || offset < 0 || !unit.Valid() {
|
|
return DocumentCaretText{}, ErrStaleObject
|
|
}
|
|
result, err := adapter.documentOperation(ctx, treeCaptureRequest{caretObject: position, caretOffset: offset, caretUnit: unit})
|
|
return result.caret, err
|
|
}
|
|
|
|
func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return DocumentCaretText{}, err
|
|
}
|
|
adapter.treeMutex.Lock()
|
|
tree := adapter.documentTree
|
|
adapter.treeMutex.Unlock()
|
|
if tree == nil {
|
|
return DocumentCaretText{}, ErrStaleObject
|
|
}
|
|
snapshot, err := tree.Resolve(position)
|
|
if err != nil {
|
|
return DocumentCaretText{}, err
|
|
}
|
|
if unit == DocumentCaretBrailleLine && snapshot.Role == "password text" {
|
|
return DocumentCaretText{}, ErrNoDocumentCaret
|
|
}
|
|
source := adapter.nativeDocument[position.ID()]
|
|
if source == nil {
|
|
return DocumentCaretText{}, ErrStaleObject
|
|
}
|
|
text := C.atspi_accessible_get_text_iface(source)
|
|
if text == nil {
|
|
return DocumentCaretText{}, ErrNoDocumentCaret
|
|
}
|
|
defer C.g_object_unref(C.gpointer(text))
|
|
var nativeErr *C.GError
|
|
count := int(C.atspi_text_get_character_count(text, &nativeErr))
|
|
if nativeErr != nil {
|
|
err := nativeError("get document text count", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return DocumentCaretText{}, err
|
|
}
|
|
if count <= 0 || offset >= count {
|
|
if unit != DocumentCaretBrailleLine || count <= 0 || offset != count {
|
|
return DocumentCaretText{CharacterCount: count}, nil
|
|
}
|
|
// A caret after the final character still belongs to the final line.
|
|
offset = count - 1
|
|
}
|
|
if unit != DocumentCaretCharacter && unit != DocumentCaretWord {
|
|
line, err := adapter.readNativeDocumentLine(ctx, position.ID(), snapshot, text, offset, count)
|
|
if err != nil {
|
|
return DocumentCaretText{}, err
|
|
}
|
|
line.RawText = line.Text
|
|
if unit == DocumentCaretBrailleLine && adapter.captureTextAttributes.Load() {
|
|
line.TextAttributeRuns = native_text_attribute_runs(text, line.Start, line.End, TextAttributeRun{})
|
|
}
|
|
if unit == DocumentCaretBrailleLine && C.atspi_text_get_n_selections(text, nil) > 0 {
|
|
if selection := C.atspi_text_get_selection(text, 0, nil); selection != nil {
|
|
line.SelectionStart, line.SelectionEnd = int(selection.start_offset), int(selection.end_offset)
|
|
C.g_free(C.gpointer(selection))
|
|
}
|
|
}
|
|
// Geometry is optional: unavailable bounds keep object-local reading.
|
|
// Query on the GLib owner; no native reference leaves this operation.
|
|
if line.Start >= 0 && line.End > line.Start && line.End <= count {
|
|
line.Bounds, _ = (nativeReviewText{text: text}).bounds(ctx, line.Start, line.End)
|
|
if err := ctx.Err(); err != nil {
|
|
return DocumentCaretText{}, err
|
|
}
|
|
}
|
|
if unit == DocumentCaretLineRemainder && line.Start < offset && offset < line.End && offset-line.Start <= utf8.RuneCountInString(line.Text) {
|
|
line.Text = string([]rune(line.Text)[offset-line.Start:])
|
|
line.Start = offset
|
|
}
|
|
if unit != DocumentCaretBrailleLine {
|
|
line.Misspelled = native_misspelled(text, offset)
|
|
line.Text = sanitizeSnapshotField(line.Text)
|
|
}
|
|
return line, nil
|
|
}
|
|
granularity := C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_LINE)
|
|
switch unit {
|
|
case DocumentCaretCharacter:
|
|
granularity = C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_CHAR)
|
|
case DocumentCaretWord:
|
|
granularity = C.AtspiTextGranularity(C.ATSPI_TEXT_GRANULARITY_WORD)
|
|
}
|
|
rangeText := C.atspi_text_get_string_at_offset(text, C.gint(offset), granularity, &nativeErr)
|
|
if nativeErr != nil {
|
|
err := nativeError("read document text", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return DocumentCaretText{}, err
|
|
}
|
|
if rangeText == nil {
|
|
return DocumentCaretText{CharacterCount: count}, nil
|
|
}
|
|
defer C.g_free(C.gpointer(rangeText))
|
|
defer C.g_free(C.gpointer(rangeText.content))
|
|
content := sanitizeSnapshotField(C.GoString(rangeText.content))
|
|
if unit == DocumentCaretCharacter {
|
|
// Whitespace is a navigable character. Label normalization would
|
|
// erase it, preventing space speech and word delimiter detection.
|
|
content = documentCaretCharacter(C.GoString(rangeText.content))
|
|
}
|
|
return DocumentCaretText{
|
|
Start: int(rangeText.start_offset), End: int(rangeText.end_offset),
|
|
Text: content, CharacterCount: count, Misspelled: native_misspelled(text, offset),
|
|
}, nil
|
|
}
|
|
|
|
// Capture only ancestors: changing focus must not require scanning a whole page.
|
|
func (adapter *Adapter) nativeDocumentContext(ctx context.Context, focus ObjectID) (DocumentContext, error) {
|
|
var source *C.AtspiAccessible
|
|
for _, item := range adapter.nativeFocus {
|
|
if item.id == focus {
|
|
source = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(item.source)))
|
|
break
|
|
}
|
|
}
|
|
if source == nil {
|
|
return DocumentContext{}, ErrObjectNotFound
|
|
}
|
|
defer func() {
|
|
if source != nil {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
}()
|
|
nodes := []TreeNode{}
|
|
seen := map[ObjectID]bool{}
|
|
var child ObjectID
|
|
for source != nil {
|
|
if err := ctx.Err(); err != nil {
|
|
return DocumentContext{}, err
|
|
}
|
|
C.atspi_accessible_clear_cache_single(source)
|
|
snapshot := snapshotFromNativeWithCaret(source, false, false, false, false)
|
|
if len(nodes) >= 256 || seen[snapshot.ID()] || snapshot.Defunct {
|
|
return DocumentContext{}, ErrStaleObject
|
|
}
|
|
seen[snapshot.ID()] = true
|
|
node := TreeNode{Snapshot: snapshot}
|
|
if child.Valid() {
|
|
node.Children = []ObjectID{child}
|
|
}
|
|
nodes = append(nodes, node)
|
|
child = snapshot.ID()
|
|
if snapshot.Role == "application" {
|
|
break
|
|
}
|
|
var nativeErr *C.GError
|
|
parent := C.atspi_accessible_get_parent(source, &nativeErr)
|
|
if nativeErr != nil {
|
|
if parent != nil {
|
|
C.g_object_unref(C.gpointer(parent))
|
|
}
|
|
err := nativeError("document context parent", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return DocumentContext{}, err
|
|
}
|
|
C.g_object_unref(C.gpointer(source))
|
|
source = parent
|
|
}
|
|
tree, err := NewAccessibleTree(child, nodes)
|
|
if err != nil {
|
|
return DocumentContext{}, err
|
|
}
|
|
ref, err := tree.Reference(focus)
|
|
if err != nil {
|
|
return DocumentContext{}, err
|
|
}
|
|
return tree.DocumentContext(ref)
|
|
}
|
|
|
|
func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position ObjectReference, focus ObjectID, offset int, useOffset bool) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
adapter.treeMutex.Lock()
|
|
tree := adapter.documentTree
|
|
adapter.treeMutex.Unlock()
|
|
if tree == nil {
|
|
return ErrStaleObject
|
|
}
|
|
snapshot, err := tree.Resolve(position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
source := adapter.nativeDocument[position.ID()]
|
|
if source == nil {
|
|
return ErrStaleObject
|
|
}
|
|
// Recheck on the GLib owner: focus can change after the worker queues us.
|
|
if len(adapter.nativeFocus) > 0 {
|
|
latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id
|
|
if latest != focus {
|
|
if err := documentPositionMatchesFocus(tree, position, latest); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
echoIDs, err := tree.subtreeIDs(position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
echoIDs = append(echoIDs, documentCaretEchoAncestors(tree, position)...)
|
|
adapter.navigationCaretOffset = offset
|
|
adapter.navigationEcho = make(map[ObjectID]uint8, len(echoIDs))
|
|
for _, id := range echoIDs {
|
|
adapter.navigationEcho[id] = 7
|
|
}
|
|
component := C.atspi_accessible_get_component_iface(source)
|
|
if component != nil {
|
|
if documentTargetGrabsFocus(tree, position, snapshot) {
|
|
C.atspi_component_grab_focus(component, nil)
|
|
}
|
|
C.atspi_component_scroll_to(component, C.ATSPI_SCROLL_ANYWHERE, nil)
|
|
C.g_object_unref(C.gpointer(component))
|
|
}
|
|
text := C.atspi_accessible_get_text_iface(source)
|
|
if text == nil {
|
|
clear(adapter.navigationEcho)
|
|
// Images and other whole-object stops have a valid virtual position
|
|
// without a native text caret. Python's web setCaretPosition likewise
|
|
// only attempts SetCaretOffset when the object supports text.
|
|
adapter.rememberNativeFocus(source, position.ID())
|
|
return nil
|
|
}
|
|
defer C.g_object_unref(C.gpointer(text))
|
|
var nativeErr *C.GError
|
|
var caretOffset C.gint
|
|
if useOffset {
|
|
caretOffset = C.int(offset)
|
|
} else {
|
|
caretOffset = C.atspi_text_get_caret_offset(text, &nativeErr)
|
|
if nativeErr != nil {
|
|
err := nativeError("get document caret", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
clear(adapter.navigationEcho)
|
|
return err
|
|
}
|
|
if caretOffset < 0 {
|
|
caretOffset = 0
|
|
}
|
|
}
|
|
adapter.navigationCaretOffset = int(caretOffset)
|
|
// Python clears the previous selection before moving its browse caret.
|
|
for selections := min(int(C.atspi_text_get_n_selections(text, nil)), 16); selections > 0; selections-- {
|
|
C.atspi_text_remove_selection(text, 0, nil)
|
|
}
|
|
moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr)
|
|
if nativeErr != nil {
|
|
err := nativeError("set document caret", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
clear(adapter.navigationEcho)
|
|
return err
|
|
}
|
|
if moved == 0 {
|
|
clear(adapter.navigationEcho)
|
|
return errors.New("browser refused document caret")
|
|
}
|
|
adapter.rememberNativeFocus(source, position.ID())
|
|
return nil
|
|
}
|
|
|
|
// Python's web grabFocusWhenSettingCaret avoids moving native focus onto
|
|
// ordinary headings and entries. Focusing a heading inside a disclosure can
|
|
// focus its button and close the disclosure while the browse caret moves.
|
|
func documentTargetGrabsFocus(tree *AccessibleTree, position ObjectReference, snapshot Snapshot) bool {
|
|
if !snapshot.Focusable || snapshot.Editable || snapshot.Role == "entry" {
|
|
return false
|
|
}
|
|
if snapshot.Role != "heading" && snapshot.Role != "image" {
|
|
return true
|
|
}
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(position)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if snapshot.Role == "image" {
|
|
for entry.hasParent {
|
|
parent, ok := tree.nodes[entry.parent]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if parent.snapshot.Role == "link" {
|
|
return true
|
|
}
|
|
entry = parent
|
|
}
|
|
return false
|
|
}
|
|
if len(entry.children) != 1 {
|
|
return false
|
|
}
|
|
child, ok := tree.nodes[entry.children[0]]
|
|
return ok && child.snapshot.Role == "link"
|
|
}
|
|
|
|
// Same-document focus changes caused by navigation are valid; chrome and other
|
|
// tabs must never be pulled back by a queued native caret/focus operation.
|
|
func documentPositionMatchesFocus(tree *AccessibleTree, position ObjectReference, focus ObjectID) error {
|
|
current, err := tree.Reference(focus)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner, err := tree.DocumentContext(current)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := tree.DocumentContext(position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner.TopLevel != target.TopLevel {
|
|
return errors.New("document focus changed before navigation")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Foreground returns owned metadata without native calls from a consumer.
|