Files
cthulhu/internal/atspi/webscripts_native.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
}