// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package wayland import ( "context" "errors" "os" "sync" "testing" "time" "git.stormux.org/storm/cthulhu/internal/input" "github.com/godbus/dbus/v5" ) type MonitorSnapshot struct { modifiers []uint32 grabs []keyGrab watching, grabbed bool client string } // FakeKeyboardMonitor verifies the real D-Bus wire contract without pretending // to prove physical key suppression by a compositor. type FakeKeyboardMonitor struct { connection *dbus.Conn mutex sync.Mutex state MonitorSnapshot changes chan MonitorSnapshot rejectNext bool rejectWatch bool } func (monitor *FakeKeyboardMonitor) publish() { copy := monitor.state copy.modifiers = append([]uint32(nil), copy.modifiers...) copy.grabs = append([]keyGrab(nil), copy.grabs...) monitor.changes <- copy } func (monitor *FakeKeyboardMonitor) WatchKeyboard(sender dbus.Sender) *dbus.Error { monitor.mutex.Lock() defer monitor.mutex.Unlock() if monitor.rejectWatch { return dbus.NewError("org.freedesktop.DBus.Error.AccessDenied", []any{"watch denied"}) } monitor.state.client, monitor.state.watching = string(sender), true monitor.publish() return nil } func (monitor *FakeKeyboardMonitor) UnwatchKeyboard() *dbus.Error { monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.state.watching = false monitor.publish() return nil } func (monitor *FakeKeyboardMonitor) GrabKeyboard() *dbus.Error { monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.state.grabbed = true monitor.publish() return nil } func (monitor *FakeKeyboardMonitor) UngrabKeyboard() *dbus.Error { monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.state.grabbed = false monitor.publish() return nil } func (monitor *FakeKeyboardMonitor) SetKeyGrabs(modifiers []uint32, grabs []keyGrab) *dbus.Error { monitor.mutex.Lock() defer monitor.mutex.Unlock() if monitor.rejectNext { monitor.rejectNext = false return dbus.NewError("org.freedesktop.DBus.Error.Failed", []any{"grab update denied"}) } monitor.state.modifiers, monitor.state.grabs = modifiers, grabs monitor.publish() return nil } func start_monitor(t *testing.T) *FakeKeyboardMonitor { t.Helper() if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { t.Skip("requires CTHULHU_NATIVE_DBUS_TEST=1 and a private dbus-run-session") } if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" { t.Fatal("missing private session bus") } connection, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } t.Cleanup(func() { connection.Close() }) monitor := &FakeKeyboardMonitor{connection: connection, changes: make(chan MonitorSnapshot, 128)} if err := connection.Export(monitor, managerPath, monitorInterface); err != nil { t.Fatal(err) } if result, err := connection.RequestName(managerName, dbus.NameFlagDoNotQueue); err != nil || result != dbus.RequestNameReplyPrimaryOwner { t.Fatalf("manager name: %v %v", result, err) } return monitor } func start_keyboard(t *testing.T, monitor *FakeKeyboardMonitor) *Keyboard { t.Helper() keyboard := New([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout, nil) if err := keyboard.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := keyboard.Stop(ctx); err != nil { t.Error(err) } }) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.modifiers) == 3 }) return keyboard } func wait_snapshot(t *testing.T, monitor *FakeKeyboardMonitor, check func(MonitorSnapshot) bool) MonitorSnapshot { t.Helper() for { select { case state := <-monitor.changes: if check(state) { return state } case <-time.After(3 * time.Second): t.Fatal("keyboard protocol update timed out") } } } func emit_key(t *testing.T, connection *dbus.Conn, client string, symbol uint32, code uint16, state uint32, released bool) { t.Helper() message := &dbus.Message{Type: dbus.TypeSignal, Headers: map[dbus.HeaderField]dbus.Variant{ dbus.FieldDestination: dbus.MakeVariant(client), dbus.FieldPath: dbus.MakeVariant(managerPath), dbus.FieldInterface: dbus.MakeVariant(monitorInterface), dbus.FieldMember: dbus.MakeVariant("KeyEvent"), dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf(released, state, symbol, uint32(0), code)), }, Body: []any{released, state, symbol, uint32(0), code}} if call := connection.Send(message, make(chan *dbus.Call, 1)); call.Err != nil { t.Fatal(call.Err) } } func (monitor *FakeKeyboardMonitor) key(t *testing.T, symbol uint32, code uint16, state uint32, released bool) { monitor.mutex.Lock() client := monitor.state.client monitor.mutex.Unlock() emit_key(t, monitor.connection, client, symbol, code, state, released) } func await_command(t *testing.T, keyboard *Keyboard, command input.Command) input.CommandEvent { t.Helper() select { case event := <-keyboard.commands: if event.Command != command { t.Fatalf("command = %q, want %q", event.Command, command) } return event case <-time.After(3 * time.Second): t.Fatalf("command %q timed out", command) } return input.CommandEvent{} } func await_input(t *testing.T, keyboard *Keyboard, name string) input.Event { t.Helper() select { case event := <-keyboard.events: if event.Name != name { t.Fatalf("input = %q, want %q", event.Name, name) } return event case <-time.After(3 * time.Second): t.Fatalf("input %q timed out", name) } return input.Event{} } func TestNativeKeyboardProtocolCommandsMonitoringAndCleanup(t *testing.T) { monitor := start_monitor(t) keyboard := start_keyboard(t, monitor) monitor.key(t, 'x', 53, 0, false) monitor.key(t, 'x', 53, 0, true) press := await_input(t, keyboard, "x") release := await_input(t, keyboard, "x") if !press.Pressed || release.Pressed || release.Timestamp <= press.Timestamp { t.Fatal("input transition order lost") } for _, modifierFirst := range []bool{false, true} { monitor.key(t, 0xff63, 118, 0, false) monitor.key(t, 's', 39, 0, false) monitor.key(t, 's', 39, 0, false) await_command(t, keyboard, input.CycleSpeechMode) if modifierFirst { monitor.key(t, 0xff63, 118, 0, true) } monitor.key(t, 's', 39, 0, true) if !modifierFirst { monitor.key(t, 0xff63, 118, 0, true) } monitor.key(t, 'x', 53, 0, false) monitor.key(t, 'x', 53, 0, true) await_input(t, keyboard, "x") await_input(t, keyboard, "x") if len(keyboard.commands) != 0 || keyboard.CommandGrabActive() { t.Fatal("repeat command or sticky reader modifier") } } monitor.mutex.Lock() client := monitor.state.client monitor.mutex.Unlock() if err := keyboard.Stop(context.Background()); err != nil { t.Fatal(err) } var exists bool if err := monitor.connection.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, client).Store(&exists); err != nil || exists { t.Fatalf("keyboard connection retained after Stop: %v %v", exists, err) } } func TestNativeKeyboardBrowseSecureAndLearnTransitions(t *testing.T) { monitor := start_monitor(t) keyboard := start_keyboard(t, monitor) keyboard.SetBrowseNavigation(true) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { for _, grab := range state.grabs { if grab.Symbol == 'h' { return true } } return false }) monitor.key(t, 'h', 43, 0, false) monitor.key(t, 'h', 43, 0, true) await_command(t, keyboard, input.DocumentNextHeading) if loadShiftPairs().unshifted['#'] == '3' { monitor.key(t, '#', 12, 1, false) monitor.key(t, '3', 12, 0, true) await_command(t, keyboard, input.DocumentPreviousHeadingAtLevel3) } keyboard.SetNativeButtonFocus(true) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { for _, grab := range state.grabs { if grab.Symbol == 0xff0d { return false } } return true }) keyboard.SetSecureInputActive(true) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return !state.watching && len(state.modifiers) == 0 && len(state.grabs) == 0 }) keyboard.SetSecureInputActive(false) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.watching && len(state.modifiers) == 3 }) monitor.key(t, 0xff63, 118, 0, false) monitor.key(t, 'h', 43, 0, false) if !await_command(t, keyboard, input.ToggleLearnMode).LearnModeStarted { t.Fatal("learn did not begin") } monitor.key(t, 'h', 43, 0, true) monitor.key(t, 0xff63, 118, 0, true) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.grabbed }) monitor.key(t, 'q', 24, 0, false) monitor.key(t, 'q', 24, 0, true) await_command(t, keyboard, input.LearnKey) monitor.key(t, 0xff1b, 9, 0, false) monitor.key(t, 0xff1b, 9, 0, true) if await_command(t, keyboard, input.ToggleLearnMode).LearnModeStarted { t.Fatal("Escape entered learn mode") } wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return !state.grabbed }) keyboard.SetCommandGrabs(false) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.modifiers) == 0 && len(state.grabs) == 0 && !state.grabbed }) monitor.key(t, 'q', 24, 0, false) monitor.key(t, 'q', 24, 0, true) await_input(t, keyboard, "q") await_input(t, keyboard, "q") } func TestNativeKeyboardBindingUpdateRollbackAndHeldCancellation(t *testing.T) { monitor := start_monitor(t) keyboard := start_keyboard(t, monitor) monitor.key(t, 0xff63, 118, 0, false) monitor.key(t, 's', 39, 0, false) await_command(t, keyboard, input.CycleSpeechMode) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() if err := keyboard.SetKeyboardConfiguration(ctx, input.LaptopKeyboardLayout, []string{"Caps_Lock"}, nil); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("held update = %v", err) } monitor.key(t, 's', 39, 0, true) monitor.key(t, 0xff63, 118, 0, true) monitor.mutex.Lock() monitor.rejectNext = true monitor.mutex.Unlock() if err := keyboard.SetKeyboardConfiguration(context.Background(), input.LaptopKeyboardLayout, []string{"Caps_Lock"}, nil); err == nil { t.Fatal("rejected update accepted") } monitor.key(t, 0xff63, 118, 0, false) monitor.key(t, ' ', 65, 0, false) await_command(t, keyboard, input.Preferences) monitor.key(t, ' ', 65, 0, true) monitor.key(t, 0xff63, 118, 0, true) bindings := []input.BindingOverride{{Layout: input.LaptopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"Cthulhu+p"}}} if err := keyboard.SetKeyboardConfiguration(context.Background(), input.LaptopKeyboardLayout, []string{"Caps_Lock"}, bindings); err != nil { t.Fatal(err) } monitor.key(t, 0xffe5, 66, 0, false) monitor.key(t, 'p', 33, 0, false) await_command(t, keyboard, input.Preferences) monitor.key(t, 'p', 33, 0, true) monitor.key(t, 0xffe5, 66, 0, true) } func TestNativeKeyboardRejectsSpoofedSignalsAndServiceLoss(t *testing.T) { monitor := start_monitor(t) keyboard := start_keyboard(t, monitor) impostor, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer impostor.Close() monitor.mutex.Lock() client := monitor.state.client monitor.mutex.Unlock() emit_key(t, impostor, client, 0xff63, 118, 0, false) if err := impostor.BusObject().Call("org.freedesktop.DBus.GetId", 0).Err; err != nil { t.Fatal(err) } monitor.key(t, 's', 39, 0, false) monitor.key(t, 's', 39, 0, true) await_input(t, keyboard, "s") await_input(t, keyboard, "s") if len(keyboard.commands) != 0 { t.Fatal("untrusted signal created reader modifier") } monitor.connection.ReleaseName(managerName) select { case <-keyboard.failures: case <-time.After(3 * time.Second): t.Fatal("service loss not detected") } select { case <-keyboard.done: case <-time.After(3 * time.Second): t.Fatal("service loss did not close keyboard connection") } } func TestNativeKeyboardDeniedWatchCleansUp(t *testing.T) { monitor := start_monitor(t) monitor.mutex.Lock() monitor.rejectWatch = true monitor.mutex.Unlock() keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil) if err := keyboard.Start(context.Background()); err == nil { t.Fatal("denied watch accepted") } if err := keyboard.Stop(context.Background()); err != nil { t.Fatal(err) } var exists bool if err := monitor.connection.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, "org.stormux.Cthulhu.KeyboardMonitor").Store(&exists); err != nil || exists { t.Fatal("partial startup retained keyboard identity") } } func TestNativeKeyboardRapidSecureCycleRetiresHeldModifier(t *testing.T) { monitor := start_monitor(t) keyboard := start_keyboard(t, monitor) monitor.key(t, 0xff63, 118, 0, false) monitor.key(t, 't', 28, 0, false) await_command(t, keyboard, input.PresentTime) keyboard.SetSecureInputActive(true) keyboard.SetSecureInputActive(false) // The old physical releases may have been discarded while secure. Plain Q // must reach monitoring rather than becoming a stale Cthulhu+Q command. monitor.key(t, 'q', 24, 0, false) monitor.key(t, 'q', 24, 0, true) await_input(t, keyboard, "q") await_input(t, keyboard, "q") if len(keyboard.commands) != 0 || keyboard.CommandGrabActive() { t.Fatal("secure cycle retained reader chord") } } func TestNativeKeyboardStartsSecureWithoutMonitoringOrGrabs(t *testing.T) { monitor := start_monitor(t) keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil) keyboard.SetSecureInputActive(true) if err := keyboard.Start(context.Background()); err != nil { t.Fatal(err) } defer keyboard.Stop(context.Background()) state := wait_snapshot(t, monitor, func(MonitorSnapshot) bool { return true }) if state.watching || state.grabbed || len(state.modifiers) != 0 || len(state.grabs) != 0 { t.Fatal("secure startup acquired keyboard access") } keyboard.SetSecureInputActive(false) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.watching && len(state.modifiers) == 1 }) }