diff --git a/PROGRESS_BARS.md b/PROGRESS_BARS.md new file mode 100644 index 0000000..855bbc3 --- /dev/null +++ b/PROGRESS_BARS.md @@ -0,0 +1,44 @@ +# Progress reporting + +Progress bars use the configured Off, Beep, Speak, or Both output mode and +percentage thresholds. Background reporting is optional. Updates do not take +focus or replace Braille for an unrelated object. + +Native HTML `` and ARIA `progressbar` controls use their AT-SPI +progress role and value events. Steam's shader dialog instead exposes changing +static text such as `Processing Vulkan shaders (37%)`. The Steam webscript +recognizes that English status under the verified `steamwebhelper` process and +passes its percentage to the shared progress handler. Other text, containing +sections, and ordinary browser processes do not use this exception. Translated +Steam shader messages and other custom text-only progress widgets are not +covered by this recognition. + +## Automated checks + +```sh +GOCACHE=/tmp/cthulhu-go-cache go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1 +python3 tests/native_progress.py +CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py +``` + +The isolated GTK test checks foreground, background, and hidden progress. The +isolated browser test drives real HTML and ARIA controls in a Chromium-family +browser and verifies the resulting tone requests. Unit tests cover Steam text +recognition, output modes, duplicate suppression, percentage thresholds, +security, stale events, background settings, and unrelated Braille ownership. +These checks do not establish audible output from the running reader. + +## Live review before submission + +Rebuild and restart the reader, then launch a Steam game that needs shader +processing. With Beep selected and Steam in the foreground, confirm that pitch +rises as the percentage increases without navigating onto the status text. +With Speak or Both selected, verify the configured speech threshold. With Off +selected, verify that automatic progress output stops. + +Switch to another application during processing: reporting should follow the +background-progress setting without moving focus or replacing that application's +Braille. Check ordinary Steam navigation and notifications afterward. Open +`tests/browser-progress.html` in a browser and activate Update progress to check +native and ARIA bars. Physical keyboard, listening, and Braille checks remain +separate acceptance gates. diff --git a/README.md b/README.md index b16803e..3d37b87 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Cthulhu is a Linux screen reader written in Go. - [Repository Guidelines](AGENTS.md): layout, commands, and working safeguards. - [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. +- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). diff --git a/internal/app/progress_bars.go b/internal/app/progress_bars.go index 08003b8..b59d80b 100644 --- a/internal/app/progress_bars.go +++ b/internal/app/progress_bars.go @@ -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 diff --git a/internal/app/progress_bars_test.go b/internal/app/progress_bars_test.go index 6e758a2..94e439d 100644 --- a/internal/app/progress_bars_test.go +++ b/internal/app/progress_bars_test.go @@ -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 diff --git a/internal/app/progress_browser_native_test.go b/internal/app/progress_browser_native_test.go new file mode 100644 index 0000000..004efac --- /dev/null +++ b/internal/app/progress_browser_native_test.go @@ -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) + } + } +} diff --git a/internal/webscripts/steam.go b/internal/webscripts/steam.go index cdeb2b2..3337d0b 100644 --- a/internal/webscripts/steam.go +++ b/internal/webscripts/steam.go @@ -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. diff --git a/internal/webscripts/steam_test.go b/internal/webscripts/steam_test.go index 6de1016..0a02554 100644 --- a/internal/webscripts/steam_test.go +++ b/internal/webscripts/steam_test.go @@ -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"} { diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index e39bc1a..b033683 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -55,3 +55,4 @@ web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/ web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Fixture speech verified; real Steam listening pending,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. +steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md. diff --git a/tests/browser-progress.html b/tests/browser-progress.html new file mode 100644 index 0000000..32ab61a --- /dev/null +++ b/tests/browser-progress.html @@ -0,0 +1,19 @@ + + + +Cthulhu Browser Probe + + + + +
+Processing Vulkan shaders (0%) + + diff --git a/tests/native_browser.py b/tests/native_browser.py index 1ebdea8..317879a 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -10,6 +10,7 @@ import time BROWSER_PROOFS = { + "progress": ("./internal/app", "^TestNativeBrowserProgress$", 30, 35, "browser-progress.html"), "fragments": ("./internal/review", "^TestNativeBrowserFragmentLines$", 30, 35, "browser-fragments.html"), "positions": ("./internal/atspi", "^TestNativeBrowserSetPositions$", 25, 30, "browser-positions.html"), "tabs": ("./internal/app", "^TestNativeBrowserTabs$", 30, 35),