Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user