555 lines
17 KiB
Go
555 lines
17 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 (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
)
|
|
|
|
// Large browser documents can expose more than ten thousand accessible nodes.
|
|
// Capture still has a time limit and a finite node budget on the native thread.
|
|
const maxTreeNodes = 30000
|
|
|
|
var (
|
|
ErrStaleGeometry = errors.New("accessible geometry requires recapture")
|
|
ErrIncompleteTree = errors.New("accessible children require recapture")
|
|
ErrInvalidTree = errors.New("invalid accessible tree")
|
|
ErrDeadObject = errors.New("defunct accessible object")
|
|
ErrObjectNotFound = errors.New("accessible object not found")
|
|
ErrStaleObject = errors.New("stale accessible object")
|
|
ErrStaleSnapshot = errors.New("accessible semantics require recapture")
|
|
)
|
|
|
|
// TreeNode is an owned, immutable accessible snapshot and the identities of
|
|
// its children in application-provided order. NewAccessibleTree and Replace
|
|
// copy Children before retaining a node.
|
|
type TreeNode struct {
|
|
Geometry Geometry
|
|
Text VisibleText
|
|
Snapshot Snapshot
|
|
Children []ObjectID
|
|
}
|
|
|
|
// ObjectReference is a generation-checked reference to an owned snapshot. A
|
|
// reference becomes stale when its object or an ancestor is invalidated, or
|
|
// when a replacement tree no longer contains that object.
|
|
type ObjectReference struct {
|
|
id ObjectID
|
|
generation uint64
|
|
}
|
|
|
|
func (reference ObjectReference) ID() ObjectID {
|
|
return reference.id
|
|
}
|
|
|
|
func (reference ObjectReference) Valid() bool {
|
|
return reference.id.Valid() && reference.generation != 0
|
|
}
|
|
|
|
type treeEntry struct {
|
|
snapshotInvalid bool
|
|
text VisibleText
|
|
textInvalid bool
|
|
geometry Geometry
|
|
geometryInvalid bool
|
|
childrenInvalid bool
|
|
snapshot Snapshot
|
|
children []ObjectID
|
|
parent ObjectID
|
|
hasParent bool
|
|
generation uint64
|
|
}
|
|
|
|
// AccessibleTree owns a validated accessibility tree. It contains no native
|
|
// references and supports concurrent readers while events replace or
|
|
// invalidate cached objects.
|
|
type AccessibleTree struct {
|
|
mu sync.RWMutex
|
|
root ObjectID
|
|
nodes map[ObjectID]treeEntry
|
|
nextGeneration uint64
|
|
}
|
|
|
|
func NewAccessibleTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) {
|
|
tree := &AccessibleTree{}
|
|
if err := tree.Replace(root, nodes); err != nil {
|
|
return nil, err
|
|
}
|
|
return tree, nil
|
|
}
|
|
|
|
// Replace atomically installs a complete tree. References to identities that
|
|
// remain live retain their generation; removed identities become stale. If an
|
|
// invalidated identity later reappears, it receives a new generation.
|
|
func (tree *AccessibleTree) Replace(root ObjectID, nodes []TreeNode) error {
|
|
next, err := validateTree(root, nodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
for id, entry := range next {
|
|
if current, ok := tree.nodes[id]; ok {
|
|
entry.generation = current.generation
|
|
} else {
|
|
tree.nextGeneration++
|
|
if tree.nextGeneration == 0 {
|
|
// Reaching this would require creating more than 2^64 distinct
|
|
// object generations in one process. Keep zero reserved for an
|
|
// invalid reference if it nevertheless occurs.
|
|
tree.nextGeneration++
|
|
}
|
|
entry.generation = tree.nextGeneration
|
|
}
|
|
next[id] = entry
|
|
}
|
|
tree.root = root
|
|
tree.nodes = next
|
|
return nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) Root() (ObjectReference, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, ok := tree.nodes[tree.root]
|
|
if !ok {
|
|
return ObjectReference{}, ErrObjectNotFound
|
|
}
|
|
return newObjectReference(tree.root, entry), nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) Reference(id ObjectID) (ObjectReference, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, ok := tree.nodes[id]
|
|
if !ok {
|
|
return ObjectReference{}, fmt.Errorf("%w: bus %q path %q", ErrObjectNotFound, id.BusName, id.Path)
|
|
}
|
|
return newObjectReference(id, entry), nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) Resolve(reference ObjectReference) (Snapshot, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
return entry.snapshot, nil
|
|
}
|
|
|
|
// InvalidateSnapshot refreshes presentation and matching data without retiring
|
|
// a live identity. A renamed button is still the same browse-caret position.
|
|
func (tree *AccessibleTree) InvalidateSnapshot(id ObjectID) int {
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
entry, ok := tree.nodes[id]
|
|
if !ok || entry.snapshotInvalid {
|
|
return 0
|
|
}
|
|
entry.snapshotInvalid = true
|
|
tree.nodes[id] = entry
|
|
return 1
|
|
}
|
|
|
|
// nativeRefresh copies a dirty tree for incremental native recapture.
|
|
// Unchanged nodes and child lists can be reused; dirty fields and newly added
|
|
// objects must be read from the provider. A missing or replaced root cannot
|
|
// inherit this cache.
|
|
func (tree *AccessibleTree) nativeRefresh(root ObjectID) map[ObjectID]treeEntry {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
if tree.root != root {
|
|
return nil
|
|
}
|
|
if _, ok := tree.nodes[root]; !ok {
|
|
return nil
|
|
}
|
|
dirty := false
|
|
nodes := make(map[ObjectID]treeEntry, len(tree.nodes))
|
|
for id, entry := range tree.nodes {
|
|
dirty = dirty || entry.snapshotInvalid || entry.childrenInvalid || entry.textInvalid || entry.geometryInvalid
|
|
entry.children = append([]ObjectID(nil), entry.children...)
|
|
entry.text.Ranges = append([]TextRange(nil), entry.text.Ranges...)
|
|
nodes[id] = entry
|
|
}
|
|
if !dirty {
|
|
return nil
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// subtreeIDs returns the captured identities rooted at reference. It is used
|
|
// only by the native adapter to classify the bounded burst of caret/focus and
|
|
// defunct events produced by browser caret placement. No native references
|
|
// escape through this copy.
|
|
func (tree *AccessibleTree) subtreeIDs(reference ObjectReference) ([]ObjectID, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
if _, err := tree.resolveLocked(reference); err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]ObjectID, 0, 4)
|
|
pending := []ObjectID{reference.id}
|
|
for len(pending) != 0 {
|
|
last := len(pending) - 1
|
|
id := pending[last]
|
|
pending = pending[:last]
|
|
entry, ok := tree.nodes[id]
|
|
if !ok {
|
|
return nil, ErrIncompleteTree
|
|
}
|
|
result = append(result, id)
|
|
pending = append(pending, entry.children...)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Geometry returns capture-time screen bounds, or ErrStaleGeometry after a
|
|
// layout/visibility event. Structural identity remains live across such events.
|
|
func (tree *AccessibleTree) Geometry(reference ObjectReference) (Geometry, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return Geometry{}, err
|
|
}
|
|
if entry.geometryInvalid {
|
|
return Geometry{}, ErrStaleGeometry
|
|
}
|
|
return entry.geometry, nil
|
|
}
|
|
|
|
// InvalidateGeometry marks an object's subtree for recapture without changing
|
|
// focus snapshots, identity generations, or child/sibling traversal. Moving a
|
|
// container can move every descendant. Work is bounded by maxTreeNodes.
|
|
func (tree *AccessibleTree) InvalidateGeometry(id ObjectID) int {
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
pending := []ObjectID{id}
|
|
invalidated := 0
|
|
for len(pending) != 0 {
|
|
last := len(pending) - 1
|
|
currentID := pending[last]
|
|
pending = pending[:last]
|
|
entry, ok := tree.nodes[currentID]
|
|
if !ok || entry.geometryInvalid {
|
|
// A marked ancestor already marked its entire subtree. Only a
|
|
// complete Replace can introduce fresh descendants.
|
|
continue
|
|
}
|
|
pending = append(pending, entry.children...)
|
|
invalidated++
|
|
entry.geometryInvalid = true
|
|
tree.nodes[currentID] = entry
|
|
}
|
|
return invalidated
|
|
}
|
|
|
|
func (tree *AccessibleTree) Parent(reference ObjectReference) (ObjectReference, bool, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return ObjectReference{}, false, err
|
|
}
|
|
if !entry.hasParent {
|
|
return ObjectReference{}, false, nil
|
|
}
|
|
parent := tree.nodes[entry.parent]
|
|
return newObjectReference(entry.parent, parent), true, nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) FirstChild(reference ObjectReference) (ObjectReference, bool, error) {
|
|
return tree.child(reference, false)
|
|
}
|
|
|
|
func (tree *AccessibleTree) LastChild(reference ObjectReference) (ObjectReference, bool, error) {
|
|
return tree.child(reference, true)
|
|
}
|
|
|
|
func (tree *AccessibleTree) child(reference ObjectReference, last bool) (ObjectReference, bool, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return ObjectReference{}, false, err
|
|
}
|
|
if entry.childrenInvalid {
|
|
return ObjectReference{}, false, ErrIncompleteTree
|
|
}
|
|
if len(entry.children) == 0 {
|
|
return ObjectReference{}, false, nil
|
|
}
|
|
index := 0
|
|
if last {
|
|
index = len(entry.children) - 1
|
|
}
|
|
id := entry.children[index]
|
|
return newObjectReference(id, tree.nodes[id]), true, nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) PreviousSibling(reference ObjectReference) (ObjectReference, bool, error) {
|
|
return tree.sibling(reference, -1)
|
|
}
|
|
|
|
func (tree *AccessibleTree) NextSibling(reference ObjectReference) (ObjectReference, bool, error) {
|
|
return tree.sibling(reference, 1)
|
|
}
|
|
|
|
func (tree *AccessibleTree) sibling(reference ObjectReference, direction int) (ObjectReference, bool, error) {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return ObjectReference{}, false, err
|
|
}
|
|
if !entry.hasParent {
|
|
return ObjectReference{}, false, nil
|
|
}
|
|
parent := tree.nodes[entry.parent]
|
|
for index, id := range parent.children {
|
|
if id != reference.id {
|
|
continue
|
|
}
|
|
siblingIndex := index + direction
|
|
if siblingIndex < 0 || siblingIndex >= len(parent.children) {
|
|
return ObjectReference{}, false, nil
|
|
}
|
|
siblingID := parent.children[siblingIndex]
|
|
return newObjectReference(siblingID, tree.nodes[siblingID]), true, nil
|
|
}
|
|
return ObjectReference{}, false, fmt.Errorf("%w: parent does not contain child", ErrInvalidTree)
|
|
}
|
|
|
|
// Invalidate removes an object and all of its descendants. It returns the
|
|
// number of removed objects. An unknown identity is an idempotent no-op.
|
|
func (tree *AccessibleTree) Invalidate(id ObjectID) int {
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
entry, ok := tree.nodes[id]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
if entry.hasParent {
|
|
parent := tree.nodes[entry.parent]
|
|
for index, childID := range parent.children {
|
|
if childID == id {
|
|
parent.children = append(parent.children[:index:index], parent.children[index+1:]...)
|
|
tree.nodes[entry.parent] = parent
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
removed := 0
|
|
pending := []ObjectID{id}
|
|
for len(pending) != 0 {
|
|
last := len(pending) - 1
|
|
currentID := pending[last]
|
|
pending = pending[:last]
|
|
current, present := tree.nodes[currentID]
|
|
if !present {
|
|
continue
|
|
}
|
|
pending = append(pending, current.children...)
|
|
delete(tree.nodes, currentID)
|
|
removed++
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// InvalidateChildren removes every descendant of a live parent while keeping
|
|
// the parent reference valid. Structural additions use this conservative path
|
|
// because the cached child order is no longer complete until the next capture.
|
|
// Child traversal returns ErrIncompleteTree until Replace installs a fresh tree;
|
|
// the parent snapshot and its own parent/sibling relationships remain usable.
|
|
func (tree *AccessibleTree) InvalidateChildren(parentID ObjectID) int {
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
parent, ok := tree.nodes[parentID]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
pending := append([]ObjectID(nil), parent.children...)
|
|
parent.children = nil
|
|
parent.childrenInvalid = true
|
|
tree.nodes[parentID] = parent
|
|
removed := 0
|
|
for len(pending) != 0 {
|
|
last := len(pending) - 1
|
|
currentID := pending[last]
|
|
pending = pending[:last]
|
|
current, present := tree.nodes[currentID]
|
|
if !present {
|
|
continue
|
|
}
|
|
pending = append(pending, current.children...)
|
|
delete(tree.nodes, currentID)
|
|
removed++
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// MarkChildrenChanged requires fresh child order without declaring the existing
|
|
// children dead. A document insertion does not replace its surviving siblings.
|
|
// Document traversal refuses the old order until Replace validates the capture;
|
|
// explicit removals and lifecycle events still retire their object generations.
|
|
func (tree *AccessibleTree) MarkChildrenChanged(parentID ObjectID) int {
|
|
tree.mu.Lock()
|
|
defer tree.mu.Unlock()
|
|
parent, ok := tree.nodes[parentID]
|
|
if !ok || parent.childrenInvalid {
|
|
return 0
|
|
}
|
|
parent.childrenInvalid = true
|
|
tree.nodes[parentID] = parent
|
|
return 1
|
|
}
|
|
|
|
func (tree *AccessibleTree) resolveLocked(reference ObjectReference) (treeEntry, error) {
|
|
entry, ok := tree.nodes[reference.id]
|
|
if !ok {
|
|
if reference.Valid() {
|
|
return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrStaleObject, reference.id.BusName, reference.id.Path)
|
|
}
|
|
return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrObjectNotFound, reference.id.BusName, reference.id.Path)
|
|
}
|
|
if reference.generation == 0 || reference.generation != entry.generation {
|
|
return treeEntry{}, fmt.Errorf("%w: bus %q path %q", ErrStaleObject, reference.id.BusName, reference.id.Path)
|
|
}
|
|
if entry.snapshotInvalid {
|
|
return treeEntry{}, ErrStaleSnapshot
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func newObjectReference(id ObjectID, entry treeEntry) ObjectReference {
|
|
return ObjectReference{id: id, generation: entry.generation}
|
|
}
|
|
|
|
func validateTree(root ObjectID, nodes []TreeNode) (map[ObjectID]treeEntry, error) {
|
|
if !root.Valid() {
|
|
return nil, fmt.Errorf("%w: root identity is empty", ErrInvalidTree)
|
|
}
|
|
if len(nodes) == 0 {
|
|
return nil, fmt.Errorf("%w: tree is empty", ErrInvalidTree)
|
|
}
|
|
if len(nodes) > maxTreeNodes {
|
|
return nil, fmt.Errorf("%w: %d nodes exceeds limit %d", ErrInvalidTree, len(nodes), maxTreeNodes)
|
|
}
|
|
|
|
entries := make(map[ObjectID]treeEntry, len(nodes))
|
|
textBytes := 0
|
|
for _, node := range nodes {
|
|
id := node.Snapshot.ID()
|
|
if !id.Valid() {
|
|
return nil, fmt.Errorf("%w: node identity is empty", ErrInvalidTree)
|
|
}
|
|
if _, exists := entries[id]; exists {
|
|
return nil, fmt.Errorf("%w: duplicate bus %q path %q", ErrInvalidTree, id.BusName, id.Path)
|
|
}
|
|
if node.Snapshot.Defunct {
|
|
return nil, fmt.Errorf("%w: %w at bus %q path %q", ErrInvalidTree, ErrDeadObject, id.BusName, id.Path)
|
|
}
|
|
if !node.Geometry.valid() {
|
|
return nil, fmt.Errorf("%w: invalid component geometry", ErrInvalidTree)
|
|
}
|
|
if !node.Text.valid(node.Geometry) {
|
|
return nil, fmt.Errorf("%w: invalid visible text", ErrInvalidTree)
|
|
}
|
|
for _, r := range node.Text.Ranges {
|
|
textBytes += len(r.Text)
|
|
}
|
|
if textBytes > maxTreeTextBytes {
|
|
return nil, fmt.Errorf("%w: visible text exceeds tree limit", ErrInvalidTree)
|
|
}
|
|
text := node.Text
|
|
text.Ranges = append([]TextRange(nil), text.Ranges...)
|
|
children := append([]ObjectID(nil), node.Children...)
|
|
entries[id] = treeEntry{snapshot: node.Snapshot, children: children, geometry: node.Geometry, text: text}
|
|
}
|
|
if _, ok := entries[root]; !ok {
|
|
return nil, fmt.Errorf("%w: root is missing", ErrInvalidTree)
|
|
}
|
|
|
|
parents := make(map[ObjectID]ObjectID, len(nodes)-1)
|
|
for parentID, entry := range entries {
|
|
seenChildren := make(map[ObjectID]struct{}, len(entry.children))
|
|
for _, childID := range entry.children {
|
|
if !childID.Valid() {
|
|
return nil, fmt.Errorf("%w: child identity is empty", ErrInvalidTree)
|
|
}
|
|
if _, exists := entries[childID]; !exists {
|
|
return nil, fmt.Errorf("%w: child bus %q path %q is missing", ErrInvalidTree, childID.BusName, childID.Path)
|
|
}
|
|
if _, duplicate := seenChildren[childID]; duplicate {
|
|
return nil, fmt.Errorf("%w: duplicate child bus %q path %q", ErrInvalidTree, childID.BusName, childID.Path)
|
|
}
|
|
seenChildren[childID] = struct{}{}
|
|
if _, exists := parents[childID]; exists {
|
|
return nil, fmt.Errorf("%w: child bus %q path %q has multiple parents", ErrInvalidTree, childID.BusName, childID.Path)
|
|
}
|
|
parents[childID] = parentID
|
|
}
|
|
}
|
|
if _, hasParent := parents[root]; hasParent {
|
|
return nil, fmt.Errorf("%w: root has a parent", ErrInvalidTree)
|
|
}
|
|
for id, entry := range entries {
|
|
parent, hasParent := parents[id]
|
|
if id != root && !hasParent {
|
|
return nil, fmt.Errorf("%w: bus %q path %q is disconnected", ErrInvalidTree, id.BusName, id.Path)
|
|
}
|
|
entry.parent = parent
|
|
entry.hasParent = hasParent
|
|
entries[id] = entry
|
|
}
|
|
|
|
visited := make(map[ObjectID]bool, len(entries))
|
|
active := make(map[ObjectID]bool, len(entries))
|
|
var visit func(ObjectID) error
|
|
visit = func(id ObjectID) error {
|
|
if active[id] {
|
|
return fmt.Errorf("%w: cycle at bus %q path %q", ErrInvalidTree, id.BusName, id.Path)
|
|
}
|
|
if visited[id] {
|
|
return nil
|
|
}
|
|
active[id] = true
|
|
for _, childID := range entries[id].children {
|
|
if err := visit(childID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
active[id] = false
|
|
visited[id] = true
|
|
return nil
|
|
}
|
|
if err := visit(root); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(visited) != len(entries) {
|
|
return nil, fmt.Errorf("%w: tree contains unreachable nodes", ErrInvalidTree)
|
|
}
|
|
return entries, nil
|
|
}
|