From 3e0bdc578f7ad37df0f88be073c8ae419ec775fd Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 15 Oct 2026 17:29:41 -0400 Subject: [PATCH] Possibly make things a little better for wayland users. --- README.md | 1 + internal/app/app.go | 14 +- internal/app/runtime_platform.go | 20 +- internal/app/wayland_native_test.go | 101 +++++ internal/platform/wayland/keyboard.go | 243 +++++++++++ internal/platform/wayland/protocol.go | 299 +++++++++++++ internal/platform/wayland/protocol_test.go | 408 ++++++++++++++++++ internal/platform/wayland/routing.go | 195 +++++++++ internal/platform/wayland/routing_test.go | 214 +++++++++ .../xorg/xterm_handoff_native_test.go | 2 +- internal/sessionbus/sessionbus.go | 4 +- testdata/parity-ledger.csv | 1 + 12 files changed, 1495 insertions(+), 7 deletions(-) create mode 100644 internal/app/wayland_native_test.go create mode 100644 internal/platform/wayland/keyboard.go create mode 100644 internal/platform/wayland/protocol.go create mode 100644 internal/platform/wayland/protocol_test.go create mode 100644 internal/platform/wayland/routing.go create mode 100644 internal/platform/wayland/routing_test.go diff --git a/README.md b/README.md index c2b2377..b4c97c0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Cthulhu is a Linux screen reader written in Go. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. - [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification. - [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification. +- [Wayland keyboard access](WAYLAND_SUPPORT.md): shared protocol support, limitations, and verification. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). diff --git a/internal/app/app.go b/internal/app/app.go index 64f3be9..81ad5f6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,6 +29,8 @@ import ( "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/lifecycle" "git.stormux.org/storm/cthulhu/internal/logging" + "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/platform/wayland" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/sound" "git.stormux.org/storm/cthulhu/internal/tentacle" @@ -171,8 +173,16 @@ func Run(ctx context.Context, options Options) error { } if options.newCommands == nil { - options.newCommands = func() commandListener { - return xorg.NewCommands(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout)) + if session.Wayland() { + keyboard := wayland.New(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout), logger) + options.newCommands = func() commandListener { return keyboard } + if options.newInput == nil { + options.newInput = func() InputListener { return keyboard.Monitor() } + } + } else { + options.newCommands = func() commandListener { + return xorg.NewCommands(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout)) + } } } commands := options.newCommands() diff --git a/internal/app/runtime_platform.go b/internal/app/runtime_platform.go index 9fd0e10..d531af0 100644 --- a/internal/app/runtime_platform.go +++ b/internal/app/runtime_platform.go @@ -11,6 +11,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/lifecycle" "git.stormux.org/storm/cthulhu/internal/platform/session" + "git.stormux.org/storm/cthulhu/internal/platform/wayland" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) @@ -19,13 +20,28 @@ type desktopInputBackend interface { Stop(context.Context) error } -// X11 services remain usable through Xwayland when available. Their startup -// failure must not prevent AT-SPI, speech and braille from running on Wayland. +// Missing desktop keyboard capabilities must not prevent AT-SPI, speech and +// braille from running on Wayland. Explicit X11 backends keep their existing +// optional Xwayland startup behavior. // Other backends retain their own startup contract. The lifecycle runner still // owns cleanup, including a native listener whose startup failed. func desktopInputService(name string, backend desktopInputBackend, logger *slog.Logger) lifecycle.Service { service := lifecycle.Service{Name: name, Start: backend.Start, Stop: backend.Stop} switch backend.(type) { + case *wayland.Keyboard: + service.Start = func(ctx context.Context) error { + err := backend.Start(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + if err != nil { + logger.Warn("Wayland keyboard access unavailable; continuing with AT-SPI reading only", "error", err) + } + return nil + } case *xorg.Listener, *xorg.CommandListener: if session.Wayland() { service.Start = func(ctx context.Context) error { diff --git a/internal/app/wayland_native_test.go b/internal/app/wayland_native_test.go new file mode 100644 index 0000000..0834ec0 --- /dev/null +++ b/internal/app/wayland_native_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "log/slog" + "os" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "github.com/godbus/dbus/v5" +) + +type RuntimeKeyboardMonitor struct{ ready chan struct{} } +type RuntimeKeyGrab struct{ Symbol, Modifiers uint32 } + +func (*RuntimeKeyboardMonitor) WatchKeyboard() *dbus.Error { return nil } +func (monitor *RuntimeKeyboardMonitor) SetKeyGrabs([]uint32, []RuntimeKeyGrab) *dbus.Error { + select { + case monitor.ready <- struct{}{}: + default: + } + return nil +} + +func TestNativeWaylandRuntimeKeyboardPreferencesAndQuit(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { + t.Skip("requires private dbus-run-session") + } + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", "") + connection, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + const managerName = "org.freedesktop.a11y.Manager" + const managerPath = dbus.ObjectPath("/org/freedesktop/a11y/Manager") + const monitorInterface = "org.freedesktop.a11y.KeyboardMonitor" + monitor := &RuntimeKeyboardMonitor{ready: make(chan struct{}, 1)} + 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("keyboard manager: %v %v", result, err) + } + speaker := &fakeSpeaker{spoken: make(chan string, 32)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} + preferences := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + completed := make(chan error, 1) + go func() { + completed <- Run(ctx, Options{ + Logger: slog.New(slog.DiscardHandler), + newSpeaker: func() Speaker { return speaker }, newSound: func() soundService { return silentSound{} }, + newAdapter: func() Adapter { return adapter }, onPreferences: func() { preferences <- struct{}{} }, + }) + }() + select { + case <-monitor.ready: + case <-time.After(3 * time.Second): + t.Fatal("runtime did not select shared keyboard backend") + } + key := func(symbol uint32, code uint16, released bool) { + t.Helper() + if err := connection.Emit(managerPath, monitorInterface+".KeyEvent", released, uint32(0), symbol, uint32(0), code); err != nil { + t.Fatal(err) + } + } + key(0xff63, 118, false) + key(' ', 65, false) + key(' ', 65, true) + key(0xff63, 118, true) + select { + case <-preferences: + case err := <-completed: + t.Fatalf("runtime exited before preferences: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("Wayland preferences shortcut was not dispatched") + } + key(0xff63, 118, false) + key('q', 24, false) + key(0xff63, 118, true) + key('q', 24, true) + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("Wayland quit shortcut did not stop runtime") + } + if !adapter.stopped || !speaker.closed { + t.Fatal("Wayland runtime shutdown leaked services") + } +} diff --git a/internal/platform/wayland/keyboard.go b/internal/platform/wayland/keyboard.go new file mode 100644 index 0000000..64156c0 --- /dev/null +++ b/internal/platform/wayland/keyboard.go @@ -0,0 +1,243 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package wayland implements the shared accessibility KeyboardMonitor interface. +// It has no compositor names, configuration formats, or desktop-specific paths. +package wayland + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type keyboardConfiguration struct { + layout input.KeyboardLayout + modifiers []string + bindings []input.BindingOverride +} + +type keyboardUpdate struct { + ctx context.Context + configuration keyboardConfiguration + result chan error +} + +// Keyboard owns both monitoring and commands. Only its run goroutine touches +// the connection, pressed keys, and command router. Requested policy is atomic; +// binding changes are acknowledged after held keys release. Stop joins that +// owner and closes its private connection, releasing every compositor grab. +type Keyboard struct { + logger *slog.Logger + mutex sync.Mutex + configuration keyboardConfiguration + started bool + cancel context.CancelFunc + done chan struct{} + wake chan struct{} + updates chan keyboardUpdate + commands chan input.CommandEvent + locks chan input.LockState + failures chan error + events chan input.Event + secureMutex sync.Mutex + secure atomic.Bool + secureGeneration atomic.Uint64 + enabled atomic.Bool + browse atomic.Bool + nativeButton atomic.Bool + applied atomic.Bool + browseApplied atomic.Bool + active atomic.Bool + learn atomic.Bool + learnEcho atomic.Bool + overflow atomic.Uint64 + intent input.IntentTracker +} + +func New(modifiers []string, layout input.KeyboardLayout, logger *slog.Logger) *Keyboard { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + keyboard := &Keyboard{ + logger: logger, + configuration: keyboardConfiguration{layout: layout, modifiers: append([]string(nil), modifiers...)}, + wake: make(chan struct{}, 1), updates: make(chan keyboardUpdate), + commands: make(chan input.CommandEvent, 32), locks: make(chan input.LockState, 16), + failures: make(chan error, 1), events: make(chan input.Event, 64), + } + keyboard.enabled.Store(true) + return keyboard +} + +// Monitor shares input with the command owner; its lifecycle is owned by Keyboard. +type Monitor struct{ keyboard *Keyboard } + +func (keyboard *Keyboard) Monitor() *Monitor { return &Monitor{keyboard: keyboard} } +func (monitor *Monitor) Start(ctx context.Context) error { return ctx.Err() } +func (monitor *Monitor) Stop(context.Context) error { return nil } +func (monitor *Monitor) Events() <-chan input.Event { return monitor.keyboard.events } +func (monitor *Monitor) Overflow() uint64 { return monitor.keyboard.overflow.Load() } +func (monitor *Monitor) ModifierKeycodes() input.ModifierKeycodes { return input.ModifierKeycodes{} } +func (monitor *Monitor) RecentInput() input.Activity { return monitor.keyboard.intent.RecentInput() } +func (monitor *Monitor) RecentIntent() input.Intent { return monitor.keyboard.intent.RecentIntent() } +func (monitor *Monitor) SetSecureInputActive(active bool) { + monitor.keyboard.SetSecureInputActive(active) +} + +func (keyboard *Keyboard) Start(ctx context.Context) error { + keyboard.mutex.Lock() + defer keyboard.mutex.Unlock() + if keyboard.started { + return errors.New("Wayland keyboard already started") + } + if err := ctx.Err(); err != nil { + return err + } + if err := validate_configuration(keyboard.configuration); err != nil { + return err + } + keyboard.started = true + workerContext, cancel := context.WithCancel(ctx) + keyboard.cancel, keyboard.done = cancel, make(chan struct{}) + setup := make(chan error, 1) + go keyboard.run(workerContext, keyboard.configuration, setup) + return <-setup +} + +func (keyboard *Keyboard) Stop(ctx context.Context) error { + keyboard.mutex.Lock() + cancel, done := keyboard.cancel, keyboard.done + keyboard.mutex.Unlock() + if cancel == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (keyboard *Keyboard) Commands() <-chan input.CommandEvent { return keyboard.commands } +func (keyboard *Keyboard) LockStates() <-chan input.LockState { return keyboard.locks } +func (keyboard *Keyboard) Failures() <-chan error { return keyboard.failures } +func (keyboard *Keyboard) CommandGrabsEnabled() bool { + return keyboard.applied.Load() && keyboard.enabled.Load() && !keyboard.secure.Load() +} +func (keyboard *Keyboard) BrowseNavigationEnabled() bool { + return keyboard.CommandGrabsEnabled() && keyboard.browse.Load() && keyboard.browseApplied.Load() +} +func (keyboard *Keyboard) CommandGrabActive() bool { return keyboard.active.Load() } +func (keyboard *Keyboard) LearnModeActive() bool { return keyboard.learn.Load() } +func (keyboard *Keyboard) LearnModeEchoActive() bool { return keyboard.learnEcho.Load() } + +func (keyboard *Keyboard) notify() { + select { + case keyboard.wake <- struct{}{}: + default: + } +} +func (keyboard *Keyboard) SetCommandGrabs(enabled bool) { + keyboard.enabled.Store(enabled) + keyboard.notify() +} +func (keyboard *Keyboard) SetBrowseNavigation(enabled bool) { + keyboard.browse.Store(enabled) + keyboard.notify() +} +func (keyboard *Keyboard) SetNativeButtonFocus(focused bool) { + keyboard.nativeButton.Store(focused) + keyboard.notify() +} + +func (keyboard *Keyboard) SetSecureInputActive(active bool) { + keyboard.secureMutex.Lock() + defer keyboard.secureMutex.Unlock() + if keyboard.secure.Swap(active) != active { + keyboard.secureGeneration.Add(1) + } + keyboard.intent.Reset() + if active { + keyboard.browse.Store(false) + } + for { + select { + case <-keyboard.events: + case <-keyboard.commands: + case <-keyboard.locks: + default: + keyboard.notify() + return + } + } +} + +func validate_configuration(configuration keyboardConfiguration) error { + if configuration.layout != input.DesktopKeyboardLayout && configuration.layout != input.LaptopKeyboardLayout { + return fmt.Errorf("invalid keyboard layout %d", configuration.layout) + } + if _, err := modifier_symbols(configuration.modifiers); err != nil { + return err + } + return input.ValidateBindingOverrides(configuration.bindings) +} + +func (keyboard *Keyboard) SetKeyBindings(ctx context.Context, bindings []input.BindingOverride) error { + keyboard.mutex.Lock() + configuration := keyboard.configuration + keyboard.mutex.Unlock() + return keyboard.SetKeyboardConfiguration(ctx, configuration.layout, configuration.modifiers, bindings) +} + +func (keyboard *Keyboard) SetKeyboardConfiguration(ctx context.Context, layout input.KeyboardLayout, modifiers []string, bindings []input.BindingOverride) error { + configuration := keyboardConfiguration{layout: layout, modifiers: append([]string(nil), modifiers...), bindings: input.CloneBindingOverrides(bindings)} + if err := ctx.Err(); err != nil { + return err + } + if err := validate_configuration(configuration); err != nil { + return err + } + keyboard.mutex.Lock() + if !keyboard.started { + keyboard.configuration = configuration + keyboard.mutex.Unlock() + return nil + } + done := keyboard.done + keyboard.mutex.Unlock() + update := keyboardUpdate{ctx: ctx, configuration: configuration, result: make(chan error, 1)} + select { + case keyboard.updates <- update: + case <-done: + return errors.New("Wayland keyboard is unavailable") + case <-ctx.Done(): + return ctx.Err() + } + // The owner resolves cancellation before returning, so a failed preference + // update cannot silently commit after its caller has rolled back settings. + select { + case err := <-update.result: + return err + case <-done: + return errors.New("Wayland keyboard stopped") + } +} + +func (keyboard *Keyboard) fail(err error) { + select { + case keyboard.failures <- err: + default: + } +} + +const callTimeout = 750 * time.Millisecond diff --git a/internal/platform/wayland/protocol.go b/internal/platform/wayland/protocol.go new file mode 100644 index 0000000..da8aa70 --- /dev/null +++ b/internal/platform/wayland/protocol.go @@ -0,0 +1,299 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wayland + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/sessionbus" + "github.com/godbus/dbus/v5" +) + +const ( + managerName = "org.freedesktop.a11y.Manager" + managerPath = dbus.ObjectPath("/org/freedesktop/a11y/Manager") + monitorInterface = "org.freedesktop.a11y.KeyboardMonitor" +) + +// godbus's default handler can reorder signals after a channel fills. Keyboard +// events need a bounded FIFO: overflow terminates the backend and releases grabs. +type signalQueue struct { + keyboard *Keyboard + signals chan queuedSignal + overflow chan struct{} + once sync.Once +} + +type queuedSignal struct { + signal *dbus.Signal + generation uint64 +} + +func (queue *signalQueue) DeliverSignal(_ string, _ string, signal *dbus.Signal) { + if signal.Path != managerPath && signal.Name != "org.freedesktop.DBus.NameOwnerChanged" { + return + } + generation := queue.keyboard.secureGeneration.Load() + if signal.Name == monitorInterface+".KeyEvent" && queue.keyboard.secure.Load() { + return + } + select { + case queue.signals <- queuedSignal{signal: signal, generation: generation}: + default: + queue.once.Do(func() { close(queue.overflow) }) + } +} +func (*signalQueue) Terminate() {} + +type keyboardOwner struct { + keyboard *Keyboard + connection *dbus.Conn + owner string + configuration keyboardConfiguration + policy keyboardPolicy + bindings input.CommandBindings + router *input.CommandRouter + held map[uint16]uint32 + captured map[uint16]bool + learning bool + started time.Time + sequence uint32 + generation uint64 +} + +func (owner *keyboardOwner) call(ctx context.Context, method string, arguments ...any) error { + deadline, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + return owner.connection.Object(owner.owner, managerPath).CallWithContext(deadline, monitorInterface+"."+method, 0, arguments...).Err +} + +func (owner *keyboardOwner) requested_policy() keyboardPolicy { + keyboard := owner.keyboard + if !keyboard.enabled.Load() || keyboard.secure.Load() { + owner.learning = false + keyboard.learn.Store(false) + } + return keyboardPolicy{enabled: keyboard.enabled.Load(), browse: keyboard.browse.Load(), button: keyboard.nativeButton.Load(), secure: keyboard.secure.Load(), learn: owner.learning} +} + +func (owner *keyboardOwner) retire_secure_generation() { + generation := owner.keyboard.secureGeneration.Load() + if generation == owner.generation { + return + } + // A complete lock/unlock can occur before the owner wakes. Retire held + // keys even when the final secure boolean is unchanged. + owner.generation = generation + owner.router.Reset() + clear(owner.held) + clear(owner.captured) + owner.learning = false + owner.keyboard.learn.Store(false) + owner.keyboard.active.Store(false) +} + +func (owner *keyboardOwner) configure(ctx context.Context, configuration keyboardConfiguration, policy keyboardPolicy) error { + symbols, grabs, bindings := make_grabs(configuration, policy) + if err := owner.call(ctx, "SetKeyGrabs", symbols, grabs); err != nil { + return err + } + if policy.learn != owner.policy.learn { + method := "UngrabKeyboard" + if policy.learn { + method = "GrabKeyboard" + } + if err := owner.call(ctx, method); err != nil { + return err + } + } + owner.configuration, owner.policy, owner.bindings = configuration, policy, bindings + owner.router = input.NewCommandRouter(symbols, bindings) + owner.keyboard.applied.Store(policy.enabled && !policy.secure) + owner.keyboard.browseApplied.Store(policy.browse && policy.enabled && !policy.secure) + owner.keyboard.learnEcho.Store(policy.learn) + return nil +} + +func (keyboard *Keyboard) run(ctx context.Context, configuration keyboardConfiguration, setup chan<- error) { + defer close(keyboard.done) + defer keyboard.applied.Store(false) + defer keyboard.browseApplied.Store(false) + defer keyboard.active.Store(false) + defer keyboard.learn.Store(false) + defer keyboard.learnEcho.Store(false) + queue := &signalQueue{keyboard: keyboard, signals: make(chan queuedSignal, 256), overflow: make(chan struct{})} + connection, err := sessionbus.Connect(keyboard.logger, "Wayland keyboard", dbus.WithSignalHandler(queue)) + if err != nil { + setup <- err + return + } + defer connection.Close() // Also releases grabs if setup only partly succeeded. + owner := &keyboardOwner{keyboard: keyboard, connection: connection, configuration: configuration, + held: make(map[uint16]uint32), captured: make(map[uint16]bool), started: time.Now()} + deadline, cancel := context.WithTimeout(ctx, callTimeout) + err = connection.BusObject().CallWithContext(deadline, "org.freedesktop.DBus.GetNameOwner", 0, managerName).Store(&owner.owner) + cancel() + if err != nil { + setup <- fmt.Errorf("shared accessibility keyboard interface unavailable: %w", err) + return + } + if err := connection.AddMatchSignal(dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, managerName)); err != nil { + setup <- err + return + } + if err := connection.AddMatchSignal(dbus.WithMatchSender(owner.owner), dbus.WithMatchObjectPath(managerPath), dbus.WithMatchInterface(monitorInterface), dbus.WithMatchMember("KeyEvent")); err != nil { + setup <- err + return + } + // Some implementations authorize watches through the client's well-known + // accessibility name. Use a Cthulhu identity, never claim another reader's. + if result, err := connection.RequestName("org.stormux.Cthulhu.KeyboardMonitor", dbus.NameFlagDoNotQueue); err != nil || result != dbus.RequestNameReplyPrimaryOwner { + if err == nil { + err = errors.New("Cthulhu keyboard name is already owned") + } + setup <- err + return + } + policy := owner.requested_policy() + if !policy.secure { + if err := owner.call(ctx, "WatchKeyboard"); err != nil { + setup <- err + return + } + } + if err := owner.configure(ctx, configuration, policy); err != nil { + setup <- err + return + } + keyboard.logger.Info("using shared Wayland accessibility keyboard interface") + setup <- nil + if err := owner.loop(ctx, queue); err != nil && ctx.Err() == nil { + keyboard.fail(err) + } +} + +func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error { + keyboard := owner.keyboard + var pending *keyboardUpdate + defer func() { + if pending != nil { + pending.result <- errors.New("Wayland keyboard stopped") + } + }() + timer := time.NewTicker(20 * time.Millisecond) + defer timer.Stop() + var drainUntil time.Time + for { + stopping := ctx.Err() != nil + if stopping { + if len(owner.captured) == 0 { + return nil + } + if drainUntil.IsZero() { + drainUntil = time.Now().Add(callTimeout) + } + if time.Now().After(drainUntil) { + return nil + } + } + owner.retire_secure_generation() + policy := owner.requested_policy() + if policy.secure != owner.policy.secure { + owner.learning, policy.learn = false, false + keyboard.learn.Store(false) + clear(owner.held) + clear(owner.captured) + keyboard.active.Store(false) + method := "WatchKeyboard" + if policy.secure { + method = "UnwatchKeyboard" + } + if err := owner.call(ctx, method); err != nil { + return err + } + } + if !stopping && len(owner.held) == 0 { + if pending != nil { + update := pending + pending = nil + err := update.ctx.Err() + if err == nil { + err = owner.configure(update.ctx, update.configuration, policy) + } + if err != nil { + // SetKeyGrabs may have reached the compositor before a timeout. + // Restore the complete previous configuration before reporting failure. + if rollback := owner.configure(ctx, owner.configuration, policy); rollback != nil { + update.result <- errors.Join(err, rollback) + return rollback + } + } else { + keyboard.mutex.Lock() + keyboard.configuration = update.configuration + keyboard.mutex.Unlock() + } + update.result <- err + } else if policy != owner.policy { + if err := owner.configure(ctx, owner.configuration, policy); err != nil { + return err + } + } + } + updates := keyboard.updates + if pending != nil || stopping { + updates = nil + } + var canceled <-chan struct{} + if pending != nil { + canceled = pending.ctx.Done() + } + select { + case <-timer.C: + case <-keyboard.wake: + case <-canceled: + pending.result <- pending.ctx.Err() + pending = nil + case update := <-updates: + pending = &update + case <-queue.overflow: + return errors.New("Wayland keyboard signal queue overflow") + case <-owner.connection.Context().Done(): + return errors.New("Wayland keyboard connection closed") + case queued := <-queue.signals: + signal := queued.signal + if signal.Name == "org.freedesktop.DBus.NameOwnerChanged" && signal.Sender == "org.freedesktop.DBus" { + var name, previous, next string + if dbus.Store(signal.Body, &name, &previous, &next) == nil && name == managerName && previous == owner.owner && next != owner.owner { + return errors.New("Wayland accessibility keyboard service disappeared; restart Cthulhu after the service returns") + } + continue + } + if signal.Sender != owner.owner || signal.Path != managerPath || signal.Name != monitorInterface+".KeyEvent" { + continue + } + if queued.generation != keyboard.secureGeneration.Load() { + continue + } + var key keyEvent + if err := dbus.Store(signal.Body, &key.released, &key.state, &key.symbol, &key.character, &key.code); err != nil { + return fmt.Errorf("invalid accessibility key event: %w", err) + } + if stopping { + if key.released { + delete(owner.captured, key.code) + } + continue + } + if err := owner.handle_key(key); err != nil { + return err + } + } + } +} diff --git a/internal/platform/wayland/protocol_test.go b/internal/platform/wayland/protocol_test.go new file mode 100644 index 0000000..53e7253 --- /dev/null +++ b/internal/platform/wayland/protocol_test.go @@ -0,0 +1,408 @@ +// 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) + 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 }) +} diff --git a/internal/platform/wayland/routing.go b/internal/platform/wayland/routing.go new file mode 100644 index 0000000..101488c --- /dev/null +++ b/internal/platform/wayland/routing.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wayland + +/* +#cgo pkg-config: xkbcommon +#include +*/ +import "C" + +import ( + "errors" + "fmt" + "sort" + "time" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type keyGrab struct{ Symbol, Modifiers uint32 } +type keyboardPolicy struct{ enabled, browse, button, secure, learn bool } + +func modifier_symbols(names []string) ([]uint32, error) { + var symbols []uint32 + for _, name := range names { + switch name { + case "Insert": + symbols = append(symbols, 0xff63) + case "KP_Insert": + symbols = append(symbols, 0xff9e, 0xffb0) // NumLock alias. + case "Caps_Lock": + symbols = append(symbols, 0xffe5) + default: + return nil, fmt.Errorf("command modifier %q is unsupported; use Insert, KP_Insert, or Caps_Lock", name) + } + } + return symbols, nil +} + +func key_name(symbol uint32) string { + var buffer [128]C.char + length := C.xkb_keysym_get_name(C.xkb_keysym_t(symbol), &buffer[0], C.size_t(len(buffer))) + if length <= 0 || int(length) >= len(buffer) { + return "" + } + return C.GoString((*C.char)(unsafe.Pointer(&buffer[0]))) +} + +func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.xkb_keysym_t(symbol))) } + +// KeyboardMonitor sends keysyms rather than a keymap. Letter case is defined +// by XKB; punctuation's physical Shift pairing cannot be inferred from it. +func shifted_symbol(symbol uint32) uint32 { + return uint32(C.xkb_keysym_to_upper(C.xkb_keysym_t(symbol))) +} + +func normalize_symbol(symbol uint32) uint32 { + symbol = lower_symbol(symbol) + if symbol == 0xfe20 { + return 0xff09 + } // ISO_Left_Tab. + return symbol +} + +func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]uint32, []keyGrab, input.CommandBindings) { + bindings, _ := input.ResolveCommandBindings(configuration.layout, policy.browse, configuration.bindings) + if policy.button { + for chord, command := range bindings { + if command == input.DocumentActivate && !chord.CthulhuModifier { + delete(bindings, chord) + } + } + } + if !policy.enabled || policy.secure { + return []uint32{}, []keyGrab{}, bindings + } + symbols, _ := modifier_symbols(configuration.modifiers) + unique := make(map[keyGrab]bool) + for chord := range bindings { + if chord.CthulhuModifier { + continue + } + for _, locks := range []uint32{0, 2, 16, 18} { + mask := uint32(chord.Modifiers) | locks + unique[keyGrab{chord.Symbol, mask}] = true + // Implementations differ in whether a shifted/capitalized keysym is + // emitted; both forms resolve to the same semantic command. + if mask&3 != 0 { + unique[keyGrab{shifted_symbol(chord.Symbol), mask}] = true + } + } + } + grabs := make([]keyGrab, 0, len(unique)) + for grab := range unique { + grabs = append(grabs, grab) + } + sort.Slice(grabs, func(i, j int) bool { + if grabs[i].Symbol == grabs[j].Symbol { + return grabs[i].Modifiers < grabs[j].Modifiers + } + return grabs[i].Symbol < grabs[j].Symbol + }) + return symbols, grabs, bindings +} + +type keyEvent struct { + released bool + state, symbol, character uint32 + code uint16 +} + +func (owner *keyboardOwner) handle_key(key keyEvent) error { + keyboard := owner.keyboard + keyboard.secureMutex.Lock() + defer keyboard.secureMutex.Unlock() + if keyboard.secure.Load() || owner.policy.secure { + return nil + } + // A fresh signal can win the select before the policy wakeup. Reset at + // delivery as well so it cannot inherit a modifier from before locking. + owner.retire_secure_generation() + owner.sequence = max(owner.sequence+1, uint32(time.Since(owner.started).Milliseconds())+1) + timestamp := owner.sequence // One local clock shared by monitoring and commands. + symbol := normalize_symbol(key.symbol) + if key.symbol == 0xffb0 && owner.router.IsModifier(0xff9e) { + symbol = 0xff9e + } + previous, repeated := owner.held[key.code] + if previous != 0 { + symbol = previous + } // Releases may arrive after Shift/locks change. + if key.released { + delete(owner.held, key.code) + } else { + owner.held[key.code] = symbol + } + chord := owner.router.Chord(symbol, uint16(key.state)) + consumed, command := false, input.Command("") + if owner.policy.enabled && (key.released || key.state <= 0xffff) { + consumed, command = owner.router.Handle(symbol, key.code, uint16(key.state), !key.released) + } + if key.released { + delete(owner.captured, key.code) + } else if consumed || owner.learning { + owner.captured[key.code] = true + } + keyboard.active.Store(len(owner.captured) != 0 || owner.learning) + if !key.released && !repeated && owner.learning { + command = input.LearnKey + switch { + case symbol == 0xff1b || owner.bindings[chord] == input.ToggleLearnMode: + command = input.ToggleLearnMode + case symbol == 0xffbe && chord.Modifiers == 0 && !chord.CthulhuModifier: + command = input.LearnDocumentation + case symbol == 0xffbf && chord.Modifiers == 0 && !chord.CthulhuModifier: + command = input.LearnDefaultShortcuts + case symbol == 0xffc0 && chord.Modifiers == 0 && !chord.CthulhuModifier: + command = input.LearnApplicationShortcuts + } + if command != input.LearnKey { + owner.learning = false + keyboard.learn.Store(false) + } + } else if command == input.ToggleLearnMode { + owner.learning = true + keyboard.learn.Store(true) + keyboard.learnEcho.Store(true) + } + if command != "" && !repeated { + event := input.CommandEvent{Command: command, Timestamp: timestamp, Chord: chord, KeyName: key_name(symbol), LearnModeStarted: command == input.ToggleLearnMode && owner.learning} + select { + case keyboard.commands <- event: + default: + return errors.New("Wayland command queue overflow") + } + } + // Command presses must not race their own speech through the independent + // monitoring worker. Modifier observation still tracks normal input intent. + if !consumed && !owner.policy.learn && command == "" && key.code <= 255 { + event := input.NewKeyEvent(uint8(key.code), !key.released, key_name(key.symbol)) + event.Timestamp, event.Repeat = timestamp, !key.released && repeated + keyboard.intent.Observe(event) + select { + case keyboard.events <- event: + default: + keyboard.overflow.Add(1) + } + } else if !consumed && key.code > 255 { + keyboard.overflow.Add(1) + } + return nil +} diff --git a/internal/platform/wayland/routing_test.go b/internal/platform/wayland/routing_test.go new file mode 100644 index 0000000..b4a1728 --- /dev/null +++ b/internal/platform/wayland/routing_test.go @@ -0,0 +1,214 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wayland + +import ( + "context" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "github.com/godbus/dbus/v5" +) + +func test_owner(t *testing.T, layout input.KeyboardLayout, modifiers ...string) *keyboardOwner { + t.Helper() + keyboard := New(modifiers, layout, nil) + policy := keyboardPolicy{enabled: true} + symbols, _, bindings := make_grabs(keyboard.configuration, policy) + return &keyboardOwner{keyboard: keyboard, configuration: keyboard.configuration, policy: policy, + bindings: bindings, router: input.NewCommandRouter(symbols, bindings), held: make(map[uint16]uint32), captured: make(map[uint16]bool), started: time.Now()} +} + +func send_key(t *testing.T, owner *keyboardOwner, symbol uint32, code uint16, state uint32, released bool) { + t.Helper() + if err := owner.handle_key(keyEvent{symbol: symbol, code: code, state: state, released: released}); err != nil { + t.Fatal(err) + } +} + +func next_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 + default: + t.Fatalf("missing command %q", command) + } + return input.CommandEvent{} +} + +func TestReaderChordsDispatchOnceAndReleaseBothOrders(t *testing.T) { + for _, modifier := range []struct { + name string + symbol uint32 + }{{"Insert", 0xff63}, {"KP_Insert", 0xff9e}, {"KP_Insert", 0xffb0}, {"Caps_Lock", 0xffe5}} { + for _, modifierFirst := range []bool{false, true} { + owner := test_owner(t, input.DesktopKeyboardLayout, modifier.name) + send_key(t, owner, modifier.symbol, 118, 0, false) + send_key(t, owner, 's', 39, 0, false) + send_key(t, owner, 's', 39, 0, false) + event := next_command(t, owner.keyboard, input.CycleSpeechMode) + if !event.Chord.CthulhuModifier || event.Timestamp == 0 { + t.Fatal("missing chord identity") + } + if modifierFirst { + send_key(t, owner, modifier.symbol, 118, 0, true) + } + send_key(t, owner, 's', 39, 0, true) + if !modifierFirst { + send_key(t, owner, modifier.symbol, 118, 0, true) + } + if owner.keyboard.active.Load() || len(owner.held) != 0 || len(owner.keyboard.commands) != 0 { + t.Fatal("repeat or held state survived release") + } + send_key(t, owner, 's', 39, 0, false) + if len(owner.keyboard.commands) != 0 { + t.Fatal("plain typing became a command") + } + } + } +} + +func TestTypingIntentModifiersRepeatsAndHighKeycodes(t *testing.T) { + owner := test_owner(t, input.DesktopKeyboardLayout, "Insert") + send_key(t, owner, 0xffe3, 37, 0, false) + send_key(t, owner, 0xff53, 114, 4, false) + if owner.keyboard.intent.RecentIntent() != input.WordIntent { + t.Fatal("Control+Right lost editing intent") + } + send_key(t, owner, 0xff53, 114, 4, false) + for index := 0; index < 3; index++ { + event := <-owner.keyboard.events + if event.Repeat != (index == 2) { + t.Fatalf("repeat for event %d = %v", index, event.Repeat) + } + } + if len(owner.keyboard.commands) != 0 { + t.Fatal("typing was routed as a command") + } + send_key(t, owner, 'x', 293, 0, false) // Must never alias physical Control at 37. + if len(owner.keyboard.events) != 0 || owner.keyboard.overflow.Load() != 1 { + t.Fatal("wide keycode was truncated") + } +} + +func TestBrowseCaseLocksAndFocusedButton(t *testing.T) { + configuration := keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}} + for _, mask := range []uint32{0, 2, 16, 18} { + policy := keyboardPolicy{enabled: true, browse: true} + symbols, grabs, bindings := make_grabs(configuration, policy) + owner := test_owner(t, configuration.layout, "Insert") + owner.bindings, owner.policy, owner.router = bindings, policy, input.NewCommandRouter(symbols, bindings) + send_key(t, owner, 'H', 43, mask|1, false) + next_command(t, owner.keyboard, input.DocumentPreviousHeading) + send_key(t, owner, 'h', 43, mask, true) + if len(owner.held) != 0 { + t.Fatal("changed release symbol left a key held") + } + found := false + for _, grab := range grabs { + if grab == (keyGrab{'H', mask | 1}) { + found = true + } + } + if !found { + t.Fatal("shifted heading grab missing") + } + } + _, grabs, bindings := make_grabs(configuration, keyboardPolicy{enabled: true, browse: true, button: true}) + if bindings[input.CommandChord{Symbol: 0xff0d}] != "" { + t.Fatal("native Return still activates document") + } + for _, grab := range grabs { + if grab.Symbol == 0xff0d { + t.Fatal("native button Return remains grabbed") + } + } + modifiers, grabs, _ := make_grabs(configuration, keyboardPolicy{secure: true, enabled: true, browse: true}) + if len(modifiers) != 0 || len(grabs) != 0 { + t.Fatal("secure input retains grabs") + } +} + +func TestLearnModeAndSecureReset(t *testing.T) { + owner := test_owner(t, input.LaptopKeyboardLayout, "Insert") + send_key(t, owner, 0xff63, 118, 0, false) + send_key(t, owner, 'h', 43, 0, false) + if !next_command(t, owner.keyboard, input.ToggleLearnMode).LearnModeStarted { + t.Fatal("learn mode did not begin") + } + send_key(t, owner, 'h', 43, 0, true) + send_key(t, owner, 0xff63, 118, 0, true) + owner.policy.learn = true // The protocol owner installs its full grab after release. + send_key(t, owner, 'q', 24, 0, false) + next_command(t, owner.keyboard, input.LearnKey) + send_key(t, owner, 'q', 24, 0, true) + send_key(t, owner, 0xff1b, 9, 0, false) + if next_command(t, owner.keyboard, input.ToggleLearnMode).LearnModeStarted { + t.Fatal("Escape restarted learn mode") + } + if owner.learning { + t.Fatal("Escape left learn mode active") + } + owner.keyboard.SetSecureInputActive(true) + send_key(t, owner, 'p', 33, 0, false) + if len(owner.keyboard.events) != 0 || len(owner.keyboard.commands) != 0 { + t.Fatal("secure input leaked output") + } +} + +func TestSignalQueueBoundedOrderedAndSecureGeneration(t *testing.T) { + keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil) + queue := &signalQueue{keyboard: keyboard, signals: make(chan queuedSignal, 2), overflow: make(chan struct{})} + for index := 0; index < 3; index++ { + queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent", Body: []any{uint32(index)}}) + } + select { + case <-queue.overflow: + default: + t.Fatal("overflow did not fail closed") + } + for index := 0; index < 2; index++ { + if (<-queue.signals).signal.Body[0] != uint32(index) { + t.Fatal("signal order changed") + } + } + keyboard.SetSecureInputActive(true) + queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent"}) + if len(queue.signals) != 0 { + t.Fatal("secure key queued") + } + keyboard.SetSecureInputActive(false) + queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent"}) + if (<-queue.signals).generation != 2 { + t.Fatal("secure generation not advanced") + } +} + +func TestKeyboardConfigurationValidation(t *testing.T) { + keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil) + if err := keyboard.SetKeyboardConfiguration(context.Background(), input.LaptopKeyboardLayout, []string{"Caps_Lock"}, nil); err != nil { + t.Fatal(err) + } + if err := keyboard.SetKeyboardConfiguration(context.Background(), input.DesktopKeyboardLayout, []string{"Super_L"}, nil); err == nil { + t.Fatal("unsupported modifier accepted") + } + if keyboard.configuration.layout != input.LaptopKeyboardLayout { + t.Fatal("failed update changed configuration") + } +} + +func TestUnknownModifierBitsDoNotDispatchTruncatedChord(t *testing.T) { + owner := test_owner(t, input.DesktopKeyboardLayout, "Insert") + send_key(t, owner, 0xff63, 118, 0, false) + send_key(t, owner, 'q', 24, 1<<20, false) + if len(owner.keyboard.commands) != 0 { + t.Fatal("wide modifier mask became Cthulhu+Q") + } +} diff --git a/internal/platform/xorg/xterm_handoff_native_test.go b/internal/platform/xorg/xterm_handoff_native_test.go index 28dfecf..0988b48 100644 --- a/internal/platform/xorg/xterm_handoff_native_test.go +++ b/internal/platform/xorg/xterm_handoff_native_test.go @@ -20,7 +20,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() probe := filepath.Join(t.TempDir(), "suspension-probe") - if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lpthread").CombinedOutput(); err != nil { + if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { t.Fatalf("compile native probe: %v: %s", err, out) } for _, mode := range []string{"queued", "replay", "passive"} { diff --git a/internal/sessionbus/sessionbus.go b/internal/sessionbus/sessionbus.go index f8c4ae9..ae7ee8c 100644 --- a/internal/sessionbus/sessionbus.go +++ b/internal/sessionbus/sessionbus.go @@ -38,12 +38,12 @@ func sessionAddress() (string, error) { // Connect uses GLib's session address discovery and godbus's private connection. // Bus IDs let debug logs identify mismatched buses without exposing addresses. -func Connect(logger *slog.Logger, component string) (*dbus.Conn, error) { +func Connect(logger *slog.Logger, component string, options ...dbus.ConnOption) (*dbus.Conn, error) { address, err := sessionAddress() if err != nil { return nil, err } - connection, err := dbus.Connect(address) + connection, err := dbus.Connect(address, options...) if err != nil { return nil, err } diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 062680e..697d614 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -56,3 +56,4 @@ web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObject steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic headers full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md. steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md. +wayland-shared-keyboard-access,src/cthulhu/input_event_manager.py,input,Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface,Storm Dragon,internal/platform/wayland internal/app internal/sessionbus,in-progress,Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py,Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established.,Shared D-Bus fixture passed without an X display. Live niri and physical key suppression remain unverified.,Pending live listening on Wayland,Pending physical Braille on Wayland,No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency.