// 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 uiUpdates chan uiUpdate 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 bypass atomic.Bool nativeButton atomic.Bool applied atomic.Bool browseApplied atomic.Bool bypassApplied atomic.Bool active atomic.Bool learn atomic.Bool learnEcho atomic.Bool uiEcho atomic.Bool uiSession atomic.Uint64 uiSequence atomic.Uint64 uiClose atomic.Uint64 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), uiUpdates: make(chan uiUpdate), 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() } // SetCommandBypass drops every key grab except the bypass shortcut after // held keys release. Watched keys still report monitoring and echo; the // shared interface has no key synthesis, so reader-modifier chords other // than the retained shortcut are swallowed rather than replayed. func (keyboard *Keyboard) SetCommandBypass(enabled bool) { keyboard.bypass.Store(enabled) keyboard.notify() } func (keyboard *Keyboard) CommandBypassEnabled() bool { return keyboard.bypassApplied.Load() } 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