Files
cthulhu/internal/app/i38lock_feedback_native_test.go

193 lines
6.0 KiB
Go

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