// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi /* #cgo pkg-config: atspi-2 gobject-2.0 #include static AtspiAccessible *web_ref(AtspiAccessible *source) { return g_object_ref(source); } static AtspiAccessible *web_event_child(AtspiEvent *event) { if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) return NULL; GObject *child = g_value_get_object(&event->any_data); return child && ATSPI_IS_ACCESSIBLE(child) ? ATSPI_ACCESSIBLE(child) : NULL; } static gchar *web_event_text(AtspiEvent *event) { return G_IS_VALUE(&event->any_data) && G_VALUE_HOLDS_STRING(&event->any_data) ? g_value_dup_string(&event->any_data) : NULL; } static void web_live_attributes(AtspiAccessible *source, gchar **live, gchar **containerLive) { GHashTable *attributes = atspi_accessible_get_attributes(source, NULL); if (!attributes) return; *live = g_strdup(g_hash_table_lookup(attributes, "live")); *containerLive = g_strdup(g_hash_table_lookup(attributes, "container-live")); g_hash_table_unref(attributes); } */ import "C" import ( "fmt" "os" "strings" "time" "git.stormux.org/storm/cthulhu/internal/webscripts" ) // Process identity is copied once per provider bus on the GLib owner. A failed // lookup remains unknown and never enables an application exception. func (adapter *Adapter) applicationProcess(source *C.AtspiAccessible) string { id := nativeObjectID(source) if !id.Valid() { return "" } if process, known := adapter.applicationProcesses[id.BusName]; known { return process } pid := C.atspi_accessible_get_process_id(source, nil) if pid == 0 { return "" } content, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) if err != nil { return "" } process := strings.TrimSpace(string(content)) if adapter.applicationProcesses == nil { adapter.applicationProcesses = make(map[string]string) } if len(adapter.applicationProcesses) >= 64 { clear(adapter.applicationProcesses) } adapter.applicationProcesses[id.BusName] = process return process } // Capture semantic roots only for a matched script and relevant event. Normal // Chromium events do not incur ancestor scans or notification text traversal. // No C pointer escapes this function; the callback still owns the event source. func (adapter *Adapter) webNotification(event *C.AtspiEvent) *webscripts.Notification { if !webscripts.NotificationEvent(C.GoString(event._type), int(event.detail1)) || webscripts.MatchProcess(adapter.applicationProcess(event.source)) != webscripts.Steam { return nil } source := event.source if C.GoString(event._type) == ChildrenAddedEvent { source = C.web_event_child(event) } if source == nil || nativeObjectID(source).BusName != nativeObjectID(event.source).BusName { return nil } // Providers can emit insertion before the accessible-name update. Refresh // only the changed object so an old cached name cannot replace new text. if C.GoString(event._type) == TextInsertedEvent { C.atspi_accessible_clear_cache_single(source) } deadline := time.Now().Add(50 * time.Millisecond) var ancestors []*C.AtspiAccessible var candidates []webscripts.NotificationCandidate defer func() { for _, ancestor := range ancestors { C.g_object_unref(C.gpointer(ancestor)) } }() for current := C.web_ref(source); current != nil; { ancestors = append(ancestors, current) role := C.atspi_accessible_get_role(current, nil) candidate := webscripts.NotificationCandidate{} switch role { case C.ATSPI_ROLE_NOTIFICATION: candidate.Role = "notification" case C.ATSPI_ROLE_ALERT: candidate.Role = "alert" } var live, containerLive *C.gchar C.web_live_attributes(current, &live, &containerLive) candidate.Live, candidate.ContainerLive = C.GoString(live), C.GoString(containerLive) C.g_free(C.gpointer(live)) C.g_free(C.gpointer(containerLive)) candidates = append(candidates, candidate) if candidate.Role != "" || role == C.ATSPI_ROLE_APPLICATION || len(ancestors) >= 32 || time.Now().After(deadline) { break } current = C.atspi_accessible_get_parent(current, nil) } index := webscripts.NotificationRoot(candidates) if index < 0 { return nil } root := ancestors[index] id := nativeObjectID(root) if !id.Valid() { return nil } text := webNotificationText(root, ancestors[index+1:], deadline) if text == "" && C.GoString(event._type) == TextInsertedEvent { payload := C.web_event_text(event) text = sanitizeSnapshotField(C.GoString(payload)) C.g_free(C.gpointer(payload)) } return &webscripts.Notification{BusName: id.BusName, RootPath: id.Path, Text: text} } func webReadableText(source *C.AtspiAccessible) string { if C.atspi_accessible_get_role(source, nil) == C.ATSPI_ROLE_PASSWORD_TEXT { return "" } name := C.atspi_accessible_get_name(source, nil) content := sanitizeSnapshotField(C.GoString(name)) C.g_free(C.gpointer(name)) if content != "" { return content } text := C.atspi_accessible_get_text_iface(source) if text == nil { return "" } defer C.g_object_unref(C.gpointer(text)) count := min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength) if count <= 0 { return "" } value := C.atspi_text_get_text(text, 0, C.gint(count), nil) content = sanitizeSnapshotField(strings.ReplaceAll(C.GoString(value), "\ufffc", "")) C.g_free(C.gpointer(value)) return content } func webNotificationText(root *C.AtspiAccessible, ancestors []*C.AtspiAccessible, deadline time.Time) string { if text := webReadableText(root); text != "" { return text } var labels []string var button, readable string remaining := 64 var visit func(*C.AtspiAccessible, int) visit = func(source *C.AtspiAccessible, depth int) { if remaining <= 0 || depth >= 8 || time.Now().After(deadline) { return } remaining-- role := C.atspi_accessible_get_role(source, nil) if role == C.ATSPI_ROLE_PASSWORD_TEXT { return } text := webReadableText(source) if text != "" { switch role { case C.ATSPI_ROLE_LABEL, C.ATSPI_ROLE_STATIC, C.ATSPI_ROLE_TEXT: labels = append(labels, text) case C.ATSPI_ROLE_PUSH_BUTTON: if button == "" { button = text } } if readable == "" { readable = text } } count := min(int(C.atspi_accessible_get_child_count(source, nil)), remaining) for index := 0; index < count && remaining > 0 && time.Now().Before(deadline); index++ { if child := C.atspi_accessible_get_child_at_index(source, C.gint(index), nil); child != nil { visit(child, depth+1) C.g_object_unref(C.gpointer(child)) } } } visit(root, 0) if len(labels) != 0 { return sanitizeSnapshotField(strings.Join(labels, " ")) } if button != "" { return button } for _, ancestor := range ancestors { if time.Now().After(deadline) { break } if C.atspi_accessible_get_role(ancestor, nil) == C.ATSPI_ROLE_PUSH_BUTTON { if text := webReadableText(ancestor); text != "" { return text } } } return readable }