// 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 := 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.