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