Possibly make things a little better for wayland users.

This commit is contained in:
Storm Dragon committed 2026-10-15 17:29:41 -04:00
1 parent 70e8e14231
commit 3e0bdc578f
12 files changed
+1495 -7

No files matched your search

+1
View File
@@ -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).
+12 -2
View File
@@ -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()
+18 -2
View File
@@ -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 {
+101
View File
@@ -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")
}
}
+243
View File
@@ -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
+299
View File
@@ -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
}
}
}
}
+408
View File
@@ -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 })
}
+195
View File
@@ -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 <xkbcommon/xkbcommon.h>
*/
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
}
+214
View File
@@ -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")
}
}
@@ -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"} {
+2 -2
View File
@@ -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
}
+1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
56 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
57 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.
58 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.
59 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.