Keep failed cursor trials and native recapture anchored to the original document, and restore the requested cached root before LRU eviction. Base braille retry resets on established connection time rather than stalled startup. Poll private AT-SPI daemon ownership without triggering service activation and clean up partial startup failures. Add focused regressions and update browser testing notes.
410 lines
14 KiB
Go
410 lines
14 KiB
Go
// 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"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID, document, documentText bool) (*AccessibleTree, error) {
|
|
if document {
|
|
trace := capture_diagnostics(ctx)
|
|
trace.begin_stage("root-selection", reviewFocus, "")
|
|
root, err := adapter.nativeDocumentRoot(ctx, reviewFocus)
|
|
if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrStaleObject) {
|
|
// A removed focus may still belong to a retained capture. Recover
|
|
// through that document's root, never through another tab's focus
|
|
// history merely because it shares the application's bus.
|
|
if owner := adapter.retainedDocumentRoot(reviewFocus); owner.Valid() && owner != reviewFocus {
|
|
root, err = adapter.nativeDocumentRoot(ctx, owner)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
trace.set_root(nativeObjectID(root), "document")
|
|
trace.begin_stage("cache-preparation", nativeObjectID(root), "")
|
|
adapter.rotateDocumentSlots(nativeObjectID(root))
|
|
adapter.treeMutex.Lock()
|
|
previousTree := adapter.documentTree
|
|
adapter.treeMutex.Unlock()
|
|
cache := nativeTreeCache{}
|
|
if !documentText && previousTree != nil {
|
|
cache.nodes = previousTree.nativeRefresh(nativeObjectID(root))
|
|
cache.sources = adapter.nativeDocument
|
|
}
|
|
// Retain this bounded capture for narrow OCR geometry queries too.
|
|
retained := make(map[ObjectID]*C.AtspiAccessible)
|
|
rootID, nodes, err := captureNativeTree(ctx, root, "", true, documentText, retained, cache)
|
|
var tree *AccessibleTree
|
|
if err == nil {
|
|
tree, err = adapter.installCapturedTree(rootID, nodes, true, documentText)
|
|
}
|
|
if err != nil {
|
|
for _, source := range retained {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
return nil, err
|
|
}
|
|
adapter.clearNativeDocument()
|
|
adapter.nativeDocument = retained
|
|
return tree, nil
|
|
}
|
|
trace := capture_diagnostics(ctx)
|
|
trace.begin_stage("root-selection", reviewFocus, "")
|
|
rootScope := "window"
|
|
var rootSource *C.AtspiAccessible
|
|
if reviewFocus.Valid() {
|
|
// A flat-review command only needs its owning window. Starting from
|
|
// the desktop walks every window in the browser process, including
|
|
// unrelated tabs and detached windows, before selecting this one.
|
|
for _, item := range adapter.nativeFocus {
|
|
if item.id == reviewFocus {
|
|
rootSource = C.cthulhu_window_ancestor(item.source)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if rootSource == nil {
|
|
rootScope = "desktop"
|
|
rootSource = C.atspi_get_desktop(0)
|
|
}
|
|
if rootSource == nil {
|
|
return nil, errors.New("capture AT-SPI review window: root is unavailable")
|
|
}
|
|
trace.set_root(nativeObjectID(rootSource), rootScope)
|
|
trace.begin_stage("cache-preparation", nativeObjectID(rootSource), "")
|
|
retained := make(map[ObjectID]*C.AtspiAccessible)
|
|
cache := nativeTreeCache{}
|
|
if reviewFocus.BusName != "" {
|
|
if adapter.nativeReviewBus == reviewFocus.BusName {
|
|
adapter.treeMutex.Lock()
|
|
previousTree := adapter.tree
|
|
adapter.treeMutex.Unlock()
|
|
if previousTree != nil {
|
|
cache.nodes = previousTree.nativeRefresh(nativeObjectID(rootSource))
|
|
cache.sources = adapter.nativeReview
|
|
}
|
|
}
|
|
}
|
|
root, nodes, err := captureNativeTree(ctx, rootSource, reviewFocus.BusName, document, documentText, retained, cache)
|
|
if err != nil {
|
|
for _, source := range retained {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
return nil, err
|
|
}
|
|
trace.begin_stage("tree-installation", root, "")
|
|
tree, err := adapter.installCapturedTree(root, nodes, document, documentText)
|
|
if err != nil {
|
|
for _, source := range retained {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
return nil, err
|
|
}
|
|
trace.begin_stage("reference-cleanup", root, "")
|
|
adapter.clearNativeReview()
|
|
adapter.nativeReview = retained
|
|
adapter.nativeReviewBus = reviewFocus.BusName
|
|
trace.begin_stage("result-delivery", root, "")
|
|
return tree, nil
|
|
}
|
|
|
|
// Remember a bounded set of event sources so document capture can start at
|
|
// native focus, without rediscovering it through cached browser chrome. All
|
|
// references are used and released on the GLib thread, including at shutdown.
|
|
func (adapter *Adapter) rememberNativeFocus(source *C.AtspiAccessible, id ObjectID) {
|
|
if !id.Valid() {
|
|
return
|
|
}
|
|
for i, item := range adapter.nativeFocus {
|
|
if item.id == id {
|
|
C.g_object_unref(C.gpointer(item.source))
|
|
adapter.nativeFocus = append(adapter.nativeFocus[:i], adapter.nativeFocus[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if len(adapter.nativeFocus) == 16 {
|
|
C.g_object_unref(C.gpointer(adapter.nativeFocus[0].source))
|
|
adapter.nativeFocus = adapter.nativeFocus[1:]
|
|
}
|
|
owned := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
|
adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id: id, source: owned})
|
|
}
|
|
|
|
// An unregistered bridge window must not remain a focused chrome candidate
|
|
// after its replacement has acquired the same X11 client. Other windows keep
|
|
// their retained focus references. This runs only on the GLib owner.
|
|
func (adapter *Adapter) forgetNativeWindowFocus(windowID ObjectID) {
|
|
retained := adapter.nativeFocus[:0]
|
|
for _, item := range adapter.nativeFocus {
|
|
window := C.cthulhu_window_ancestor(item.source)
|
|
retired := nativeObjectID(window) == windowID
|
|
if window != nil {
|
|
C.g_object_unref(C.gpointer(window))
|
|
}
|
|
if retired {
|
|
C.g_object_unref(C.gpointer(item.source))
|
|
} else {
|
|
retained = append(retained, item)
|
|
}
|
|
}
|
|
adapter.nativeFocus = retained
|
|
}
|
|
|
|
// Keep an exact document-to-frame association across Chromium tab switches.
|
|
// Some returned tabs temporarily lose their AT-SPI parent chain, although
|
|
// their document identity and the already correlated frame remain live.
|
|
func (adapter *Adapter) rememberDocumentWindow(document ObjectID, window *C.AtspiAccessible) {
|
|
if !document.Valid() || window == nil {
|
|
return
|
|
}
|
|
if adapter.documentWindows == nil {
|
|
adapter.documentWindows = make(map[ObjectID]nativeDocumentWindow)
|
|
}
|
|
if old, ok := adapter.documentWindows[document]; ok {
|
|
C.g_object_unref(C.gpointer(old.source))
|
|
} else {
|
|
if len(adapter.documentWindowIDs) == 32 {
|
|
oldest := adapter.documentWindowIDs[0]
|
|
C.g_object_unref(C.gpointer(adapter.documentWindows[oldest].source))
|
|
delete(adapter.documentWindows, oldest)
|
|
adapter.documentWindowIDs = adapter.documentWindowIDs[1:]
|
|
}
|
|
adapter.documentWindowIDs = append(adapter.documentWindowIDs, document)
|
|
}
|
|
adapter.documentWindows[document] = nativeDocumentWindow{
|
|
id: nativeObjectID(window), source: (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(window))),
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) forgetDocumentWindow(document ObjectID) {
|
|
known, ok := adapter.documentWindows[document]
|
|
if !ok {
|
|
return
|
|
}
|
|
C.g_object_unref(C.gpointer(known.source))
|
|
delete(adapter.documentWindows, document)
|
|
for i, id := range adapter.documentWindowIDs {
|
|
if id == document {
|
|
adapter.documentWindowIDs = append(adapter.documentWindowIDs[:i], adapter.documentWindowIDs[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if adapter.activeDocument == document {
|
|
adapter.activeDocument = ObjectID{}
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) forgetWindowDocuments(window ObjectID) {
|
|
for _, id := range append([]ObjectID(nil), adapter.documentWindowIDs...) {
|
|
if adapter.documentWindows[id].id == window {
|
|
adapter.forgetDocumentWindow(id)
|
|
}
|
|
}
|
|
if adapter.selectedTabWindow == window {
|
|
adapter.selectedTabWindow = ObjectID{}
|
|
}
|
|
}
|
|
|
|
func (adapter *Adapter) detachedDocumentWindow(source *C.AtspiAccessible, eventType string, detail int) (*C.AtspiAccessible, ObjectID) {
|
|
if adapter.selectedTabWindow == (ObjectID{}) && !adapter.activeDocument.Valid() {
|
|
return nil, ObjectID{}
|
|
}
|
|
var document *C.AtspiAccessible
|
|
if _, known := adapter.documentWindows[nativeObjectID(source)]; known {
|
|
document = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
|
} else {
|
|
document = C.cthulhu_document_ancestor(source)
|
|
}
|
|
if document == nil {
|
|
return nil, ObjectID{}
|
|
}
|
|
id := nativeObjectID(document)
|
|
states := C.atspi_accessible_get_state_set(document)
|
|
showing := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0 &&
|
|
C.atspi_state_set_contains(states, C.ATSPI_STATE_VISIBLE) != 0 &&
|
|
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
|
|
if states != nil {
|
|
C.g_object_unref(C.gpointer(states))
|
|
}
|
|
C.g_object_unref(C.gpointer(document))
|
|
known, ok := adapter.documentWindows[id]
|
|
if !showing || !ok || !restoredDocumentWindowAllowed(id, adapter.activeDocument,
|
|
adapter.selectedTabWindow, known.id, eventType, detail) {
|
|
return nil, ObjectID{}
|
|
}
|
|
frameStates := C.atspi_accessible_get_state_set(known.source)
|
|
frameActive := frameStates != nil && C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_ACTIVE) != 0 &&
|
|
C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_SHOWING) != 0 &&
|
|
C.atspi_state_set_contains(frameStates, C.ATSPI_STATE_DEFUNCT) == 0
|
|
if frameStates != nil {
|
|
C.g_object_unref(C.gpointer(frameStates))
|
|
}
|
|
if !frameActive {
|
|
return nil, ObjectID{}
|
|
}
|
|
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(known.source))), id
|
|
}
|
|
|
|
func restoredDocumentWindowAllowed(document, active, selectedWindow, knownWindow ObjectID, eventType string, detail int) bool {
|
|
if !document.Valid() || !knownWindow.Valid() {
|
|
return false
|
|
}
|
|
if eventType == FocusedStateEvent && detail == 0 {
|
|
return false
|
|
}
|
|
if document == active {
|
|
return true
|
|
}
|
|
return selectedWindow == knownWindow && eventType == FocusedStateEvent && detail != 0
|
|
}
|
|
|
|
// Called only by the GLib owner at load completion. Chromium can finish an
|
|
// earlier load after the user has returned to chrome. Window activation alone
|
|
// cannot distinguish that from a load which should acquire document focus.
|
|
func (adapter *Adapter) focusedChrome(document ObjectID) ObjectID {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
for i := len(adapter.nativeFocus) - 1; i >= 0 && ctx.Err() == nil; i-- {
|
|
item := adapter.nativeFocus[i]
|
|
if item.id.BusName != document.BusName || item.id == document {
|
|
continue
|
|
}
|
|
C.atspi_accessible_clear_cache_single(item.source)
|
|
states := C.atspi_accessible_get_state_set(item.source)
|
|
if states == nil {
|
|
continue
|
|
}
|
|
focused := C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0
|
|
C.g_object_unref(C.gpointer(states))
|
|
if !focused {
|
|
continue
|
|
}
|
|
root, err := adapter.nativeDocumentRoot(ctx, item.id)
|
|
if root != nil {
|
|
C.g_object_unref(C.gpointer(root))
|
|
}
|
|
if errors.Is(err, ErrNotInDocument) {
|
|
return item.id
|
|
}
|
|
}
|
|
return ObjectID{}
|
|
}
|
|
|
|
// Return an owned reference to the outermost document containing focus.
|
|
// Parent traversal is bounded and never scans other windows or tabs.
|
|
// retainedDocumentSource returns a new reference to a retained native
|
|
// source from the active or a stashed document capture. Capture root
|
|
// selection needs this when the focus identity (often the document root
|
|
// itself after a load, or a saved document used as capture anchor) never
|
|
// received a focus event and so is absent from the focus history.
|
|
func (adapter *Adapter) retainedDocumentSource(id ObjectID) *C.AtspiAccessible {
|
|
adapter.treeMutex.Lock()
|
|
defer adapter.treeMutex.Unlock()
|
|
if source := adapter.nativeDocument[id]; source != nil {
|
|
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
|
}
|
|
for _, root := range adapter.stashedDocumentIDs {
|
|
if source := adapter.stashedDocuments[root].sources[id]; source != nil {
|
|
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *Adapter) nativeDocumentRoot(ctx context.Context, focus ObjectID) (*C.AtspiAccessible, 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 {
|
|
source = adapter.retainedDocumentSource(focus)
|
|
}
|
|
if source == nil {
|
|
return nil, fmt.Errorf("%w: document focus bus %q path %q", ErrObjectNotFound, focus.BusName, focus.Path)
|
|
}
|
|
C.atspi_accessible_clear_cache_single(source)
|
|
states := C.atspi_accessible_get_state_set(source)
|
|
if states != nil {
|
|
dead := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
|
|
C.g_object_unref(C.gpointer(states))
|
|
if dead {
|
|
C.g_object_unref(C.gpointer(source))
|
|
return nil, ErrStaleObject
|
|
}
|
|
}
|
|
var document *C.AtspiAccessible
|
|
defer func() {
|
|
if source != nil {
|
|
C.g_object_unref(C.gpointer(source))
|
|
}
|
|
if document != nil {
|
|
C.g_object_unref(C.gpointer(document))
|
|
}
|
|
}()
|
|
seen := make(map[ObjectID]bool)
|
|
for depth := 0; source != nil; depth++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
id := nativeObjectID(source)
|
|
if depth >= 256 || seen[id] {
|
|
return nil, ErrInvalidTree
|
|
}
|
|
seen[id] = true
|
|
C.atspi_accessible_clear_cache_single(source)
|
|
var nativeErr *C.GError
|
|
role := C.atspi_accessible_get_role(source, &nativeErr)
|
|
if nativeErr != nil {
|
|
err := nativeError("query document ancestor role", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return nil, err
|
|
}
|
|
if role == C.ATSPI_ROLE_DOCUMENT_WEB || role == C.ATSPI_ROLE_DOCUMENT_FRAME || role == C.ATSPI_ROLE_EMBEDDED {
|
|
if document != nil {
|
|
C.g_object_unref(C.gpointer(document))
|
|
}
|
|
document = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
|
}
|
|
if role == C.ATSPI_ROLE_APPLICATION {
|
|
break
|
|
}
|
|
parent := C.atspi_accessible_get_parent(source, &nativeErr)
|
|
if nativeErr != nil {
|
|
if parent != nil {
|
|
C.g_object_unref(C.gpointer(parent))
|
|
}
|
|
err := nativeError("query document ancestor", nativeErr)
|
|
C.g_error_free(nativeErr)
|
|
return nil, err
|
|
}
|
|
C.g_object_unref(C.gpointer(source))
|
|
source = parent
|
|
}
|
|
if document == nil {
|
|
return nil, ErrNotInDocument
|
|
}
|
|
root := document
|
|
document = nil
|
|
return root, nil
|
|
}
|
|
|
|
// geometryFromNative runs only during owned-tree capture on the GLib thread.
|
|
// All interface/state/rectangle references are released here; only scalar
|
|
// values cross the boundary. Unsupported or failed component queries leave
|
|
// bounds unknown, while cancellation aborts capture.
|