Files
cthulhu/internal/atspi/adapter_document_native.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.