// 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 . 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 }