193 lines
6.0 KiB
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")
|
|
}
|
|
}
|