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,72 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package webscripts owns application-specific web exceptions. It consumes
|
||||
// copied semantics, never native accessibles, and does not depend on the reader.
|
||||
package webscripts
|
||||
|
||||
import "time"
|
||||
|
||||
type Script uint8
|
||||
|
||||
const (
|
||||
Default Script = iota
|
||||
Steam
|
||||
)
|
||||
|
||||
// MatchProcess uses the provider's verified process identity, not a page title
|
||||
// or the ambiguous AT-SPI application name "Chromium".
|
||||
func MatchProcess(processName string) Script {
|
||||
if processName == "steamwebhelper" {
|
||||
return Steam
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// Notification contains only the selected root identity and bounded content.
|
||||
type Notification struct {
|
||||
BusName, RootPath, Text string
|
||||
WindowPath string
|
||||
}
|
||||
|
||||
type NotificationCandidate struct {
|
||||
Role, Live, ContainerLive string
|
||||
}
|
||||
|
||||
func NotificationEvent(eventType string, detail int) bool {
|
||||
return eventType == "object:text-changed:insert" ||
|
||||
eventType == "object:children-changed:add" ||
|
||||
eventType == "object:state-changed:showing" && detail != 0
|
||||
}
|
||||
|
||||
func HandlesNotification(processName, eventType string, detail int, message *Notification) bool {
|
||||
return MatchProcess(processName) == Steam && NotificationEvent(eventType, detail) &&
|
||||
message != nil && message.BusName != "" && message.RootPath != ""
|
||||
}
|
||||
|
||||
// Registry is owned by the reader's event loop. The loop owns its timer and
|
||||
// presents completed messages using the shared speech/braille/history services.
|
||||
type Registry struct {
|
||||
steam steamNotifications
|
||||
}
|
||||
|
||||
func (registry *Registry) Receive(processName, eventType string, detail int, message *Notification, now time.Time) ([]string, bool) {
|
||||
if !HandlesNotification(processName, eventType, detail, message) {
|
||||
return nil, false
|
||||
}
|
||||
return registry.steam.queue(*message, now), true
|
||||
}
|
||||
|
||||
func (registry *Registry) Flush(now time.Time) []string { return registry.steam.flush(now) }
|
||||
func (registry *Registry) Deadline() time.Time { return registry.steam.deadline() }
|
||||
func (registry *Registry) Reset() { registry.steam = steamNotifications{} }
|
||||
|
||||
// Retire removes delayed content whose accessible root has been destroyed.
|
||||
func (registry *Registry) Retire(busName, rootPath string) {
|
||||
for key, pending := range registry.steam.pending {
|
||||
if key.busName == busName && (key.rootPath == rootPath || pending.windowPath == rootPath) {
|
||||
delete(registry.steam.pending, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package webscripts
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const steamNotificationDelay = 500 * time.Millisecond
|
||||
|
||||
var steamRelativeTime = regexp.MustCompile(`(?i)^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$`)
|
||||
|
||||
// NotificationRoot mirrors Python's Steam root selection: notification/alert
|
||||
// ancestors take precedence over inherited live-region attributes on a leaf.
|
||||
// Candidates are ordered from the event source towards its application root.
|
||||
func NotificationRoot(candidates []NotificationCandidate) int {
|
||||
for index, candidate := range candidates {
|
||||
if candidate.Role == "notification" || candidate.Role == "alert" {
|
||||
return index
|
||||
}
|
||||
}
|
||||
for index, candidate := range candidates {
|
||||
if candidate.Live == "assertive" || candidate.Live == "polite" ||
|
||||
candidate.ContainerLive == "assertive" || candidate.ContainerLive == "polite" {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type notificationKey struct{ busName, rootPath string }
|
||||
type pendingNotification struct {
|
||||
text string
|
||||
windowPath string
|
||||
timestampOnly bool
|
||||
deadline time.Time
|
||||
}
|
||||
type completedNotification struct {
|
||||
text string
|
||||
at time.Time
|
||||
}
|
||||
type steamNotifications struct {
|
||||
pending map[notificationKey]pendingNotification
|
||||
last map[string]completedNotification
|
||||
}
|
||||
|
||||
func normalize(text string) string {
|
||||
return strings.Join(strings.Fields(strings.ReplaceAll(text, "\ufffc", "")), " ")
|
||||
}
|
||||
|
||||
func contains(text, other string) bool {
|
||||
text, other = strings.ToLower(text), strings.ToLower(other)
|
||||
if len(other) < 4 {
|
||||
return text == other
|
||||
}
|
||||
return strings.Contains(text, other)
|
||||
}
|
||||
|
||||
func appendTimestamp(text, timestamp string) string {
|
||||
if strings.ContainsAny(text[len(text)-1:], ".!?") {
|
||||
return text + " " + timestamp
|
||||
}
|
||||
return text + ". " + timestamp
|
||||
}
|
||||
|
||||
func (steam *steamNotifications) queue(message Notification, now time.Time) []string {
|
||||
text := normalize(message.Text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if steam.pending == nil {
|
||||
steam.pending = make(map[notificationKey]pendingNotification)
|
||||
}
|
||||
key := notificationKey{message.BusName, message.RootPath}
|
||||
pending, exists := steam.pending[key]
|
||||
timestamp := steamRelativeTime.MatchString(text)
|
||||
var messages []string
|
||||
if exists {
|
||||
switch {
|
||||
case text == pending.text:
|
||||
return nil
|
||||
case timestamp:
|
||||
if pending.timestampOnly {
|
||||
pending.text = text
|
||||
} else {
|
||||
pending.text = appendTimestamp(pending.text, text)
|
||||
}
|
||||
case pending.timestampOnly:
|
||||
pending.text = appendTimestamp(text, pending.text)
|
||||
pending.timestampOnly = false
|
||||
case contains(text, pending.text):
|
||||
pending.text = text
|
||||
case contains(pending.text, text):
|
||||
return nil
|
||||
default:
|
||||
pending.text = normalize(pending.text + " " + text)
|
||||
}
|
||||
} else {
|
||||
// Bound outstanding roots without merging independent toasts. Finish
|
||||
// the oldest root if a provider emits an excessive burst.
|
||||
if len(steam.pending) >= 32 {
|
||||
oldest := steam.orderedKeys()[0]
|
||||
messages = steam.complete(oldest, now)
|
||||
}
|
||||
pending = pendingNotification{text: text, timestampOnly: timestamp}
|
||||
}
|
||||
// Bound accumulated fragments as well as each native capture.
|
||||
characters := []rune(pending.text)
|
||||
if len(characters) > 4096 {
|
||||
pending.text = string(characters[:4096])
|
||||
}
|
||||
pending.deadline = now.Add(steamNotificationDelay)
|
||||
pending.windowPath = message.WindowPath
|
||||
steam.pending[key] = pending
|
||||
return messages
|
||||
}
|
||||
|
||||
func (steam *steamNotifications) orderedKeys() []notificationKey {
|
||||
keys := make([]notificationKey, 0, len(steam.pending))
|
||||
for key := range steam.pending {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(first, second int) bool {
|
||||
left, right := keys[first], keys[second]
|
||||
if steam.pending[left].deadline.Equal(steam.pending[right].deadline) {
|
||||
if left.busName == right.busName {
|
||||
return left.rootPath < right.rootPath
|
||||
}
|
||||
return left.busName < right.busName
|
||||
}
|
||||
return steam.pending[left].deadline.Before(steam.pending[right].deadline)
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func (steam *steamNotifications) complete(key notificationKey, now time.Time) []string {
|
||||
pending := steam.pending[key]
|
||||
delete(steam.pending, key)
|
||||
if pending.timestampOnly {
|
||||
return nil
|
||||
}
|
||||
last := steam.last[key.busName]
|
||||
if last.text == pending.text && now.Sub(last.at) < time.Second {
|
||||
return nil
|
||||
}
|
||||
if steam.last == nil {
|
||||
steam.last = make(map[string]completedNotification)
|
||||
}
|
||||
for busName, completed := range steam.last {
|
||||
if now.Sub(completed.at) >= time.Second {
|
||||
delete(steam.last, busName)
|
||||
}
|
||||
}
|
||||
if len(steam.last) >= 64 {
|
||||
clear(steam.last)
|
||||
}
|
||||
steam.last[key.busName] = completedNotification{text: pending.text, at: now}
|
||||
return []string{"Steam: " + pending.text}
|
||||
}
|
||||
|
||||
func (steam *steamNotifications) flush(now time.Time) []string {
|
||||
var messages []string
|
||||
for _, key := range steam.orderedKeys() {
|
||||
if !steam.pending[key].deadline.After(now) {
|
||||
messages = append(messages, steam.complete(key, now)...)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func (steam *steamNotifications) deadline() time.Time {
|
||||
keys := steam.orderedKeys()
|
||||
if len(keys) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return steam.pending[keys[0]].deadline
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package webscripts
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSteamNotificationMatchingAndRoot(t *testing.T) {
|
||||
message := &Notification{BusName: ":1.42", RootPath: "/toast", Text: "Username is now online"}
|
||||
for _, process := range []string{"Chromium", "chromium", "brave", "", "steamwebhelper.exe"} {
|
||||
if HandlesNotification(process, "object:text-changed:insert", 0, message) {
|
||||
t.Fatalf("enabled Steam for %q", process)
|
||||
}
|
||||
}
|
||||
for _, event := range []struct {
|
||||
typeName string
|
||||
detail int
|
||||
want bool
|
||||
}{
|
||||
{"object:text-changed:insert", 0, true},
|
||||
{"object:children-changed:add", 0, true},
|
||||
{"object:state-changed:showing", 1, true},
|
||||
{"object:state-changed:showing", 0, false},
|
||||
{"object:state-changed:focused", 1, false},
|
||||
} {
|
||||
if got := HandlesNotification("steamwebhelper", event.typeName, event.detail, message); got != event.want {
|
||||
t.Fatalf("%s/%d: %t", event.typeName, event.detail, got)
|
||||
}
|
||||
}
|
||||
candidates := []NotificationCandidate{{ContainerLive: "assertive"}, {Role: "alert"}}
|
||||
if index := NotificationRoot(candidates); index != 1 {
|
||||
t.Fatalf("selected live leaf instead of alert ancestor: %d", index)
|
||||
}
|
||||
if index := NotificationRoot([]NotificationCandidate{{Live: "off"}, {Live: "polite"}}); index != 1 {
|
||||
t.Fatalf("live root = %d", index)
|
||||
}
|
||||
if index := NotificationRoot([]NotificationCandidate{{Live: "off"}}); index != -1 {
|
||||
t.Fatalf("non-live root = %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSteamNotificationCoalescing(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fragments []string
|
||||
want []string
|
||||
}{
|
||||
{"fragments", []string{" Username ", "Playing: Example Game"}, []string{"Steam: Username Playing: Example Game"}},
|
||||
{"timestamp after", []string{"Username is now online", "2 minutes ago"}, []string{"Steam: Username is now online. 2 minutes ago"}},
|
||||
{"timestamp before", []string{"2 minutes ago", "Username is now online"}, []string{"Steam: Username is now online. 2 minutes ago"}},
|
||||
{"complete replaces fragment", []string{"Username", "Username is now online", "Username"}, []string{"Steam: Username is now online"}},
|
||||
{"repeated complete", []string{"Username is now online", "Username is now online"}, []string{"Steam: Username is now online"}},
|
||||
{"timestamp only", []string{"just now"}, nil},
|
||||
{"empty", []string{"", " \ufffc "}, nil},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var registry Registry
|
||||
now := time.Unix(100, 0)
|
||||
for _, text := range test.fragments {
|
||||
messages, handled := registry.Receive("steamwebhelper", "object:text-changed:insert", 0,
|
||||
&Notification{BusName: ":1.42", RootPath: "/toast", Text: text}, now)
|
||||
if !handled || len(messages) != 0 {
|
||||
t.Fatalf("fragment presented early: %q, handled %t", messages, handled)
|
||||
}
|
||||
}
|
||||
if got := registry.Flush(now.Add(499 * time.Millisecond)); len(got) != 0 {
|
||||
t.Fatalf("presented before deadline: %q", got)
|
||||
}
|
||||
if got := registry.Flush(now.Add(500 * time.Millisecond)); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("messages = %q, want %q", got, test.want)
|
||||
}
|
||||
if !registry.Deadline().IsZero() {
|
||||
t.Fatal("completed timer still armed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSteamNotificationDeduplicationAndOwnership(t *testing.T) {
|
||||
var registry Registry
|
||||
now := time.Unix(100, 0)
|
||||
queue := func(bus, root, text string, at time.Time) {
|
||||
registry.Receive("steamwebhelper", "object:text-changed:insert", 0,
|
||||
&Notification{BusName: bus, RootPath: root, Text: text, WindowPath: "/window"}, at)
|
||||
}
|
||||
queue(":1.42", "/first", "Username is now online", now)
|
||||
if got := registry.Flush(now.Add(500 * time.Millisecond)); len(got) != 1 {
|
||||
t.Fatalf("initial message: %q", got)
|
||||
}
|
||||
queue(":1.42", "/first", "Username is now online", now.Add(600*time.Millisecond))
|
||||
if got := registry.Flush(now.Add(1100 * time.Millisecond)); len(got) != 0 {
|
||||
t.Fatalf("duplicate within one second: %q", got)
|
||||
}
|
||||
queue(":1.42", "/first", "Username is now online", now.Add(time.Second))
|
||||
if got := registry.Flush(now.Add(1500 * time.Millisecond)); len(got) != 1 {
|
||||
t.Fatalf("new message after one second: %q", got)
|
||||
}
|
||||
queue(":1.42", "/first", "Username", now.Add(2*time.Second))
|
||||
queue(":1.42", "/second", "Playing: Example Game", now.Add(2*time.Second))
|
||||
queue(":1.43", "/first", "2 minutes ago", now.Add(2*time.Second))
|
||||
if got := registry.Flush(now.Add(2500 * time.Millisecond)); !reflect.DeepEqual(got, []string{"Steam: Username", "Steam: Playing: Example Game"}) {
|
||||
t.Fatalf("independent roots combined: %q", got)
|
||||
}
|
||||
queue(":1.42", "/first", "retired message", now.Add(3*time.Second))
|
||||
registry.Retire(":1.43", "/window")
|
||||
if registry.Deadline().IsZero() {
|
||||
t.Fatal("other bus retired pending toast")
|
||||
}
|
||||
registry.Retire(":1.42", "/window")
|
||||
if got := registry.Flush(now.Add(4 * time.Second)); len(got) != 0 {
|
||||
t.Fatalf("destroyed window presented: %q", got)
|
||||
}
|
||||
queue(":1.42", "/first", "locked message", now)
|
||||
registry.Reset()
|
||||
if got := registry.Flush(now.Add(time.Second)); len(got) != 0 {
|
||||
t.Fatalf("reset presented pending content: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user