219 lines
6.9 KiB
Go
219 lines
6.9 KiB
Go
// 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 <atspi/atspi.h>
|
|
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
|
|
}
|