// 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 #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") } }