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,192 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
)
|
||||
|
||||
type lockerFeedbackSpeaker struct{ spoken chan string }
|
||||
|
||||
func (speaker *lockerFeedbackSpeaker) Connect(context.Context) error { return nil }
|
||||
func (speaker *lockerFeedbackSpeaker) Close(context.Context) error { return nil }
|
||||
func (speaker *lockerFeedbackSpeaker) Cancel(context.Context) error { return nil }
|
||||
func (speaker *lockerFeedbackSpeaker) Speak(_ context.Context, text string) error {
|
||||
speaker.spoken <- text
|
||||
return nil
|
||||
}
|
||||
|
||||
type lockerFeedbackPresenter struct {
|
||||
remoteSpeech
|
||||
interrupts atomic.Int32
|
||||
presented chan struct{}
|
||||
}
|
||||
|
||||
func (presenter *lockerFeedbackPresenter) PresentMessage(message string) bool {
|
||||
accepted := presenter.remoteSpeech.PresentMessage(message)
|
||||
presenter.presented <- struct{}{}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func (presenter *lockerFeedbackPresenter) CancelSpeech() bool {
|
||||
presenter.interrupts.Add(1)
|
||||
return presenter.remoteSpeech.CancelSpeech()
|
||||
}
|
||||
|
||||
// Compile the actual locker feedback implementation without X grabs or PAM.
|
||||
// This catches mismatches in its bus discovery and complete D-Bus call sequence.
|
||||
func TestNativeI38lockFeedback(t *testing.T) {
|
||||
sourceDirectory := os.Getenv("CTHULHU_I38LOCK_SOURCE")
|
||||
if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" || sourceDirectory == "" {
|
||||
t.Skip("requires private session bus and CTHULHU_I38LOCK_SOURCE checkout")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
directory := t.TempDir()
|
||||
harness := filepath.Join(directory, "feedback.c")
|
||||
source := `#include <stdio.h>
|
||||
#include "accessibility_feedback.h"
|
||||
int main(void) {
|
||||
if (!accessibility_feedback_start(-1)) return 1;
|
||||
accessibility_feedback_notify(ACCESSIBILITY_SCREEN_LOCKED);
|
||||
puts("ready");
|
||||
fflush(stdout);
|
||||
int event;
|
||||
while (scanf("%d", &event) == 1) {
|
||||
accessibility_feedback_notify((accessibility_feedback_t)event);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(harness, []byte(source), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "gio-2.0").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable := filepath.Join(directory, "i38lock")
|
||||
arguments := []string{"-o", executable, harness, filepath.Join(sourceDirectory, "accessibility_feedback.c"), "-I" + filepath.Join(sourceDirectory, "include")}
|
||||
arguments = append(arguments, strings.Fields(string(flags))...)
|
||||
if output, err := exec.CommandContext(ctx, "cc", arguments...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("compile actual feedback: %v\n%s", err, output)
|
||||
}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
speaker := &lockerFeedbackSpeaker{spoken: make(chan string, 32)}
|
||||
worker := newSpeechWorker(logger, speaker)
|
||||
if err := worker.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer worker.Stop(context.Background())
|
||||
display := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)}
|
||||
gate := newFocusGate(nil)
|
||||
states := make(chan bool, 8)
|
||||
monitor := &screenLockMonitor{logger: logger, setActive: func(active bool) {
|
||||
gate.SetLocked(active, nil, nil, nil, worker, display)
|
||||
states <- active
|
||||
}}
|
||||
if err := monitor.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer monitor.Stop(context.Background())
|
||||
presenter := &lockerFeedbackPresenter{remoteSpeech: remoteSpeech{speech: worker, braille: lockFeedbackBraille{display}}, presented: make(chan struct{}, 32)}
|
||||
service := remote.New(logger, presenter)
|
||||
if err := service.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Stop(context.Background())
|
||||
process := exec.CommandContext(ctx, executable)
|
||||
stdin, err := process.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stdin.Close()
|
||||
stdout, err := process.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process.Stderr = os.Stderr
|
||||
if err := process.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer process.Process.Kill()
|
||||
ready := make(chan string, 1)
|
||||
go func() { line, _ := bufio.NewReader(stdout).ReadString('\n'); ready <- line }()
|
||||
select {
|
||||
case line := <-ready:
|
||||
if line != "ready\n" {
|
||||
t.Fatalf("locker ready: %q", line)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("locker startup timed out")
|
||||
}
|
||||
expectState := func(want bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-states:
|
||||
if got != want {
|
||||
t.Fatalf("secure state=%v want=%v", got, want)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("missing lock transition")
|
||||
}
|
||||
}
|
||||
expectState(true)
|
||||
// Local output is blocked. Locker feedback travels through self-voice and
|
||||
// does not depend on enabling a local key-echo presenter.
|
||||
worker.Enqueue(speechRequest{Text: "Desktop notification", Cause: "notification"})
|
||||
for index, message := range []string{"Screen locked. Enter password.", "Enter password.", "star", "backspace star", "backspace blank", "Password cleared.", "Verifying.", "Authenticated.", "Incorrect password. Enter password."} {
|
||||
if index > 0 {
|
||||
if _, err := fmt.Fprintln(stdin, index); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case spoken := <-speaker.spoken:
|
||||
if spoken != message {
|
||||
t.Fatalf("feedback %d = %q want=%q", index, spoken, message)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("missing feedback %d: %q", index, message)
|
||||
}
|
||||
select {
|
||||
case <-presenter.presented:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("presentation did not complete")
|
||||
}
|
||||
if got := presenter.interrupts.Load(); got != int32(index+1) {
|
||||
t.Fatalf("feedback %d interrupts=%d", index, got)
|
||||
}
|
||||
if got := display.current().Text; got != message {
|
||||
t.Fatalf("braille feedback=%q want=%q", got, message)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
if err := process.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectState(false)
|
||||
worker.Enqueue(speechRequest{Text: "Desktop resumed", Cause: "focus"})
|
||||
select {
|
||||
case spoken := <-speaker.spoken:
|
||||
if spoken != "Desktop resumed" {
|
||||
t.Fatalf("unlock speech=%q", spoken)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("desktop speech did not resume")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user