300 lines
9.6 KiB
Go
300 lines
9.6 KiB
Go
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|