Files

244 lines
7.9 KiB
Go

// 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