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