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