// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package remote import ( "context" "log/slog" "os" "strings" "testing" "github.com/godbus/dbus/v5" ) type moduleRecordingHandler struct { recordingHandler value bool command string preferencesShown bool quitRequested bool } func (handler *moduleRecordingHandler) ShowPreferences() bool { handler.mutex.Lock() defer handler.mutex.Unlock() handler.preferencesShown = true return true } func (handler *moduleRecordingHandler) Quit() bool { handler.mutex.Lock() defer handler.mutex.Unlock() handler.quitRequested = true return true } func (handler *moduleRecordingHandler) RuntimeStateSnapshot() string { return "CTHULHU RUNTIME STATE SNAPSHOT\n[event_manager]\noverflow: 0" } func (handler *moduleRecordingHandler) RemoteGet(module, property string) (dbus.Variant, bool) { if module == "TypingEchoManager" && property == "KeyEchoEnabled" { handler.mutex.Lock() defer handler.mutex.Unlock() return dbus.MakeVariant(handler.value), true } return dbus.Variant{}, false } func (handler *moduleRecordingHandler) RemoteSet(module, property string, value dbus.Variant) bool { if module != "TypingEchoManager" || property != "KeyEchoEnabled" { return false } handler.mutex.Lock() defer handler.mutex.Unlock() handler.value = value.Value().(bool) return true } func (handler *moduleRecordingHandler) RemoteCommand(module, command string, _ bool) bool { handler.mutex.Lock() defer handler.mutex.Unlock() handler.command = module + "." + command return true } func TestNativeAndOrcaModuleBus(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 := &moduleRecordingHandler{recordingHandler: recordingHandler{result: true}} logger := slog.New(slog.DiscardHandler) native := New(logger, handler) orca := NewOrca(logger, handler) if err := native.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { native.Stop(context.Background()) }) if err := orca.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { orca.Stop(context.Background()) }) client, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer client.Close() for _, test := range []struct { name string path dbus.ObjectPath iface string }{ {BusName, ObjectPath, InterfaceName}, {OrcaBusName, OrcaObjectPath, OrcaInterfaceName}, } { object := client.Object(test.name, test.path) var version string if err := object.Call(test.iface+".GetVersion", 0).Store(&version); err != nil || !strings.Contains(version, "Cthulhu") { t.Fatalf("GetVersion: %q, %v", version, err) } var accepted bool if err := object.Call(test.iface+".ShowPreferences", 0).Store(&accepted); err != nil || !accepted { t.Fatalf("ShowPreferences: %v, %v", accepted, err) } if err := object.Call(test.iface+".Quit", 0).Store(&accepted); err != nil || !accepted { t.Fatalf("Quit: %v, %v", accepted, err) } } var speechXML string if err := client.Object(BusName, SpeechManagerPath).Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&speechXML); err != nil { t.Fatal(err) } for _, signature := range []string{`property name="Rate" type="i" access="readwrite"`, `property name="Pitch" type="d" access="readwrite"`, `method name="IncreaseRate"`} { if !strings.Contains(speechXML, signature) { t.Fatalf("speech introspection missing %s: %s", signature, speechXML) } } handler.mutex.Lock() shown, quit := handler.preferencesShown, handler.quitRequested handler.mutex.Unlock() if !shown || !quit { t.Fatal("control calls did not reach runtime owner") } var snapshot string if err := client.Object(BusName, ObjectPath).Call(InterfaceName+".GetRuntimeStateSnapshot", 0).Store(&snapshot); err != nil || !strings.Contains(snapshot, "RUNTIME STATE SNAPSHOT") { t.Fatalf("snapshot = %q, %v", snapshot, err) } for _, test := range []struct { name string path dbus.ObjectPath iface string }{ {BusName, modulePath(ObjectPath, "TypingEchoManager"), typingEchoInterface}, {OrcaBusName, modulePath(OrcaObjectPath, "TypingEchoManager"), orcaModuleInterface}, } { object := client.Object(test.name, test.path) var xml string if err := object.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil { t.Fatal(err) } if !strings.Contains(xml, `name="`+test.iface+`"`) { t.Fatalf("missing interface %s in %s", test.iface, xml) } if test.name == BusName { if !strings.Contains(xml, `property name="KeyEchoEnabled" type="b" access="readwrite"`) { t.Fatalf("missing property: %s", xml) } if !strings.Contains(xml, `method name="CycleKeyEcho"`) { t.Fatalf("missing command: %s", xml) } } else if !strings.Contains(xml, `method name="ExecuteRuntimeSetter"`) { t.Fatalf("missing generic protocol: %s", xml) } } nativeObject := client.Object(BusName, modulePath(ObjectPath, "TypingEchoManager")) var value dbus.Variant if err := nativeObject.Call("org.freedesktop.DBus.Properties.Get", 0, typingEchoInterface, "KeyEchoEnabled").Store(&value); err != nil { t.Fatal(err) } if value.Value() != false { t.Fatalf("initial value = %v", value) } if err := nativeObject.Call("org.freedesktop.DBus.Properties.Set", 0, typingEchoInterface, "KeyEchoEnabled", dbus.MakeVariant(true)).Err; err != nil { t.Fatal(err) } handler.mutex.Lock() gotValue := handler.value handler.mutex.Unlock() if !gotValue { t.Fatal("native setter did not reach runtime owner") } if err := nativeObject.Call("org.freedesktop.DBus.Properties.Set", 0, typingEchoInterface, "KeyEchoEnabled", dbus.MakeVariant("wrong type")).Err; err == nil { t.Fatal("wrong type accepted") } orcaObject := client.Object(OrcaBusName, modulePath(OrcaObjectPath, "TypingEchoManager")) if err := orcaObject.Call(orcaModuleInterface+".ExecuteRuntimeGetter", 0, "KeyEchoEnabled").Store(&value); err != nil { t.Fatal(err) } if value.Value() != true { t.Fatalf("Orca getter = %v", value) } var accepted bool if err := orcaObject.Call(orcaModuleInterface+".ExecuteRuntimeSetter", 0, "KeyEchoEnabled", dbus.MakeVariant(false)).Store(&accepted); err != nil || !accepted { t.Fatalf("Orca setter = %v, %v", accepted, err) } if err := orcaObject.Call(orcaModuleInterface+".ExecuteCommand", 0, "CycleKeyEcho", true).Store(&accepted); err != nil || !accepted { t.Fatalf("Orca command = %v, %v", accepted, err) } handler.mutex.Lock() gotCommand := handler.command handler.mutex.Unlock() if gotCommand != "TypingEchoManager.CycleKeyEcho" { t.Fatalf("command owner got %q", gotCommand) } if err := orcaObject.Call(orcaModuleInterface+".ExecuteCommand", 0, "Unsupported", true).Err; err == nil { t.Fatal("unsupported command falsely succeeded") } var names []string if err := client.Object(OrcaBusName, OrcaObjectPath).Call(OrcaInterfaceName+".ListModules", 0).Store(&names); err != nil { t.Fatal(err) } if strings.Join(names, ",") != "SpeechManager,TypingEchoManager" { t.Fatalf("modules = %v", names) } var commands []namedDescription if err := client.Object(OrcaBusName, OrcaObjectPath).Call(OrcaInterfaceName+".ListCommands", 0).Store(&commands); err != nil || len(commands) != 6 { t.Fatalf("service commands = %v, %v", commands, err) } if err := orcaObject.Call(orcaModuleInterface+".ListRuntimeGetters", 0).Store(&commands); err != nil || len(commands) != 13 { t.Fatalf("module getters = %v, %v", commands, err) } var parameterized []parameterizedDescription if err := orcaObject.Call(orcaModuleInterface+".ListParameterizedCommands", 0).Store(¶meterized); err != nil || len(parameterized) != 0 { t.Fatalf("parameterized commands = %v, %v", parameterized, err) } var result dbus.Variant if err := orcaObject.Call(orcaModuleInterface+".ExecuteParameterizedCommand", 0, "Unsupported", map[string]dbus.Variant{}, false).Store(&result); err == nil { t.Fatal("unsupported parameterized command falsely succeeded") } conflict := NewOrca(logger, handler) if err := conflict.Start(context.Background()); err == nil { conflict.Stop(context.Background()) t.Fatal("second Orca name acquired") } if err := orca.Stop(context.Background()); err != nil { t.Fatal(err) } if err := nativeObject.Call("org.freedesktop.DBus.Properties.Get", 0, typingEchoInterface, "KeyEchoEnabled").Store(&value); err != nil { t.Fatalf("native service lost when Orca stopped: %v", err) } replacement := NewOrca(logger, handler) if err := replacement.Start(context.Background()); err != nil { t.Fatalf("Orca name not reacquired: %v", err) } if err := replacement.Stop(context.Background()); err != nil { t.Fatal(err) } }