Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"encoding/json"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/remote"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/wine"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
)
type wineNativeOutput struct {
requests chan speechRequest
frames chan braille.Frame
}
func (o *wineNativeOutput) Enqueue(request speechRequest) { o.requests <- request }
func (o *wineNativeOutput) Interrupt() {}
func (o *wineNativeOutput) EnqueueFrame(frame braille.Frame) { o.frames <- frame }
func TestNativeWineReviewPhysicalKeys(t *testing.T) {
if os.Getenv("CTHULHU_WINE_REVIEW_BUILD") == "" {
t.Skip("requires isolated Wine build/display/session")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
registry := wine.NewReviewRegistry(logger)
if err := registry.Start(ctx); err != nil {
t.Fatal(err)
}
defer registry.Stop(context.Background())
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
if err := commands.Start(ctx); err != nil {
t.Fatal(err)
}
defer commands.Stop(context.Background())
gate := newFocusGate(commands)
worker, err := review.NewWorker(&reviewCaptureRouter{native: adapter, wine: registry}, time.Second)
if err != nil {
t.Fatal(err)
}
if err = worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
live := &liveSettings{wineReview: registry}
live.Store(config.DefaultSettings())
output := &wineNativeOutput{requests: make(chan speechRequest, 256), frames: make(chan braille.Frame, 256)}
queue := outputQueue{speech: output, braille: output, live: live}
helperEvents := newWineEventPresenter(func(text string) bool {
output.Enqueue(speechRequest{Text: text, Cause: "wine-accessible-event"})
return true
})
defer helperEvents.Close()
service := remote.New(logger, remoteSpeech{speech: output, wine: helperEvents, wineReview: registry})
if err = service.Start(ctx); err != nil {
t.Fatal(err)
}
defer service.Stop(context.Background())
eventDone, commandDone := make(chan error, 1), make(chan error, 1)
go func() {
eventDone <- processEventsWithAttributes(ctx, logger, queue, adapter, nil, nil, worker, 0, live, nil, gate, nil, nil)
}()
go func() {
commandDone <- (commandRuntime{logger: logger, commands: commands, focusChanges: commands.FocusChanges(), focusGate: gate, speechOutput: queue, objectReview: worker, live: live, stopRun: cancel}).run(ctx)
}()
defer func() {
cancel()
if err := <-eventDone; err != nil {
t.Error(err)
}
if err := <-commandDone; err != nil {
t.Error(err)
}
}()
driver := exec.CommandContext(ctx, "python3", "-u", "../../tests/wine_review_x11.py")
stdin, err := driver.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := driver.StdoutPipe()
if err != nil {
t.Fatal(err)
}
driver.Stderr = os.Stderr
if err = driver.Start(); err != nil {
t.Fatal(err)
}
defer func() {
stdin.Close()
if err := driver.Wait(); err != nil && ctx.Err() == nil {
t.Error(err)
}
}()
scanner := bufio.NewScanner(stdout)
ready := func() {
t.Helper()
if !scanner.Scan() || scanner.Text() != "{\"ready\": true}" {
t.Fatalf("physical fixture not ready: %s", scanner.Text())
}
}
ready()
send := func(command map[string]any) {
t.Helper()
data, _ := json.Marshal(command)
if _, err := stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
ready()
}
waitFocus := func() {
t.Helper()
deadline := time.Now().Add(6 * time.Second)
for {
matched := false
probe := &wineReviewFocusController{}
for _, notice := range registry.Focuses() {
if notice.ID() == worker.Focus() && probe.eligible(notice, adapter.Foreground(), adapter.ForegroundTitle()) {
matched = true
}
}
if matched {
return
}
if time.Now().After(deadline) {
t.Fatalf("Wine focus unavailable; notices=%+v foreground=%+v title=%+v", registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle())
}
time.Sleep(10 * time.Millisecond)
}
}
waitFocus()
waitCaptureReady := func() {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
id := worker.Focus()
if _, err := registry.Capture(ctx, id); err == nil && registry.Current(id) && worker.Focus() == id {
return
}
if time.Now().After(deadline) {
t.Fatal("new native provider never reached a consistent capture")
}
time.Sleep(10 * time.Millisecond)
}
}
check := func(symbols []uint32, want string, spelling bool) {
t.Helper()
// Normal Linux focus frames can remain queued after switching back to
// Wine. Inspect only frames produced for this physical command.
for len(output.frames) > 0 {
<-output.frames
}
started := time.Now()
send(map[string]any{"action": "keys", "symbols": symbols})
for {
select {
case request := <-output.requests:
if request.Cause != "flat-review" {
continue
}
if request.Text != want || (spelling && len(request.Spelling) == 0) {
t.Fatalf("keys %x: speech=%+v want=%q", symbols, request, want)
}
if spelling && want == "b" && request.Spelling[0] != "bravo" {
t.Fatalf("phonetic letter: %+v", request.Spelling)
}
t.Logf("keys %x: %q queued in %s", symbols, want, time.Since(started))
if strings.Contains(request.Text, "private-review-secret") || strings.Contains(request.Text, "hidden-review-secret") {
t.Fatal("excluded text reached speech")
}
goto frame
case <-time.After(2 * time.Second):
tree, captureErr := registry.Capture(ctx, worker.Focus())
t.Logf("capture=%+v error=%v focus=%+v notices=%+v foreground=%+v title=%+v", tree, captureErr, worker.Focus(), registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle())
t.Fatalf("physical keys %x did not produce %q", symbols, want)
}
}
frame:
if !wine.IsReviewID(worker.Focus()) {
return
}
for {
select {
case f := <-output.frames:
if f.Text == "" {
continue
}
if len(f.Routes) != 0 || f.Document != nil || strings.Contains(f.Text, "private-review-secret") {
t.Fatalf("unsafe native routing: %+v", f)
}
return
case <-time.After(time.Second):
t.Fatal("review braille output missing")
}
}
}
check([]uint32{0xff97}, "alpha beta", false)
check([]uint32{0xff9a}, "second line", false)
check([]uint32{0xff95}, "alpha beta", false)
check([]uint32{0xff9d}, "alpha ", false)
check([]uint32{0xff9d}, "alpha ", true)
check([]uint32{0xff9d}, "alpha ", true)
check([]uint32{0xff98}, "beta", false)
check([]uint32{0xff99}, "b", false)
check([]uint32{0xff99}, "b", true)
check([]uint32{0xff9b}, "e", false)
check([]uint32{0xff9c}, "b", false)
check([]uint32{0xff63, 0xff9c}, "a", false)
check([]uint32{0xff63, 0xff98}, "line", false)
check([]uint32{0xff63, 0xff96}, "beta", false)
check([]uint32{0xff63, 0xff95}, "Command:", false)
check([]uint32{0xff63, 0xff9a}, "Open & close", false)
check([]uint32{0xff63, 0xff95}, "Command:", false)
check([]uint32{0xff9a}, "alpha beta", false)
check([]uint32{0xff98}, "beta", false)
send(map[string]any{"action": "check"})
if os.Getenv("CTHULHU_WINE_REVIEW_WM") == "i3" {
send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}})
send(map[string]any{"action": "check", "check": "--check-right"})
return
}
send(map[string]any{"action": "configure", "mode": 6})
time.Sleep(200 * time.Millisecond)
check([]uint32{0xff9d}, "zeta", false)
// Seek the first text line from the window's caption, then verify an update
// within the same control without a native caret change.
check([]uint32{0xff63, 0xff95}, "Command:", false)
check([]uint32{0xff9a}, "omega zeta native & item", false)
send(map[string]any{"action": "check"})
send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}})
send(map[string]any{"action": "check", "check": "--check-right"})
send(map[string]any{"action": "configure", "mode": 8})
positionDeadline := time.Now().Add(5 * time.Second)
for {
moved := false
for _, notice := range registry.Focuses() {
if notice.Bounds.X < 0 {
moved = true
}
}
if moved {
break
}
if time.Now().After(positionDeadline) {
t.Fatal("native window movement was not published")
}
time.Sleep(10 * time.Millisecond)
}
waitFocus()
check([]uint32{0xff97}, "alpha beta native & item", false)
oldFocus := worker.Focus()
send(map[string]any{"action": "restart-helper"})
deadline := time.Now().Add(5 * time.Second)
for worker.Focus() == oldFocus || !wine.IsReviewID(worker.Focus()) {
if time.Now().After(deadline) {
t.Fatal("helper restart kept old review lifetime")
}
time.Sleep(10 * time.Millisecond)
}
waitFocus()
check([]uint32{0xff97}, "alpha beta native & item", false)
// Restart the actual service owner and registry connections. The helper must
// register again and authorize the newly owned connection for capture.
if err := service.Stop(context.Background()); err != nil {
t.Fatal(err)
}
if err := registry.Stop(context.Background()); err != nil {
t.Fatal(err)
}
worker.ClearFocus()
if err := registry.Start(ctx); err != nil {
t.Fatal(err)
}
if err := service.Start(ctx); err != nil {
t.Fatal(err)
}
waitFocus()
check([]uint32{0xff97}, "alpha beta native & item", false)
// A native Linux foreground change retires a queued Wine capture, while its
// blocked MSAA worker is still waiting in the application's window procedure.
send(map[string]any{"action": "configure", "mode": 10})
waitFocus()
send(map[string]any{"action": "keys", "symbols": []uint32{0xff97}})
send(map[string]any{"action": "linux"})
deadline = time.Now().Add(5 * time.Second)
for !worker.Focus().Valid() || wine.IsReviewID(worker.Focus()) {
if time.Now().After(deadline) {
t.Fatalf("Linux focus not restored: %+v", worker.Focus())
}
time.Sleep(10 * time.Millisecond)
}
check([]uint32{0xff97}, "Linux review ready", false)
lateDeadline := time.After(2 * time.Second)
late:
for {
select {
case request := <-output.requests:
if request.Cause == "flat-review" {
t.Fatalf("late Wine review result: %+v", request)
}
case <-lateDeadline:
break late
}
}
send(map[string]any{"action": "wine"})
waitFocus()
firstPrefix := worker.Focus()
send(map[string]any{"action": "second-prefix"})
deadline = time.Now().Add(5 * time.Second)
for worker.Focus().BusName == firstPrefix.BusName || !wine.IsReviewID(worker.Focus()) {
if time.Now().After(deadline) {
t.Fatal("identical titles shared a provider across prefixes")
}
time.Sleep(10 * time.Millisecond)
}
waitFocus()
if worker.Focus().BusName == firstPrefix.BusName {
t.Fatal("identical titles shared a provider across prefixes")
}
// The 32-bit hook host can publish initial lifetime events after its X11
// window appears. Handshake on a consistent snapshot before this physical
// key; every key still captures again. Do not warm the stalled-call phase.
waitCaptureReady()
check([]uint32{0xff97}, "alpha beta native & item", false)
send(map[string]any{"action": "wine"})
deadline = time.Now().Add(5 * time.Second)
for worker.Focus() != firstPrefix {
if time.Now().After(deadline) {
t.Fatal("return to first prefix lost its native lifetime")
}
time.Sleep(10 * time.Millisecond)
}
waitFocus()
if worker.Focus() != firstPrefix {
t.Fatal("return to first prefix lost its native lifetime")
}
check([]uint32{0xff97}, "alpha beta native & item", false)
oldDesktopFocus := worker.Focus()
send(map[string]any{"action": "virtual-desktop"})
deadline = time.Now().Add(6 * time.Second)
for !wine.IsReviewID(worker.Focus()) || worker.Focus().BusName == oldDesktopFocus.BusName {
if time.Now().After(deadline) {
t.Fatal("virtual desktop restart kept the old helper lifetime")
}
time.Sleep(10 * time.Millisecond)
}
waitFocus()
waitCaptureReady()
check([]uint32{0xff97}, "alpha beta native & item", false)
}