Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,508 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
Reference in new issue
Block a user