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