// 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 } return retryDocumentSoundGeometry(ctx, link.Valid(), func(ctx context.Context) ([2]SoundGeometry, error) { 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 := document_position_matches_retained_focus(tree, position, adapter.nativeFocus[len(adapter.nativeFocus)-1]); 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"} var nativeErr *C.GError count := int(C.atspi_action_get_n_actions(action, &nativeErr)) if nativeErr != nil { err := nativeError("get document actions", nativeErr) C.g_error_free(nativeErr) return err } // Read each action name once. Previously each preferred spelling caused // another full round of synchronous accessibility calls. actionNames := make([]string, min(max(count, 0), 64)) for index := range actionNames { if err := ctx.Err(); err != nil { return err } name := C.atspi_action_get_name(action, C.gint(index), nil) actionNames[index] = C.GoString(name) C.g_free(C.gpointer(name)) } for _, wanted := range names { for index, name := range actionNames { if !strings.EqualFold(name, wanted) { 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 { adapter.treeInvalidations.Add(uint64(tree.markAncestorChildrenChanged(position.ID()))) 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 } // ReadDocumentCharacters shares the normal GLib request/lifetime checks. The // consumer can skip spaces in one block without one provider request per rune. func (adapter *Adapter) ReadDocumentCharacters(ctx context.Context, position ObjectReference, offset int) (DocumentCaretText, error) { return adapter.ReadDocumentCaret(ctx, position, offset, DocumentCaretCharacters) } 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 || unit == DocumentCaretCharacters) && 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 := adapter.document_object_error(position.ID(), nativeError("get document text count", nativeErr)) C.g_error_free(nativeErr) return DocumentCaretText{}, err } if count == 0 && snapshot.CharacterCount > 0 && !snapshot.Editable && (snapshot.Role == "static" || snapshot.Role == "text") { return DocumentCaretText{}, adapter.document_text_became_empty(position.ID()) } 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 == DocumentCaretCharacters { start := max(0, offset/128*128) end := min(count, start+128) content := C.atspi_text_get_text(text, C.gint(start), C.gint(end), &nativeErr) if nativeErr != nil { err := adapter.document_object_error(position.ID(), nativeError("read document characters", nativeErr)) C.g_error_free(nativeErr) C.g_free(C.gpointer(content)) return DocumentCaretText{}, err } defer C.g_free(C.gpointer(content)) return DocumentCaretText{Start: start, End: end, RawText: C.GoString(content), CharacterCount: count}, nil } 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 { var boundsErr error line.Bounds, boundsErr = (nativeReviewText{text: text}).bounds(ctx, line.Start, line.End) if boundsErr != nil { if err := adapter.document_object_error(position.ID(), boundsErr); errors.Is(err, ErrDeadObject) { return DocumentCaretText{}, err } } if err := ctx.Err(); err != nil { return DocumentCaretText{}, err } } // Generated text can disappear between the count, line and geometry // queries. Keep missing geometry optional for live text, but refresh a // stale text object instead of dropping its fragment from the line. if !snapshot.Editable && (snapshot.Role == "static" || snapshot.Role == "text") && (!line.Bounds.Valid() || line.End <= line.Start) { var verifyErr *C.GError remaining := int(C.atspi_text_get_character_count(text, &verifyErr)) if verifyErr != nil { err := adapter.document_object_error(position.ID(), nativeError("verify document text count", verifyErr)) C.g_error_free(verifyErr) if errors.Is(err, ErrDeadObject) { return DocumentCaretText{}, err } } else if remaining == 0 && count > 0 { return DocumentCaretText{}, adapter.document_text_became_empty(position.ID()) } } 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 := adapter.document_object_error(position.ID(), 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 } // Document captures are rooted at the outermost web document. Its // identity remains valid when an unrelated changing label is marked stale. document, err := tree.Root() if err != nil { return err } // 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 := document_position_matches_retained_focus(tree, position, adapter.nativeFocus[len(adapter.nativeFocus)-1]); err != nil { return err } } } echoIDs, err := tree.subtreeIDs(position) if err != nil { return err } echoIDs = append(echoIDs, documentCaretEchoAncestors(tree, position)...) adapter.navigationCaretOffset = offset adapter.navigationSelectionOffsets = nil adapter.navigationEcho = make(map[ObjectID]uint8, len(echoIDs)) for _, id := range echoIDs { adapter.navigationEcho[id] = 15 } 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()) adapter.nativeFocus[len(adapter.nativeFocus)-1].navigationDocument = document 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 := adapter.document_object_error(position.ID(), 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-- { // Removing a selection can first collapse to its start or end. Those // intermediate caret events must not overwrite the requested position, // particularly when routing Braille before the result reaches the UI. selection := C.atspi_text_get_selection(text, 0, nil) if selection != nil { if adapter.navigationSelectionOffsets == nil { adapter.navigationSelectionOffsets = make(map[int]bool) } adapter.navigationSelectionOffsets[int(selection.start_offset)] = true adapter.navigationSelectionOffsets[int(selection.end_offset)] = true C.g_free(C.gpointer(selection)) } C.atspi_text_remove_selection(text, 0, nil) } moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr) if nativeErr != nil { err := adapter.document_object_error(position.ID(), 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()) adapter.nativeFocus[len(adapter.nativeFocus)-1].navigationDocument = document return nil } // A dynamic page can replace the text at a successfully placed browse caret // before the next command. That remembered position is not evidence that native // focus left the document. Retain its generation-checked document proof while // continuing to reject actual focus changes to chrome or another document. func document_position_matches_retained_focus(tree *AccessibleTree, position ObjectReference, focus nativeFocusReference) error { err := documentPositionMatchesFocus(tree, position, focus.id) if !errors.Is(err, ErrObjectNotFound) || !focus.navigationDocument.Valid() { return err } owner, targetErr := tree.DocumentContext(position) if targetErr != nil { return targetErr } if owner.TopLevel != focus.navigationDocument { return err } 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.