// 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. Nested frames (such as // Chromium's Find bar) belong to their outer frame too: they have no independent // X11 foreground window or ACTIVE state. 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) { 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; } // Qt exposes an editable combo's read-only line edit as TEXT with EDITABLE // still set. Require READ_ONLY and a direct combo parent; an unrelated editor // in the same application must keep ordinary caret and word navigation. AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source) { if (source == NULL) return NULL; AtspiRole role = atspi_accessible_get_role(source, NULL); if (role != ATSPI_ROLE_TEXT && role != ATSPI_ROLE_ENTRY) return NULL; AtspiStateSet *states = atspi_accessible_get_state_set(source); gboolean readOnly = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) && !atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT); if (states != NULL) g_object_unref(states); if (!readOnly) return NULL; GError *error = NULL; AtspiAccessible *parent = atspi_accessible_get_parent(source, &error); if (error == NULL && parent != NULL && atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_COMBO_BOX) return parent; g_clear_error(&error); if (parent != NULL) g_object_unref(parent); 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; cthulhu_clear_selection(data); // 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); }