// 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 ( "runtime/cgo" "unsafe" ) func (adapter *Adapter) Foreground() ForegroundState { state := adapter.foreground.Load() if state == nil { return ForegroundState{} } return *state } func (adapter *Adapter) ForegroundChanges() <-chan struct{} { return adapter.foregroundChanges } func (adapter *Adapter) refreshForeground() { value := adapter.foregroundReader.Read() previous := adapter.Foreground() if value == previous.Client { return } state := ForegroundState{Client: value, Epoch: previous.Epoch} if value.Window != previous.Client.Window || value.PID != previous.Client.PID || value.Known != previous.Client.Known { clear(adapter.foregroundProcesses) } if value.Known != previous.Client.Known || value.Window != previous.Client.Window { state.Epoch++ adapter.foregroundWindow = ObjectID{} adapter.selectedTabWindow = ObjectID{} adapter.activeDocument = ObjectID{} adapter.foregroundRecovery = 8 } adapter.foreground.Store(&state) select { case adapter.foregroundChanges <- struct{}{}: default: } } //export cthulhuAtspiForegroundCallback func cthulhuAtspiForegroundCallback(data unsafe.Pointer) { handle := cgo.Handle(C.cthulhu_callback_value((*C.cthulhu_callback_data)(data))) adapter := handle.Value().(*Adapter) adapter.applySecureReset() if adapter.secure.Load() { return } adapter.refreshForeground() adapter.refreshWindowTitle() if adapter.secureResync.Swap(false) { adapter.foregroundRecovery = 8 } adapter.recoverForeground() } func (adapter *Adapter) matchesForeground(window *C.AtspiAccessible, id ObjectID) bool { return adapter.matchForeground(window, id, nil, false) } func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID, check *ForegroundCheck, focusedMenuItem bool) bool { state := adapter.Foreground().Client if !state.Known { return true } if window == nil { if check != nil { check.Reason = "no X11 foreground window or accessible window" } return false } pid := int(C.atspi_accessible_get_process_id(window, nil)) if check != nil { check.AccessiblePID = pid } pid = adapter.foregroundProcessID(pid) // Desktop panel menus can grab the keyboard while X11 still names the // previous application. Orca accepts their active, showing menu window. // Keep the correlated X11 owner so dismissal can return to that client. if adapter.is_panel_menu(window) { return true } if state.Window == 0 { if check != nil { check.Reason = "no X11 foreground window or accessible window" } return false } if adapter.foregroundWindow.Valid() { if adapter.foregroundWindow == id { return true } matched := foregroundMenuPopupMatches(state, adapter.foregroundWindow, id, pid, focusedMenuItem || C.cthulhu_active_menu_window(window) != 0) if matched { return true } } // Same-process maximized windows can have identical bounds. As in // Python canBeActiveWindow(clearCache=True), only correlate a freshly // active, showing, non-iconified candidate. Never latch the first sibling // merely because its geometry matches the X11 client. C.atspi_accessible_clear_cache_single(window) states := C.atspi_accessible_get_state_set(window) if states == nil { if check != nil { check.Reason = "accessible window state unavailable" } return false } active := C.atspi_state_set_contains(states, C.ATSPI_STATE_ACTIVE) != 0 showing := C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0 iconified := C.atspi_state_set_contains(states, C.ATSPI_STATE_ICONIFIED) != 0 C.g_object_unref(C.gpointer(states)) if check != nil { check.Active, check.Showing, check.Iconified = active, showing, iconified } if !active || !showing || iconified { if check != nil { check.Reason = "accessible window inactive, hidden, or iconified" } return false } // Tiling window managers can report GTK's frame and X11's client at // different origins and sizes. Our own active preferences child is known // by its launched PID; require X11 to confirm that process is foreground. if preferences_foreground_matches(state, pid, adapter.preferencesPID.Load()) { adapter.foregroundWindow = id adapter.foregroundRecovery = 0 return true } component := C.atspi_accessible_get_component_iface(window) if component == nil { if check != nil { check.Reason = "accessible window component unavailable" } return false } bounds := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, nil) C.g_object_unref(C.gpointer(component)) if bounds == nil { if check != nil { check.Reason = "accessible window bounds unavailable" } return false } rectangle := Rectangle{X: int32(bounds.x), Y: int32(bounds.y), Width: int32(bounds.width), Height: int32(bounds.height)} C.g_free(C.gpointer(bounds)) if check != nil { check.Bounds = rectangle } if !foregroundWindowMatches(state, pid, rectangle) { if check != nil { if state.PID > 0 && pid > 0 && state.PID != pid { check.Reason = "process ID differs" } else { check.Reason = "window bounds differ" } } return false } // Mozilla can replace its GTK application root with a Gecko root without // changing the X11 client. Only inspect its window list after fresh state // and geometry match. Existing siblings cannot retire the previous owner. if adapter.foregroundWindow.Valid() && !adapter.replacesForegroundWindow(window, id, state.PID, pid) { if check != nil { check.Reason = "different accessible window from correlated foreground" } return false } if adapter.foregroundWindow.Valid() { adapter.forgetNativeWindowFocus(adapter.foregroundWindow) adapter.forgetWindowDocuments(adapter.foregroundWindow) } adapter.foregroundWindow = id adapter.foregroundRecovery = 0 return true } // The exception is for a native menu from the panel process, never an // arbitrary panel control or a provider claiming the panel's accessible name. // Query fresh window state on the GLib owner; no popup state is retained. func (adapter *Adapter) is_panel_menu(window *C.AtspiAccessible) bool { if adapter.applicationProcess(window) != "mate-panel" { return false } C.atspi_accessible_clear_cache_single(window) return C.cthulhu_active_menu_window(window) != 0 } func (adapter *Adapter) replacesForegroundWindow(window *C.AtspiAccessible, id ObjectID, foregroundPID, pid int) bool { if foregroundPID <= 0 || pid != foregroundPID || id.BusName != adapter.foregroundWindow.BusName { return false } application := C.atspi_accessible_get_parent(window, nil) if application == nil { return false } defer C.g_object_unref(C.gpointer(application)) if C.atspi_accessible_get_role(application, nil) != C.ATSPI_ROLE_APPLICATION { return false } // A retired GTK bridge can still answer queries after Gecko registers // its replacement application. Do not let that detached root reclaim // the client. GetApplication itself can use libatspi's old root cache. desktop := C.atspi_get_desktop(0) if desktop == nil { return false } defer C.g_object_unref(C.gpointer(desktop)) C.atspi_accessible_clear_cache_single(desktop) var queryError *C.GError applications := int(C.atspi_accessible_get_child_count(desktop, &queryError)) if queryError != nil { C.g_error_free(queryError) return false } if applications <= 0 || applications > 128 { return false } registered := false for index := 0; index < applications; index++ { child := C.atspi_accessible_get_child_at_index(desktop, C.gint(index), &queryError) if queryError != nil { C.g_error_free(queryError) } if child == nil { return false } matches := nativeObjectID(child) == nativeObjectID(application) C.g_object_unref(C.gpointer(child)) if queryError != nil { return false } if matches { registered = true break } } if !registered { return false } C.atspi_accessible_clear_cache_single(application) count := int(C.atspi_accessible_get_child_count(application, &queryError)) if queryError != nil { C.g_error_free(queryError) return false } if count <= 0 || count > 128 { return false } windows := make([]ObjectID, 0, count) for index := 0; index < count; index++ { child := C.atspi_accessible_get_child_at_index(application, C.gint(index), &queryError) if queryError != nil { C.g_error_free(queryError) } if child == nil { return false } childID := nativeObjectID(child) C.g_object_unref(C.gpointer(child)) if queryError != nil || !childID.Valid() { return false } windows = append(windows, childID) } return foregroundWindowReplaced(adapter.foregroundWindow, id, windows) } // Recovery runs only at foreground transitions, off the event consumer. It // handles returning from an empty workspace when a toolkit retains focus and // emits no new focused-child event. Searches and retries are bounded. func (adapter *Adapter) recoverForeground() { secureEpoch := adapter.secureEpoch.Load() state := adapter.Foreground() if adapter.foregroundRecovery == 0 || adapter.recoveringForeground || !state.Client.Known || state.Client.Window == 0 || state.Client.PID <= 0 { return } adapter.foregroundRecovery-- adapter.recoveringForeground = true defer func() { adapter.recoveringForeground = false }() desktop := C.atspi_get_desktop(0) if desktop == nil { return } defer C.g_object_unref(C.gpointer(desktop)) count := min(int(C.atspi_accessible_get_child_count(desktop, nil)), 128) for i := 0; i < count; i++ { application := C.atspi_accessible_get_child_at_index(desktop, C.gint(i), nil) if application == nil { continue } pid := int(C.atspi_accessible_get_process_id(application, nil)) if adapter.foregroundProcessID(pid) != state.Client.PID { C.g_object_unref(C.gpointer(application)) continue } windows := min(int(C.atspi_accessible_get_child_count(application, nil)), 128) for j := 0; j < windows; j++ { window := C.atspi_accessible_get_child_at_index(application, C.gint(j), nil) if window == nil { continue } if state.Epoch == adapter.Foreground().Epoch && adapter.matchesForeground(window, nativeObjectID(window)) { target := adapter.libreOfficeFrameTarget(window) officeFrame := target != nil if target == nil { target = C.cthulhu_focused_descendant(window) } if target == nil { target = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(window))) } // Recover the active child just as a normal focus event does. role := C.atspi_accessible_get_role(target, nil) var child *C.AtspiAccessible if role == C.ATSPI_ROLE_TABLE { child = C.cthulhu_table_selected_child(target) } if role == C.ATSPI_ROLE_MENU { child = C.cthulhu_selected_child(target) } if child != nil { C.g_object_unref(C.gpointer(target)) target = child } snapshot := snapshotFromNativeWithCaret(target, true, false, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) if officeFrame { snapshot.ApplicationProcess = adapter.applicationProcess(target) } event := NewEvent(WindowActivatedEvent, snapshot) event.Window = nativeObjectID(window) name := C.atspi_accessible_get_name(window, nil) event.WindowTitle = sanitizeSnapshotField(C.GoString(name)) C.g_free(C.gpointer(name)) event.WindowDescription = nativeWindowDescription(window, event.Type, snapshot.Role) if snapshot.Role == "tree table" || snapshot.Role == "table cell" { if labels := C.cthulhu_collect_simple_prompt_text(window); labels != nil { event.WindowMessage = sanitizeSnapshotField(C.GoString(labels)) C.g_free(C.gpointer(labels)) } if event.WindowMessage == "" { adapter.promptRefreshWindow = event.Window } } event.OriginPath = event.Window.Path event.Foreground = adapter.Foreground() adapter.rememberNativeFocus(target, snapshot.ID()) C.g_object_unref(C.gpointer(target)) adapter.enqueueSecureEvent(event, secureEpoch) C.g_object_unref(C.gpointer(window)) C.g_object_unref(C.gpointer(application)) return } C.g_object_unref(C.gpointer(window)) } C.g_object_unref(C.gpointer(application)) } } // SetSecureInputActive publishes policy to the GLib owner. Native references // are released there; no caller outside that thread touches AT-SPI objects.