384 lines
13 KiB
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.
|