Files
cthulhu/internal/atspi/adapter_references_native.go

384 lines
13 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 {
root, err := adapter.nativeDocumentRoot(ctx, reviewFocus)
adapter.treeMutex.Lock()
previousTree := adapter.documentTree
adapter.treeMutex.Unlock()
if (errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrStaleObject)) && previousTree != nil && len(adapter.nativeFocus) != 0 {
// Dynamic pages can remove the browse cursor without sending a new
// focus event. Recover only from the newest retained native focus in
// the same document. A chrome or other-tab focus is not a fallback.
currentRoot, rootErr := previousTree.Root()
for i := len(adapter.nativeFocus) - 1; rootErr == nil && i >= 0; i-- {
latest := adapter.nativeFocus[i]
if latest.id.BusName != reviewFocus.BusName {
break
}
C.atspi_accessible_clear_cache_single(latest.source)
states := C.atspi_accessible_get_state_set(latest.source)
if states == nil {
continue
}
dead := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
C.g_object_unref(C.gpointer(states))
if dead {
continue
}
// A removed browse target can retain a non-defunct proxy after
// Chromium has dropped its accessible object. Its role is then
// invalid; try the preceding real focus instead.
if C.atspi_accessible_get_role(latest.source, nil) == C.ATSPI_ROLE_INVALID {
continue
}
candidate, candidateErr := adapter.nativeDocumentRoot(ctx, latest.id)
if candidateErr != nil {
break
}
if nativeObjectID(candidate) == currentRoot.ID() {
root, err = candidate, nil
} else {
C.g_object_unref(C.gpointer(candidate))
}
break
}
}
if err != nil {
return nil, err
}
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, adapter.captureSound.Load(), 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
}
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 {
rootSource = C.atspi_get_desktop(0)
}
if rootSource == nil {
return nil, errors.New("capture AT-SPI review window: root is unavailable")
}
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, adapter.captureSound.Load(), retained, cache)
if err != nil {
for _, source := range retained {
C.g_object_unref(C.gpointer(source))
}
return nil, err
}
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
}
adapter.clearNativeReview()
adapter.nativeReview = retained
adapter.nativeReviewBus = reviewFocus.BusName
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, owned})
}
// 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.
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 {
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.