Progressbar fix.

This commit is contained in:
Storm Dragon committed 2026-10-14 18:22:59 -04:00
1 parent 6754c8ef6c
commit 9205e7280a
10 files changed
+266 -3

No files matched your search

+11 -3
View File
@@ -12,6 +12,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
"git.stormux.org/storm/cthulhu/internal/webscripts"
)
type ProgressBarEntry struct {
@@ -64,7 +65,8 @@ func (history *ProgressBarHistory) report(id atspi.ObjectID, percentage float64,
// Handle progress before ordinary focused-object filtering. This path never
// changes window ownership, native focus, review or browser mode.
func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event) bool {
if !isValueChangeEvent(event.Type) || event.Source.Role != "progress bar" {
percentage, shaderProgress := webscripts.ShaderProgress(event.Source.ApplicationProcess, event.Type, event.Source.Role, event.Source.Name)
if !shaderProgress && (!isValueChangeEvent(event.Type) || event.Source.Role != "progress bar") {
return false
}
settings := config.DefaultSettings()
@@ -75,10 +77,16 @@ func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event)
// NVDA falls back to ordinary focused-control value reporting here.
return false
}
percentage, valid := presentation.ProgressPercentage(event.Source)
if !valid {
if shaderProgress && (event.Source.Defunct || event.Source.Indeterminate) {
return false
}
if !shaderProgress {
var valid bool
percentage, valid = presentation.ProgressPercentage(event.Source)
if !valid {
return false
}
}
state := handler.state
if handler.foreground != nil && event.Foreground.Epoch != state.foregroundState.Epoch {
return true
+68
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"fmt"
"log/slog"
"testing"
@@ -15,6 +16,73 @@ import (
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestSteamShaderProgressUsesSharedOutputAndGates(t *testing.T) {
for _, step := range []struct {
name, mode string
background, reportBackground bool
hidden, defunct, locked, stale bool
wantHandled, wantBeep, wantSpeech bool
}{
{name: "beep", mode: "beep", wantHandled: true, wantBeep: true},
{name: "speak", mode: "speak", wantHandled: true, wantSpeech: true},
{name: "both", mode: "both", wantHandled: true, wantBeep: true, wantSpeech: true},
{name: "off", mode: "off"},
{name: "background suppressed", mode: "both", background: true, wantHandled: true},
{name: "background enabled", mode: "both", background: true, reportBackground: true, wantHandled: true, wantBeep: true, wantSpeech: true},
{name: "hidden", mode: "both", hidden: true},
{name: "defunct", mode: "both", defunct: true},
{name: "secure", mode: "both", locked: true, wantHandled: true},
{name: "stale", mode: "both", stale: true, wantHandled: true},
} {
t.Run(step.name, func(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.OutputMode = step.mode
settings.ProgressBar.ReportBackground = step.reportBackground
live := &liveSettings{}
live.Store(settings)
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
frames := frameRecorder{frames: make(chan braille.Frame, 8)}
state := &eventLoopState{}
state.current = atspi.Snapshot{BusName: ":1.1", Path: "/button", Role: "button"}
before := state.current
gate := &focusGate{}
gate.secure.Store(step.locked)
handler := &accessibilityEventHandler{state: state, live: live, speech: speech, focusGate: gate, brailleOutput: frames}
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{BusName: ":1.1", Path: "/shader-status", Role: "static",
ApplicationProcess: "steamwebhelper", StatesKnown: true, Visible: true, Showing: !step.hidden, Defunct: step.defunct})
event.ForegroundMismatch = step.background
if step.stale {
handler.foreground = &titleProbeSource{}
state.foregroundState.Epoch = 1
}
for _, percentage := range []int{18, 18, 19, 100} {
event.Source.Name = fmt.Sprintf("Processing Vulkan shaders (%d%%)", percentage)
if handled := handler.handle_progress_bar(event); handled != step.wantHandled {
t.Fatalf("handled=%t, want %t", handled, step.wantHandled)
}
}
wantCount := 0
if step.wantBeep {
wantCount = 3
} else if step.wantSpeech {
wantCount = 2
}
if len(speech.requests) != wantCount {
t.Fatalf("got %d requests, want %d", len(speech.requests), wantCount)
}
if wantCount > 0 {
request := <-speech.requests
if !request.Append || (len(request.Sound) == 1) != step.wantBeep || (request.Text == "18 percent") != step.wantSpeech {
t.Fatalf("shader output: %+v", request)
}
}
if state.current.ID() != before.ID() || state.focusedID.Valid() || len(frames.frames) != 0 {
t.Fatal("shader progress changed focus or unrelated braille")
}
})
}
}
func TestProgressSecureEpochAndBrailleGates(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.ReportBackground = true
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// Standard web progress must reach the shared handler without Steam policy.
func TestNativeBrowserProgress(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
t.Fatalf("fixture focus: %s, %v", output, err)
}
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Update progress" {
goto focused
}
case <-ctx.Done():
t.Fatal("update button focus missing")
}
}
focused:
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "space").CombinedOutput(); err != nil {
t.Fatalf("update progress: %s, %v", output, err)
}
seen := make(map[string]bool)
for len(seen) < 2 {
select {
case event := <-adapter.Events():
if !isValueChangeEvent(event.Type) || event.Source.Role != "progress bar" {
continue
}
wantTone := ""
switch event.Source.Name {
case "Native progress":
wantTone = "generated-progress-tone:20"
case "ARIA progress":
wantTone = "generated-progress-tone:30"
default:
continue
}
if seen[event.Source.Name] {
continue
}
if !handler.handle_progress_bar(event) || len(speech.requests) != 1 {
t.Fatalf("web progress was lost: %+v", event)
}
request := <-speech.requests
if len(request.Sound) != 1 || request.Sound[0].Name != wantTone || !request.Append || request.Text != "" {
t.Fatalf("web progress output: %+v", request)
}
seen[event.Source.Name] = true
case <-ctx.Done():
t.Fatalf("web progress events missing; received %v", seen)
}
}
}
+18
View File
@@ -7,6 +7,7 @@ package webscripts
import (
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@@ -15,6 +16,23 @@ 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)$`)
var steamShaderProgress = regexp.MustCompile(`^Processing Vulkan shaders \((\d{1,3})%\)$`)
// ShaderProgress recognizes Steam's English shader status, which Chromium
// exposes as static text rather than an AT-SPI progress bar. Match the leaf
// only: containing sections repeat this text alongside dialog controls.
func ShaderProgress(processName, eventType, role, name string) (float64, bool) {
if MatchProcess(processName) != Steam || eventType != "object:property-change:accessible-name" || role != "static" {
return 0, false
}
match := steamShaderProgress.FindStringSubmatch(strings.TrimSpace(name))
if match == nil {
return 0, false
}
percentage, err := strconv.Atoi(match[1])
return float64(percentage), err == nil && percentage <= 100
}
// 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.
+24
View File
@@ -10,6 +10,30 @@ import (
"time"
)
func TestSteamShaderProgress(t *testing.T) {
for _, step := range []struct {
process, eventType, role, name string
percentage float64
valid bool
}{
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (0%)", 0, true},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 37, true},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (100%)", 100, true},
{"brave", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 0, false},
{"Chromium", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "section", "Processing Vulkan shaders (37%) SKIP", 0, false},
{"steamwebhelper", "object:state-changed:focused", "static", "Processing Vulkan shaders (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Download (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (101%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (-1%)", 0, false},
} {
percentage, valid := ShaderProgress(step.process, step.eventType, step.role, step.name)
if valid != step.valid || valid && percentage != step.percentage {
t.Fatalf("%+v: percentage=%g valid=%t", step, percentage, valid)
}
}
}
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"} {