325 lines
10 KiB
Go
325 lines
10 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package remote
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/godbus/dbus/v5"
|
|
)
|
|
|
|
type recordingHandler struct {
|
|
mutex sync.Mutex
|
|
presented []string
|
|
spoken []string
|
|
interrupts []bool
|
|
canceled int
|
|
braille []string
|
|
wine []AccessibleEvent
|
|
result bool
|
|
}
|
|
|
|
func (handler *recordingHandler) PresentMessage(message string) bool {
|
|
handler.mutex.Lock()
|
|
defer handler.mutex.Unlock()
|
|
handler.presented = append(handler.presented, message)
|
|
return handler.result
|
|
}
|
|
|
|
func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool {
|
|
handler.mutex.Lock()
|
|
defer handler.mutex.Unlock()
|
|
handler.spoken = append(handler.spoken, text)
|
|
handler.interrupts = append(handler.interrupts, interrupt)
|
|
return handler.result
|
|
}
|
|
|
|
func (handler *recordingHandler) BrailleMessage(text string) bool {
|
|
handler.mutex.Lock()
|
|
defer handler.mutex.Unlock()
|
|
handler.braille = append(handler.braille, text)
|
|
return false
|
|
}
|
|
|
|
func (handler *recordingHandler) CancelSpeech() bool {
|
|
handler.mutex.Lock()
|
|
defer handler.mutex.Unlock()
|
|
handler.canceled++
|
|
return handler.result
|
|
}
|
|
|
|
func (handler *recordingHandler) PresentAccessibleEvent(event AccessibleEvent) bool {
|
|
handler.mutex.Lock()
|
|
defer handler.mutex.Unlock()
|
|
handler.wine = append(handler.wine, event)
|
|
return handler.result
|
|
}
|
|
|
|
func authorizedEndpoint(handler Handler) *endpoint {
|
|
endpoint := newEndpoint(slog.New(slog.DiscardHandler), handler, func(dbus.Sender) bool { return true })
|
|
endpoint.startAccepting()
|
|
return endpoint
|
|
}
|
|
|
|
type fakeConnection struct {
|
|
mutex sync.Mutex
|
|
exports []string
|
|
requestReply dbus.RequestNameReply
|
|
requestError error
|
|
releaseReply dbus.ReleaseNameReply
|
|
closed bool
|
|
failExport string
|
|
}
|
|
|
|
func (connection *fakeConnection) Export(value any, _ dbus.ObjectPath, iface string) error {
|
|
connection.mutex.Lock()
|
|
defer connection.mutex.Unlock()
|
|
if value != nil && iface == connection.failExport {
|
|
return errors.New("simulated export failure")
|
|
}
|
|
action := "export "
|
|
if value == nil {
|
|
action = "unexport "
|
|
}
|
|
connection.exports = append(connection.exports, action+iface)
|
|
return nil
|
|
}
|
|
|
|
func TestServiceExportFailureClosesOnlyItsConnection(t *testing.T) {
|
|
failed := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, failExport: "org.freedesktop.DBus.Introspectable"}
|
|
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return failed, nil })
|
|
service.orca = true
|
|
if err := service.Start(context.Background()); err == nil {
|
|
t.Fatal("export failure was ignored")
|
|
}
|
|
if !failed.closed {
|
|
t.Fatal("failed Orca connection was left open")
|
|
}
|
|
ready := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, releaseReply: dbus.ReleaseNameReplyReleased}
|
|
native := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return ready, nil })
|
|
if err := native.Start(context.Background()); err != nil {
|
|
t.Fatalf("native service after Orca export failure: %v", err)
|
|
}
|
|
if err := native.Stop(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (connection *fakeConnection) RequestName(string, dbus.RequestNameFlags) (dbus.RequestNameReply, error) {
|
|
return connection.requestReply, connection.requestError
|
|
}
|
|
|
|
func (connection *fakeConnection) ReleaseName(string) (dbus.ReleaseNameReply, error) {
|
|
return connection.releaseReply, nil
|
|
}
|
|
|
|
func (connection *fakeConnection) UnixUser(context.Context, string) (uint32, error) {
|
|
return uint32(os.Getuid()), nil
|
|
}
|
|
|
|
func (connection *fakeConnection) Close() error {
|
|
connection.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestServiceOwnsAndReleasesName(t *testing.T) {
|
|
fakeBus := &fakeConnection{
|
|
requestReply: dbus.RequestNameReplyPrimaryOwner,
|
|
releaseReply: dbus.ReleaseNameReplyReleased,
|
|
}
|
|
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) {
|
|
return fakeBus, nil
|
|
})
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if err := service.Stop(context.Background()); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if !fakeBus.closed {
|
|
t.Fatal("session bus connection was not closed")
|
|
}
|
|
want := []string{
|
|
"export " + InterfaceName,
|
|
"export org.freedesktop.DBus.Introspectable",
|
|
"export " + SpeechManagerInterface,
|
|
"export org.freedesktop.DBus.Introspectable",
|
|
"unexport org.freedesktop.DBus.Introspectable",
|
|
"unexport " + SpeechManagerInterface,
|
|
"unexport org.freedesktop.DBus.Introspectable",
|
|
"unexport " + InterfaceName,
|
|
}
|
|
if got := strings.Join(fakeBus.exports, ","); got != strings.Join(want, ",") {
|
|
t.Fatalf("exports = %q, want %q", got, strings.Join(want, ","))
|
|
}
|
|
}
|
|
|
|
func TestNativeSessionBusSelfVoice(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" {
|
|
t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus")
|
|
}
|
|
handler := &recordingHandler{result: true}
|
|
service := New(slog.New(slog.DiscardHandler), handler)
|
|
if err := service.Start(context.Background()); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
t.Cleanup(func() { service.Stop(context.Background()) })
|
|
|
|
conflict := New(slog.New(slog.DiscardHandler), handler)
|
|
if err := conflict.Start(context.Background()); err == nil {
|
|
conflict.Stop(context.Background())
|
|
t.Fatal("second service acquired the well-known name")
|
|
}
|
|
|
|
client, err := dbus.ConnectSessionBus()
|
|
if err != nil {
|
|
t.Fatalf("connect client: %v", err)
|
|
}
|
|
defer client.Close()
|
|
object := client.Object(BusName, ObjectPath)
|
|
var accepted bool
|
|
if err := object.Call(InterfaceName+".SpeakText", 0, "Native message", false).Store(&accepted); err != nil {
|
|
t.Fatalf("SpeakText: %v", err)
|
|
}
|
|
if !accepted {
|
|
t.Fatal("SpeakText was not accepted")
|
|
}
|
|
if err := object.Call(InterfaceName+".PresentAccessibleEvent", 0,
|
|
"42:0:0", "focus", "Start", "button", "", "", "", int32(1), int32(3), "Game").Store(&accepted); err != nil {
|
|
t.Fatalf("PresentAccessibleEvent: %v", err)
|
|
}
|
|
if !accepted {
|
|
t.Fatal("PresentAccessibleEvent was not accepted")
|
|
}
|
|
if err := object.Call(InterfaceName+".BrailleMessage", 0, "Braille").Store(&accepted); err != nil {
|
|
t.Fatalf("BrailleMessage: %v", err)
|
|
}
|
|
if accepted {
|
|
t.Fatal("BrailleMessage succeeded without a braille backend")
|
|
}
|
|
var ssmlResult int32
|
|
if err := object.Call(InterfaceName+".SpeakSsml", 0, "request", "<speak>Text</speak>").Store(&ssmlResult); err != nil {
|
|
t.Fatalf("SpeakSsml: %v", err)
|
|
}
|
|
if ssmlResult != windowsErrorNotSupported {
|
|
t.Fatalf("SpeakSsml = %d, want %d", ssmlResult, windowsErrorNotSupported)
|
|
}
|
|
var xml string
|
|
if err := object.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil {
|
|
t.Fatalf("Introspect: %v", err)
|
|
}
|
|
for _, method := range []string{"PresentMessage", "SpeakText", "BrailleMessage", "CancelSpeech", "SpeakSsml", "PresentAccessibleEvent"} {
|
|
if !strings.Contains(xml, `method name="`+method+`"`) {
|
|
t.Fatalf("introspection does not contain %s: %s", method, xml)
|
|
}
|
|
}
|
|
|
|
manager := client.Object(BusName, SpeechManagerPath)
|
|
for _, notifyUser := range []bool{true, false} {
|
|
if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, notifyUser).Store(&accepted); err != nil || !accepted {
|
|
t.Fatalf("InterruptSpeech(%v): accepted=%v, error=%v", notifyUser, accepted, err)
|
|
}
|
|
}
|
|
handler.mutex.Lock()
|
|
canceled := handler.canceled
|
|
handler.mutex.Unlock()
|
|
if canceled != 2 {
|
|
t.Fatalf("InterruptSpeech cancellations = %d", canceled)
|
|
}
|
|
if err := manager.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(xml, `method name="InterruptSpeech"`) || !strings.Contains(xml, `type="b" direction="in"`) {
|
|
t.Fatalf("missing SpeechManager signature: %s", xml)
|
|
}
|
|
if err := service.Stop(context.Background()); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, true).Store(&accepted); err == nil {
|
|
t.Fatal("SpeechManager accepted a call after Stop")
|
|
}
|
|
replacement := New(slog.New(slog.DiscardHandler), handler)
|
|
if err := replacement.Start(context.Background()); err != nil {
|
|
t.Fatalf("replacement Start after name release: %v", err)
|
|
}
|
|
if err := replacement.Stop(context.Background()); err != nil {
|
|
t.Fatalf("replacement Stop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsNameConflictAndCleansUp(t *testing.T) {
|
|
fakeBus := &fakeConnection{requestReply: dbus.RequestNameReplyExists}
|
|
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) {
|
|
return fakeBus, nil
|
|
})
|
|
err := service.Start(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "already owned") {
|
|
t.Fatalf("Start error = %v, want name ownership error", err)
|
|
}
|
|
if !fakeBus.closed {
|
|
t.Fatal("conflicted session bus connection was not closed")
|
|
}
|
|
}
|
|
|
|
func TestServicePropagatesConnectionFailure(t *testing.T) {
|
|
want := errors.New("no session bus")
|
|
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{}, func() (connection, error) {
|
|
return nil, want
|
|
})
|
|
if err := service.Start(context.Background()); !errors.Is(err, want) {
|
|
t.Fatalf("Start error = %v, want %v", err, want)
|
|
}
|
|
}
|
|
|
|
func TestStopWaitsForActiveCall(t *testing.T) {
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
endpoint := newEndpoint(slog.New(slog.DiscardHandler), &recordingHandler{}, func(dbus.Sender) bool {
|
|
close(started)
|
|
<-release
|
|
return true
|
|
})
|
|
endpoint.startAccepting()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
endpoint.CancelSpeech(dbus.Sender(":1.9"))
|
|
close(done)
|
|
}()
|
|
<-started
|
|
idle := endpoint.stopAccepting()
|
|
select {
|
|
case <-idle:
|
|
t.Fatal("endpoint became idle while a call was active")
|
|
default:
|
|
}
|
|
close(release)
|
|
select {
|
|
case <-idle:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("endpoint did not become idle")
|
|
}
|
|
<-done
|
|
}
|