Possibly make things a little better for wayland users.
This commit is contained in:
1 parent
70e8e14231
commit
3e0bdc578f
12 files changed
+1495
-7
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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.
|
||||
|
Reference in new issue
Block a user