Reset master to testing content

This commit is contained in:
Storm Dragon committed 2026-10-25 01:57:54 -04:00
1 parent 0f2587b719
commit 42afa2a56c
1483 files changed
+145308 -938069

No files matched your search

+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")
}
}