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