Files
cthulhu/internal/atspi/adapter_foreground_native.go

371 lines
12 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 (
"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.