862 lines
37 KiB
C
862 lines
37 KiB
C
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "adapter_native.h"
|
|
|
|
extern void cthulhuAtspiEventCallback(AtspiEvent *event, void *userData);
|
|
extern void cthulhuAtspiTreeCaptureCallback(void *userData);
|
|
extern void cthulhuAtspiForegroundCallback(void *userData);
|
|
static gboolean foreground_tick(gpointer data) { cthulhuAtspiForegroundCallback(data); return G_SOURCE_CONTINUE; }
|
|
guint start_foreground_timer(gpointer data) { return g_timeout_add(50, foreground_tick, data); }
|
|
|
|
const gchar *cthulhu_event_type(AtspiEvent *event) {
|
|
return event->type;
|
|
}
|
|
|
|
gint cthulhu_event_detail1(AtspiEvent *event) {
|
|
return event->detail1;
|
|
}
|
|
|
|
gchar *cthulhu_document_uri(AtspiAccessible *source) {
|
|
AtspiDocument *document = atspi_accessible_get_document_iface(source);
|
|
if (document == NULL) return NULL;
|
|
GError *error = NULL;
|
|
gchar *uri = atspi_document_get_attribute_value(document, "DocURL", &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
g_free(uri);
|
|
uri = NULL;
|
|
}
|
|
if (uri == NULL || *uri == '\0') {
|
|
g_free(uri);
|
|
uri = atspi_document_get_attribute_value(document, "URI", &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
g_free(uri);
|
|
uri = NULL;
|
|
}
|
|
}
|
|
g_object_unref(document);
|
|
return uri;
|
|
}
|
|
|
|
gchar *cthulhu_event_payload(AtspiEvent *event) {
|
|
if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_STRING(&event->any_data)) {
|
|
return NULL;
|
|
}
|
|
return g_value_dup_string(&event->any_data);
|
|
}
|
|
|
|
gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key) {
|
|
GError *error = NULL;
|
|
GHashTable *attributes = atspi_accessible_get_attributes(source, &error);
|
|
if (error != NULL) g_clear_error(&error);
|
|
if (attributes == NULL) return NULL;
|
|
gchar *value = g_strdup(g_hash_table_lookup(attributes, key));
|
|
g_hash_table_unref(attributes);
|
|
return value;
|
|
}
|
|
|
|
cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source) {
|
|
cthulhu_live_properties properties = {0};
|
|
GError *error = NULL;
|
|
GHashTable *attributes = atspi_accessible_get_attributes(source, &error);
|
|
if (error != NULL) g_clear_error(&error);
|
|
if (attributes == NULL) return properties;
|
|
properties.politeness = g_strdup(g_hash_table_lookup(attributes, "container-live"));
|
|
properties.atomic = g_strcmp0(g_hash_table_lookup(attributes, "container-atomic"), "true") == 0;
|
|
properties.busy = g_strcmp0(g_hash_table_lookup(attributes, "container-busy"), "true") == 0;
|
|
g_hash_table_unref(attributes);
|
|
return properties;
|
|
}
|
|
|
|
// container-atomic is inherited. Chromium also publishes atomic=false on
|
|
// ordinary descendants, so only atomic=true identifies an enclosing region.
|
|
// The caller first checks the source's effective container-atomic attribute.
|
|
gchar *cthulhu_atomic_live_content(AtspiAccessible *source) {
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
for (guint depth = 0; current != NULL && depth < 64; depth++) {
|
|
gchar *atomic = cthulhu_live_attribute(current, "atomic");
|
|
gboolean boundary = g_strcmp0(atomic, "true") == 0;
|
|
g_free(atomic);
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (boundary) {
|
|
gchar *content = cthulhu_live_content(current);
|
|
g_object_unref(current);
|
|
return content;
|
|
}
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
|
|
g_object_unref(current);
|
|
return cthulhu_live_content(source);
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_busy_live_container(AtspiAccessible *source) {
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
for (guint depth = 0; current != NULL && depth < 64; depth++) {
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(current);
|
|
gboolean busy = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_BUSY);
|
|
if (states != NULL) g_object_unref(states);
|
|
if (busy) return current;
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
|
|
g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event) {
|
|
if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) {
|
|
return NULL;
|
|
}
|
|
GObject *object = g_value_get_object(&event->any_data);
|
|
if (object == NULL || !ATSPI_IS_ACCESSIBLE(object)) {
|
|
return NULL;
|
|
}
|
|
return ATSPI_ACCESSIBLE(object);
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source) {
|
|
AtspiSelection *selection = atspi_accessible_get_selection_iface(source);
|
|
if (selection == NULL) {
|
|
return NULL;
|
|
}
|
|
GError *error = NULL;
|
|
AtspiAccessible *child = atspi_selection_get_selected_child(selection, 0, &error);
|
|
g_object_unref(selection);
|
|
if (error != NULL) {
|
|
g_error_free(error);
|
|
return NULL;
|
|
}
|
|
return child;
|
|
}
|
|
|
|
// GtkTreeView can override its role to LIST while retaining table cells and
|
|
// table selection. Ordinary lists and browser content keep their own policy.
|
|
gboolean cthulhu_is_gtk_list_table(AtspiAccessible *source) {
|
|
if (atspi_accessible_get_role(source, NULL) != ATSPI_ROLE_LIST) return FALSE;
|
|
AtspiTable *table = atspi_accessible_get_table_iface(source);
|
|
if (table == NULL) return FALSE;
|
|
g_object_unref(table);
|
|
gchar *toolkit = atspi_accessible_get_toolkit_name(source, NULL);
|
|
gboolean matches = g_ascii_strcasecmp(toolkit != NULL ? toolkit : "", "gtk") == 0;
|
|
g_free(toolkit);
|
|
return matches;
|
|
}
|
|
|
|
// Qt item views use TREE even for flat, multi-column playlists. Require the
|
|
// Table interface before applying row-selection policy to a tree.
|
|
gboolean cthulhu_is_table_container(AtspiAccessible *source) {
|
|
AtspiRole role = atspi_accessible_get_role(source, NULL);
|
|
if (role == ATSPI_ROLE_TABLE) return TRUE;
|
|
if (role == ATSPI_ROLE_LIST) return cthulhu_is_gtk_list_table(source);
|
|
if (role != ATSPI_ROLE_TREE && role != ATSPI_ROLE_TREE_TABLE) return FALSE;
|
|
AtspiTable *table = atspi_accessible_get_table_iface(source);
|
|
if (table == NULL) return FALSE;
|
|
g_object_unref(table);
|
|
return TRUE;
|
|
}
|
|
|
|
// Selection counts cells, so a single GTK row with two columns already has
|
|
// two selected children. Query the table's selected rows before treating its
|
|
// first selected cell as the focus target.
|
|
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source) {
|
|
// A menu/dialog can return focus without refreshing the cached child list.
|
|
atspi_accessible_clear_cache_single(source);
|
|
AtspiTable *table = atspi_accessible_get_table_iface(source);
|
|
if (table == NULL) return cthulhu_selected_child(source);
|
|
GError *error = NULL;
|
|
gint count = atspi_table_get_n_selected_rows(table, &error);
|
|
gboolean ambiguous = error == NULL && count > 1;
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
}
|
|
AtspiAccessible *child = ambiguous ? NULL : cthulhu_selected_child(source);
|
|
// Qt selects every cell in a row, including an unnamed status column.
|
|
// Prefer the provider's focused cell in that same uniquely selected row
|
|
// so returning from a menu restores a useful Braille/review target too.
|
|
// Keep the existing target when no such evidence is available; never
|
|
// search other rows or guess a focus target in a multiple selection.
|
|
gint columns = atspi_table_get_n_columns(table, NULL);
|
|
if (!ambiguous && count == 1 && columns > 0 && columns <= 32) {
|
|
GArray *rows = atspi_table_get_selected_rows(table, NULL);
|
|
if (rows != NULL && rows->len == 1) {
|
|
gint row = g_array_index(rows, gint, 0);
|
|
for (gint column = 0; column < columns; column++) {
|
|
AtspiAccessible *item = atspi_table_get_accessible_at(table, row, column, NULL);
|
|
if (item == NULL) continue;
|
|
atspi_accessible_clear_cache_single(item);
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(item);
|
|
gboolean focused = states != NULL &&
|
|
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
|
|
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
|
|
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
if (states != NULL) g_object_unref(states);
|
|
if (focused) {
|
|
if (child != NULL) g_object_unref(child);
|
|
child = item;
|
|
break;
|
|
}
|
|
g_object_unref(item);
|
|
}
|
|
}
|
|
if (rows != NULL) g_array_unref(rows);
|
|
}
|
|
g_object_unref(table);
|
|
return child;
|
|
}
|
|
|
|
// Resolve the focused descendant at a window-activation boundary. Providers
|
|
// such as Steam's Chromium UI can activate a frame without emitting a usable
|
|
// object-focus event. Keep the synchronous callback work bounded so a broken
|
|
// or unusually large accessible tree cannot hold the AT-SPI event loop
|
|
// indefinitely.
|
|
typedef gboolean (*cthulhu_descendant_matcher)(AtspiAccessible *source);
|
|
|
|
static gboolean cthulhu_is_focused_descendant(AtspiAccessible *source) {
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(source);
|
|
gboolean matches = states != NULL &&
|
|
atspi_state_set_contains(states, ATSPI_STATE_FOCUSED) &&
|
|
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
if (states != NULL) g_object_unref(states);
|
|
return matches;
|
|
}
|
|
|
|
static gboolean cthulhu_is_showing_web_document(AtspiAccessible *source) {
|
|
GError *error = NULL;
|
|
AtspiRole role = atspi_accessible_get_role(source, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
return FALSE;
|
|
}
|
|
if (role != ATSPI_ROLE_DOCUMENT_WEB && role != ATSPI_ROLE_DOCUMENT_FRAME &&
|
|
role != ATSPI_ROLE_EMBEDDED) return FALSE;
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(source);
|
|
gboolean matches = states != NULL &&
|
|
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
|
|
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
if (states != NULL) g_object_unref(states);
|
|
return matches;
|
|
}
|
|
|
|
static AtspiAccessible *cthulhu_find_matching_descendant(
|
|
AtspiAccessible *current, guint *visited, guint depth,
|
|
cthulhu_descendant_matcher matches) {
|
|
if (current == NULL || *visited >= 1024 || depth >= 128) return NULL;
|
|
(*visited)++;
|
|
if (matches(current)) return g_object_ref(current);
|
|
|
|
GError *error = NULL;
|
|
gint childCount = atspi_accessible_get_child_count(current, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
return NULL;
|
|
}
|
|
for (gint index = 0; index < childCount && *visited < 1024; index++) {
|
|
AtspiAccessible *child = atspi_accessible_get_child_at_index(current, index, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
continue;
|
|
}
|
|
if (child == NULL) continue;
|
|
AtspiAccessible *result = cthulhu_find_matching_descendant(
|
|
child, visited, depth + 1, matches);
|
|
g_object_unref(child);
|
|
if (result != NULL) return result;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static AtspiAccessible *cthulhu_matching_descendant(
|
|
AtspiAccessible *window, cthulhu_descendant_matcher matches) {
|
|
if (window == NULL) return NULL;
|
|
guint visited = 0;
|
|
GError *error = NULL;
|
|
gint childCount = atspi_accessible_get_child_count(window, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
return NULL;
|
|
}
|
|
for (gint index = 0; index < childCount && visited < 1024; index++) {
|
|
AtspiAccessible *child = atspi_accessible_get_child_at_index(window, index, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
continue;
|
|
}
|
|
if (child == NULL) continue;
|
|
AtspiAccessible *result = cthulhu_find_matching_descendant(child, &visited, 0, matches);
|
|
g_object_unref(child);
|
|
if (result != NULL) return result;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
void cthulhu_refresh_frame_children(AtspiAccessible *window) {
|
|
// Chromium may advertise a socket-backed child before its accessibility
|
|
// tree is initialized. Reading frame attributes activates the child tree;
|
|
// clear the stale child cache before asking for descendants.
|
|
gchar *toolkit = atspi_accessible_get_toolkit_name(window, NULL);
|
|
gboolean chromium = g_strcmp0(toolkit, "Chromium") == 0;
|
|
g_free(toolkit);
|
|
if (!chromium) return;
|
|
|
|
GError *attributesError = NULL;
|
|
GHashTable *attributes = atspi_accessible_get_attributes(window, &attributesError);
|
|
if (attributesError != NULL) g_clear_error(&attributesError);
|
|
if (attributes != NULL) g_hash_table_unref(attributes);
|
|
atspi_accessible_clear_cache_single(window);
|
|
}
|
|
|
|
static AtspiAccessible *cthulhu_collection_descendant(AtspiAccessible *window,
|
|
gboolean webDocument, gboolean *answered) {
|
|
*answered = FALSE;
|
|
AtspiCollection *collection = atspi_accessible_get_collection_iface(window);
|
|
if (collection != NULL) {
|
|
AtspiStateSet *states = atspi_state_set_new(NULL);
|
|
atspi_state_set_add(states, webDocument ? ATSPI_STATE_SHOWING : ATSPI_STATE_FOCUSED);
|
|
GArray *roles = NULL;
|
|
if (webDocument) {
|
|
roles = g_array_new(FALSE, FALSE, sizeof(AtspiRole));
|
|
AtspiRole documentRoles[] = {ATSPI_ROLE_DOCUMENT_WEB,
|
|
ATSPI_ROLE_DOCUMENT_FRAME, ATSPI_ROLE_EMBEDDED};
|
|
g_array_append_vals(roles, documentRoles, G_N_ELEMENTS(documentRoles));
|
|
}
|
|
AtspiMatchRule *rule = atspi_match_rule_new(states,
|
|
ATSPI_Collection_MATCH_ALL, NULL, ATSPI_Collection_MATCH_ALL,
|
|
roles, webDocument ? ATSPI_Collection_MATCH_ANY : ATSPI_Collection_MATCH_ALL, NULL,
|
|
ATSPI_Collection_MATCH_ALL, FALSE);
|
|
GError *error = NULL;
|
|
GArray *matches = atspi_collection_get_matches(collection, rule,
|
|
ATSPI_Collection_SORT_ORDER_CANONICAL, 1, TRUE, &error);
|
|
AtspiAccessible *target = NULL;
|
|
*answered = error == NULL && matches != NULL;
|
|
if (error == NULL && matches != NULL && matches->len > 0) {
|
|
AtspiAccessible *candidate = g_array_index(matches, AtspiAccessible *, 0);
|
|
if (candidate != NULL) {
|
|
if (webDocument ? cthulhu_is_showing_web_document(candidate) :
|
|
cthulhu_is_focused_descendant(candidate)) {
|
|
target = g_object_ref(candidate);
|
|
}
|
|
}
|
|
}
|
|
if (error != NULL) g_clear_error(&error);
|
|
if (matches != NULL) {
|
|
// Collection results own each accessible as well as the array.
|
|
// Keep only the separate reference returned as target.
|
|
for (guint index = 0; index < matches->len; index++) {
|
|
AtspiAccessible *match = g_array_index(matches, AtspiAccessible *, index);
|
|
if (match != NULL) g_object_unref(match);
|
|
}
|
|
g_array_unref(matches);
|
|
}
|
|
g_object_unref(rule);
|
|
g_object_unref(states);
|
|
if (roles != NULL) g_array_unref(roles);
|
|
g_object_unref(collection);
|
|
return target;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_focused_descendant(AtspiAccessible *window) {
|
|
// An empty successful Collection reply is conclusive. GTK briefly has
|
|
// no focused child when a combo popup closes; walking the entire window
|
|
// then delays the focus event which is already waiting to be processed.
|
|
gboolean answered;
|
|
AtspiAccessible *target = cthulhu_collection_descendant(window, FALSE, &answered);
|
|
if (answered) return target;
|
|
return cthulhu_matching_descendant(window, cthulhu_is_focused_descendant);
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_showing_web_document(AtspiAccessible *window) {
|
|
gboolean answered;
|
|
AtspiAccessible *target = cthulhu_collection_descendant(window, TRUE, &answered);
|
|
if (answered) return target;
|
|
return cthulhu_matching_descendant(window, cthulhu_is_showing_web_document);
|
|
}
|
|
|
|
// Return the autocomplete entry named by a popup-for relation on this object
|
|
// or an ancestor. The caller owns the returned reference.
|
|
AtspiAccessible *cthulhu_autocomplete_popup_target(AtspiAccessible *source) {
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
for (int depth = 0; current != NULL && depth < 16; depth++) {
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME || role == ATSPI_ROLE_APPLICATION) {
|
|
g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
GArray *relations = atspi_accessible_get_relation_set(current, NULL);
|
|
AtspiAccessible *found = NULL;
|
|
if (relations != NULL) {
|
|
for (guint i = 0; i < relations->len && found == NULL; i++) {
|
|
AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i);
|
|
if (atspi_relation_get_relation_type(relation) != ATSPI_RELATION_POPUP_FOR) continue;
|
|
for (gint j = 0; j < atspi_relation_get_n_targets(relation); j++) {
|
|
AtspiAccessible *target = atspi_relation_get_target(relation, j);
|
|
if (target != NULL) {
|
|
AtspiRole targetRole = atspi_accessible_get_role(target, NULL);
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(target);
|
|
gboolean autocomplete = targetRole == ATSPI_ROLE_AUTOCOMPLETE || (targetRole == ATSPI_ROLE_ENTRY && states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SUPPORTS_AUTOCOMPLETION));
|
|
if (states != NULL) g_object_unref(states);
|
|
if (autocomplete) found = target;
|
|
else g_object_unref(target);
|
|
}
|
|
if (found != NULL) break;
|
|
}
|
|
}
|
|
for (guint i = 0; i < relations->len; i++) {
|
|
g_object_unref(g_array_index(relations, AtspiRelation *, i));
|
|
}
|
|
g_array_unref(relations);
|
|
}
|
|
if (found != NULL) { g_object_unref(current); return found; }
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_combo_ancestor(AtspiAccessible *source) {
|
|
if (source == NULL) return NULL;
|
|
GError *error = NULL;
|
|
AtspiRole source_role = atspi_accessible_get_role(source, &error);
|
|
if (error != NULL) {
|
|
g_error_free(error);
|
|
return NULL;
|
|
}
|
|
if (source_role != ATSPI_ROLE_TOGGLE_BUTTON) return NULL;
|
|
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
for (int depth = 0; current != NULL && depth < 4; depth++) {
|
|
error = NULL;
|
|
AtspiRole role = atspi_accessible_get_role(current, &error);
|
|
if (error != NULL) {
|
|
g_error_free(error);
|
|
g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
if (role == ATSPI_ROLE_COMBO_BOX) {
|
|
return current;
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
|
|
g_object_unref(current);
|
|
if (error != NULL) {
|
|
g_error_free(error);
|
|
return NULL;
|
|
}
|
|
current = parent;
|
|
}
|
|
if (current != NULL) {
|
|
g_object_unref(current);
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
// Announce only a showing top-level document in the active window.
|
|
// Background tabs and embedded frame loads must not interrupt the user.
|
|
gboolean cthulhu_active_document(AtspiAccessible *source) {
|
|
if (source == NULL) return FALSE;
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(source);
|
|
gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING);
|
|
if (states != NULL) g_object_unref(states);
|
|
if (!showing) return FALSE;
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
for (int depth = 0; current != NULL && depth < 256; depth++) {
|
|
GError *error = NULL;
|
|
AtspiRole role = atspi_accessible_get_role(current, &error);
|
|
if (error != NULL) { g_error_free(error); g_object_unref(current); return FALSE; }
|
|
gboolean document = role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME || role == ATSPI_ROLE_EMBEDDED;
|
|
if ((depth == 0 && !document) || (depth > 0 && document)) {
|
|
g_object_unref(current);
|
|
return FALSE;
|
|
}
|
|
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) {
|
|
states = atspi_accessible_get_state_set(current);
|
|
gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE);
|
|
if (states != NULL) g_object_unref(states);
|
|
g_object_unref(current);
|
|
return active;
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
|
|
g_object_unref(current);
|
|
if (error != NULL) {
|
|
g_error_free(error);
|
|
if (parent != NULL) g_object_unref(parent);
|
|
return FALSE;
|
|
}
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return FALSE;
|
|
}
|
|
|
|
gboolean cthulhu_in_active_document(AtspiAccessible *source) {
|
|
AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL;
|
|
AtspiAccessible *document = NULL;
|
|
for (int depth = 0; current != NULL && depth < 256; depth++) {
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
|
|
if (document != NULL) g_object_unref(document);
|
|
document = g_object_ref(current);
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
if (document == NULL) return FALSE;
|
|
gboolean active = cthulhu_active_document(document);
|
|
g_object_unref(document);
|
|
return active;
|
|
}
|
|
|
|
|
|
// Some applications continue emitting focus and text events after their X11
|
|
// window loses activation. Check the containing top-level window: an embedded
|
|
// web dialog need not carry ACTIVE even when its frame is active. Missing state
|
|
// is inconclusive.
|
|
gboolean cthulhu_in_inactive_window(AtspiAccessible *source) {
|
|
AtspiAccessible *window = cthulhu_window_ancestor(source);
|
|
if (window == NULL) return FALSE;
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(window);
|
|
gboolean inactive = states != NULL && !atspi_state_set_contains(states, ATSPI_STATE_ACTIVE);
|
|
if (states != NULL) g_object_unref(states);
|
|
g_object_unref(window);
|
|
return inactive;
|
|
}
|
|
|
|
// GTK menus can live in their own AT-SPI window while X11 continues to name
|
|
// the invoking application window as the foreground client. Python Cthulhu
|
|
// accepts an active, showing window with one menu child in this situation.
|
|
gboolean cthulhu_active_menu_window(AtspiAccessible *window) {
|
|
if (window == NULL) return FALSE;
|
|
AtspiRole role = atspi_accessible_get_role(window, NULL);
|
|
if (role != ATSPI_ROLE_WINDOW && role != ATSPI_ROLE_FRAME) return FALSE;
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(window);
|
|
gboolean active = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_ACTIVE);
|
|
gboolean showing = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_SHOWING);
|
|
gboolean unavailable = states == NULL || atspi_state_set_contains(states, ATSPI_STATE_ICONIFIED) ||
|
|
atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
if (states != NULL) g_object_unref(states);
|
|
if (!active || !showing || unavailable || atspi_accessible_get_child_count(window, NULL) != 1) return FALSE;
|
|
AtspiAccessible *child = atspi_accessible_get_child_at_index(window, 0, NULL);
|
|
if (child == NULL) return FALSE;
|
|
AtspiRole childRole = atspi_accessible_get_role(child, NULL);
|
|
gboolean menu = childRole == ATSPI_ROLE_MENU;
|
|
if (role == ATSPI_ROLE_FRAME && childRole == ATSPI_ROLE_MENU_BAR) {
|
|
gchar *name = atspi_accessible_get_name(window, NULL);
|
|
menu = name == NULL || *name == '\0';
|
|
g_free(name);
|
|
}
|
|
g_object_unref(child);
|
|
return menu;
|
|
}
|
|
|
|
static gboolean cthulhu_is_menu(AtspiAccessible *source) {
|
|
return atspi_accessible_get_role(source, NULL) == ATSPI_ROLE_MENU;
|
|
}
|
|
|
|
AtspiAccessible *cthulhu_popup_menu_target(AtspiAccessible *window) {
|
|
if (!cthulhu_active_menu_window(window)) return NULL;
|
|
AtspiAccessible *menu = cthulhu_matching_descendant(window, cthulhu_is_menu);
|
|
if (menu == NULL) return NULL;
|
|
AtspiAccessible *selected = cthulhu_selected_child(menu);
|
|
if (selected != NULL) {
|
|
g_object_unref(menu);
|
|
return selected;
|
|
}
|
|
return menu;
|
|
}
|
|
|
|
// Compound native menu rows can report selection on an inner control without
|
|
// changing the menu's selected child. Return its menu only when a menu-item
|
|
// ancestor precedes that menu, and the chain reaches a native window without
|
|
// entering document content. The caller owns the returned reference.
|
|
AtspiAccessible *cthulhu_inline_menu_container(AtspiAccessible *source) {
|
|
AtspiAccessible *current = atspi_accessible_get_parent(source, NULL);
|
|
AtspiAccessible *menu = NULL;
|
|
gboolean inRow = FALSE;
|
|
for (int depth = 0; current != NULL && depth < 32; depth++) {
|
|
GError *error = NULL;
|
|
AtspiRole role = atspi_accessible_get_role(current, &error);
|
|
if (error != NULL) { g_error_free(error); break; }
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME ||
|
|
role == ATSPI_ROLE_EMBEDDED || role == ATSPI_ROLE_APPLICATION) break;
|
|
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) {
|
|
g_object_unref(current);
|
|
return menu;
|
|
}
|
|
if (menu == NULL) {
|
|
if (role == ATSPI_ROLE_MENU) {
|
|
if (!inRow) break; // Ordinary menu entries keep their existing events.
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(current);
|
|
gboolean showing = states != NULL &&
|
|
atspi_state_set_contains(states, ATSPI_STATE_SHOWING) &&
|
|
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
if (states != NULL) g_object_unref(states);
|
|
if (!showing) break;
|
|
menu = g_object_ref(current);
|
|
} else if (role == ATSPI_ROLE_MENU_ITEM || role == ATSPI_ROLE_CHECK_MENU_ITEM ||
|
|
role == ATSPI_ROLE_RADIO_MENU_ITEM) {
|
|
inRow = TRUE;
|
|
}
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
if (error != NULL) { g_error_free(error); break; }
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
if (menu != NULL) g_object_unref(menu);
|
|
return NULL;
|
|
}
|
|
|
|
const gchar *cthulhu_accessible_path(AtspiAccessible *source) {
|
|
return source != NULL ? ATSPI_OBJECT(source)->path : NULL;
|
|
}
|
|
|
|
const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source) {
|
|
if (source == NULL || ATSPI_OBJECT(source)->app == NULL) {
|
|
return NULL;
|
|
}
|
|
return ATSPI_OBJECT(source)->app->bus_name;
|
|
}
|
|
|
|
// Cache-only classification: never contact an unrelated provider just to
|
|
// decide whether its event can be ignored. Unknown cache entries retain the
|
|
// ordinary validation path, including native alerts and notification daemons.
|
|
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) {
|
|
if (source == NULL || !(source->cached_properties & ATSPI_CACHE_ROLE)) return TRUE;
|
|
if (source->role == ATSPI_ROLE_ALERT || source->role == ATSPI_ROLE_NOTIFICATION) return TRUE;
|
|
AtspiApplication *application = source->parent.app;
|
|
AtspiAccessible *root = application != NULL ? application->root : NULL;
|
|
if (root == NULL || !(root->cached_properties & ATSPI_CACHE_NAME)) return TRUE;
|
|
const gchar *name = root->name != NULL ? root->name : "";
|
|
return g_ascii_strcasecmp(name, "notification-daemon") == 0 ||
|
|
g_ascii_strcasecmp(name, "mate-notification-daemon") == 0 ||
|
|
g_ascii_strcasecmp(name, "xfce4-notifyd") == 0 ||
|
|
g_ascii_strcasecmp(name, "notify-osd") == 0;
|
|
}
|
|
|
|
// Return the containing top-level window. An embedded web dialog is part of
|
|
// its frame; a standalone dialog directly below the application owns itself.
|
|
// GTK plugs also publish a frame, but its parent chain crosses the socket's
|
|
// provider before reaching the host window. Keep that host as the owner while
|
|
// retaining the source object's provider identity. Same-provider nested frames
|
|
// retain their existing nearest-window behavior.
|
|
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) {
|
|
if (source == NULL) return NULL;
|
|
AtspiAccessible *current = g_object_ref(source);
|
|
AtspiAccessible *window = NULL;
|
|
for (gint depth = 0; current != NULL && depth < 256; depth++) {
|
|
GError *error = NULL;
|
|
AtspiRole role = atspi_accessible_get_role(current, &error);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
g_object_unref(current);
|
|
if (window != NULL) g_object_unref(window);
|
|
return NULL;
|
|
}
|
|
if (role == ATSPI_ROLE_APPLICATION) {
|
|
g_object_unref(current);
|
|
return window;
|
|
}
|
|
if ((role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW) &&
|
|
(window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window),
|
|
cthulhu_accessible_bus_name(current)) != 0)) {
|
|
if (window != NULL) g_object_unref(window);
|
|
window = g_object_ref(current);
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, &error);
|
|
// Only a frame with parents below the application can be a plug.
|
|
// Refresh that chain to reject detached sockets, without invalidating
|
|
// every ordinary top-level window for every accessibility event.
|
|
if (window != NULL && parent != NULL && error == NULL &&
|
|
atspi_accessible_get_role(parent, NULL) != ATSPI_ROLE_APPLICATION) {
|
|
g_object_unref(parent);
|
|
atspi_accessible_clear_cache_single(current);
|
|
parent = atspi_accessible_get_parent(current, &error);
|
|
}
|
|
// Qt can expose a top-level QWidget as a filler rather than a frame.
|
|
// Python's Qt utilities use the application-child boundary here.
|
|
if (parent != NULL && error == NULL && role != ATSPI_ROLE_APPLICATION &&
|
|
atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_APPLICATION) {
|
|
g_object_unref(parent);
|
|
if (window == NULL || g_strcmp0(cthulhu_accessible_bus_name(window),
|
|
cthulhu_accessible_bus_name(current)) != 0) {
|
|
if (window != NULL) g_object_unref(window);
|
|
return current;
|
|
}
|
|
g_object_unref(current);
|
|
return window;
|
|
}
|
|
g_object_unref(current);
|
|
if (error != NULL) {
|
|
g_clear_error(&error);
|
|
if (parent != NULL) g_object_unref(parent);
|
|
if (window != NULL) g_object_unref(window);
|
|
return NULL;
|
|
}
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
if (current == NULL) return window;
|
|
if (window != NULL) g_object_unref(window);
|
|
return NULL;
|
|
}
|
|
|
|
// Editors can retain native focus while their paragraphs emit typing/caret/
|
|
// selection events. Use the nearest focusable writable ancestor or editable
|
|
// document. Never cross a document, password, or read-only editor boundary.
|
|
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source) {
|
|
AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL;
|
|
for (gint depth = 0; current != NULL && depth < 64; depth++) {
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW || role == ATSPI_ROLE_APPLICATION) break;
|
|
AtspiStateSet *states = atspi_accessible_get_state_set(current);
|
|
gboolean blocked = states == NULL || role == ATSPI_ROLE_PASSWORD_TEXT ||
|
|
atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) ||
|
|
atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
|
|
gboolean editable = !blocked && atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
|
|
gboolean focusable = !blocked && atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE);
|
|
if (states != NULL) g_object_unref(states);
|
|
gboolean document = role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME;
|
|
if (editable && (focusable || document)) return current;
|
|
if (blocked || document || focusable) break;
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return NULL;
|
|
}
|
|
|
|
// Chromium can detach an existing document from its frame's AT-SPI parent
|
|
// chain while returning to a tab. Its document identity remains usable.
|
|
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) {
|
|
AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL;
|
|
AtspiAccessible *document = NULL;
|
|
for (gint depth = 0; current != NULL && depth < 256; depth++) {
|
|
AtspiRole role = atspi_accessible_get_role(current, NULL);
|
|
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
|
|
if (document != NULL) g_object_unref(document);
|
|
document = g_object_ref(current);
|
|
}
|
|
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
|
|
g_object_unref(current);
|
|
current = parent;
|
|
}
|
|
if (current != NULL) g_object_unref(current);
|
|
return document;
|
|
}
|
|
|
|
static void cthulhu_event_callback(AtspiEvent *event, void *userData) {
|
|
cthulhuAtspiEventCallback(event, userData);
|
|
// libatspi passes each listener an owned boxed copy. Go retains only
|
|
// copied metadata and explicitly referenced accessibles.
|
|
if (event != NULL) g_boxed_free(ATSPI_TYPE_EVENT, event);
|
|
}
|
|
|
|
AtspiEventListenerCB cthulhu_event_callback_pointer(void) {
|
|
return cthulhu_event_callback;
|
|
}
|
|
|
|
static gboolean cthulhu_quit_event_loop(gpointer unused) {
|
|
(void) unused;
|
|
atspi_event_quit();
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
gboolean cthulhu_request_event_loop_quit(void) {
|
|
GSource *source = g_idle_source_new();
|
|
if (source == NULL) {
|
|
return FALSE;
|
|
}
|
|
g_source_set_priority(source, G_PRIORITY_HIGH);
|
|
g_source_set_callback(source, cthulhu_quit_event_loop, NULL, NULL);
|
|
guint source_id = g_source_attach(source, g_main_context_default());
|
|
g_source_unref(source);
|
|
return source_id != 0;
|
|
}
|
|
|
|
typedef struct {
|
|
cthulhu_callback_data *owner;
|
|
GSource *source;
|
|
} cthulhu_pending_capture;
|
|
|
|
static gboolean cthulhu_capture_tree_callback(gpointer userData) {
|
|
cthulhu_pending_capture *capture = userData;
|
|
cthulhuAtspiTreeCaptureCallback(capture->owner);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
static void cthulhu_capture_destroy(gpointer userData) {
|
|
cthulhu_pending_capture *capture = userData;
|
|
g_mutex_lock(&capture->owner->mutex);
|
|
capture->owner->captures = g_list_remove(capture->owner->captures, capture);
|
|
g_mutex_unlock(&capture->owner->mutex);
|
|
g_source_unref(capture->source);
|
|
g_free(capture);
|
|
}
|
|
|
|
gboolean cthulhu_request_tree_capture(gpointer userData) {
|
|
cthulhu_callback_data *data = userData;
|
|
GSource *source = g_idle_source_new();
|
|
if (source == NULL) {
|
|
return FALSE;
|
|
}
|
|
cthulhu_pending_capture *capture = g_new0(cthulhu_pending_capture, 1);
|
|
capture->owner = data;
|
|
capture->source = source;
|
|
g_source_set_priority(source, G_PRIORITY_HIGH);
|
|
g_source_set_callback(source, cthulhu_capture_tree_callback, capture, cthulhu_capture_destroy);
|
|
g_mutex_lock(&data->mutex);
|
|
data->captures = g_list_prepend(data->captures, capture);
|
|
guint source_id = g_source_attach(source, g_main_context_default());
|
|
g_mutex_unlock(&data->mutex);
|
|
if (source_id == 0) g_source_destroy(source);
|
|
return source_id != 0;
|
|
}
|
|
|
|
|
|
|
|
cthulhu_callback_data *cthulhu_callback_data_new(uintptr_t callback) {
|
|
cthulhu_callback_data *data = g_new0(cthulhu_callback_data, 1);
|
|
if (data != NULL) {
|
|
data->callback = callback;
|
|
g_mutex_init(&data->mutex);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data) {
|
|
return data->callback;
|
|
}
|
|
|
|
void cthulhu_callback_data_free(cthulhu_callback_data *data) {
|
|
if (data == NULL) return;
|
|
// Dispatch has stopped and the Go request mutex excludes new sources.
|
|
// Destroy notifications remove each source and release its retained ref.
|
|
while (data->captures != NULL) {
|
|
cthulhu_pending_capture *capture = data->captures->data;
|
|
g_source_destroy(capture->source);
|
|
}
|
|
g_mutex_clear(&data->mutex);
|
|
g_free(data);
|
|
}
|