Add command bypass and internal UI sessions to Wayland keyboard backend

This commit is contained in:
hjozwiak committed 2026-11-11 08:12:04 -05:00
1 parent bebde8fd5e
commit fc25f7e7e8
9 files changed
+426 -25

No files matched your search

+17 -1
View File
@@ -43,6 +43,7 @@ type Keyboard struct {
done chan struct{}
wake chan struct{}
updates chan keyboardUpdate
uiUpdates chan uiUpdate
commands chan input.CommandEvent
locks chan input.LockState
failures chan error
@@ -52,12 +53,17 @@ type Keyboard struct {
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
uiSession atomic.Uint64
uiSequence atomic.Uint64
uiClose atomic.Uint64
overflow atomic.Uint64
intent input.IntentTracker
}
@@ -69,7 +75,7 @@ func New(modifiers []string, layout input.KeyboardLayout, logger *slog.Logger) *
keyboard := &Keyboard{
logger: logger,
configuration: keyboardConfiguration{layout: layout, modifiers: append([]string(nil), modifiers...)},
wake: make(chan struct{}, 1), updates: make(chan keyboardUpdate),
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),
}
@@ -155,6 +161,16 @@ 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()
+68 -6
View File
@@ -56,6 +56,7 @@ type keyboardOwner struct {
keyboard *Keyboard
connection *dbus.Conn
owner string
ctx context.Context
configuration keyboardConfiguration
policy keyboardPolicy
bindings input.CommandBindings
@@ -63,6 +64,7 @@ type keyboardOwner struct {
held map[uint16]uint32
captured map[uint16]bool
learning bool
uiSession uint64
started time.Time
sequence uint32
generation uint64
@@ -80,7 +82,7 @@ func (owner *keyboardOwner) requested_policy() keyboardPolicy {
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}
return keyboardPolicy{enabled: keyboard.enabled.Load(), browse: keyboard.browse.Load(), button: keyboard.nativeButton.Load(), secure: keyboard.secure.Load(), learn: owner.learning, bypass: keyboard.bypass.Load(), ui: owner.uiSession != 0}
}
func (owner *keyboardOwner) retire_secure_generation() {
@@ -96,6 +98,14 @@ func (owner *keyboardOwner) retire_secure_generation() {
clear(owner.captured)
owner.learning = false
owner.keyboard.learn.Store(false)
if owner.uiSession != 0 {
session := owner.uiSession
owner.dismiss_internal_ui()
select {
case owner.keyboard.commands <- input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}:
default:
}
}
owner.keyboard.active.Store(false)
}
@@ -104,9 +114,10 @@ func (owner *keyboardOwner) configure(ctx context.Context, configuration keyboar
if err := owner.call(ctx, "SetKeyGrabs", symbols, grabs); err != nil {
return err
}
if policy.learn != owner.policy.learn {
grabKeyboard := policy.learn || policy.ui
if grabKeyboard != (owner.policy.learn || owner.policy.ui) {
method := "UngrabKeyboard"
if policy.learn {
if grabKeyboard {
method = "GrabKeyboard"
}
if err := owner.call(ctx, method); err != nil {
@@ -116,7 +127,8 @@ func (owner *keyboardOwner) configure(ctx context.Context, configuration keyboar
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.browseApplied.Store(policy.browse && !policy.bypass && policy.enabled && !policy.secure)
owner.keyboard.bypassApplied.Store(policy.bypass && policy.enabled && !policy.secure)
owner.keyboard.learnEcho.Store(policy.learn)
return nil
}
@@ -125,6 +137,8 @@ func (keyboard *Keyboard) run(ctx context.Context, configuration keyboardConfigu
defer close(keyboard.done)
defer keyboard.applied.Store(false)
defer keyboard.browseApplied.Store(false)
defer keyboard.bypassApplied.Store(false)
defer keyboard.uiSession.Store(0)
defer keyboard.active.Store(false)
defer keyboard.learn.Store(false)
defer keyboard.learnEcho.Store(false)
@@ -180,12 +194,17 @@ func (keyboard *Keyboard) run(ctx context.Context, configuration keyboardConfigu
}
func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error {
owner.ctx = ctx
keyboard := owner.keyboard
var pending *keyboardUpdate
var pendingUI *uiUpdate
defer func() {
if pending != nil {
pending.result <- errors.New("Wayland keyboard stopped")
}
if pendingUI != nil {
pendingUI.result <- uiReply{err: errors.New("Wayland keyboard stopped")}
}
}()
timer := time.NewTicker(20 * time.Millisecond)
defer timer.Stop()
@@ -219,8 +238,38 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error
return err
}
}
// A close request revokes the session even if its acknowledgement
// times out waiting for held keys, mirroring the Xorg owner. The
// pending update (if any) then just confirms the ungrab.
if owner.uiSession != 0 && keyboard.uiClose.Load() >= owner.uiSession {
owner.dismiss_internal_ui()
if err := owner.configure(ctx, owner.configuration, owner.requested_policy()); err != nil {
return err
}
policy = owner.requested_policy()
}
if !stopping && len(owner.held) == 0 {
if pending != nil {
if pendingUI != nil {
update := pendingUI
pendingUI = nil
reply := uiReply{err: update.ctx.Err()}
if reply.err == nil {
if update.closeSession != 0 {
if update.closeSession == owner.uiSession {
owner.dismiss_internal_ui()
}
if owner.policy.ui {
reply.err = owner.configure(update.ctx, owner.configuration, owner.requested_policy())
}
} else {
reply.session, reply.err = owner.begin_internal_ui(update.ctx)
}
}
update.result <- reply
if reply.err != nil && update.closeSession != 0 {
return reply.err
}
} else if pending != nil {
update := pending
pending = nil
err := update.ctx.Err()
@@ -240,7 +289,7 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error
keyboard.mutex.Unlock()
}
update.result <- err
} else if policy != owner.policy {
} else if policy != owner.policy && (policy.bypass == owner.policy.bypass || len(owner.held) == 0) {
if err := owner.configure(ctx, owner.configuration, policy); err != nil {
return err
}
@@ -250,18 +299,31 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error
if pending != nil || stopping {
updates = nil
}
uiUpdates := keyboard.uiUpdates
if pendingUI != nil || stopping {
uiUpdates = nil
}
var canceled <-chan struct{}
if pending != nil {
canceled = pending.ctx.Done()
}
var canceledUI <-chan struct{}
if pendingUI != nil {
canceledUI = pendingUI.ctx.Done()
}
select {
case <-timer.C:
case <-keyboard.wake:
case <-canceled:
pending.result <- pending.ctx.Err()
pending = nil
case <-canceledUI:
pendingUI.result <- uiReply{err: pendingUI.ctx.Err()}
pendingUI = nil
case update := <-updates:
pending = &update
case update := <-uiUpdates:
pendingUI = &update
case <-queue.overflow:
return errors.New("Wayland keyboard signal queue overflow")
case <-owner.connection.Context().Done():
@@ -411,3 +411,51 @@ func TestNativeKeyboardStartsSecureWithoutMonitoringOrGrabs(t *testing.T) {
keyboard.SetSecureInputActive(false)
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.watching && len(state.modifiers) == 1 })
}
func TestNativeBypassAndInternalUIGrabLifecycle(t *testing.T) {
monitor := start_monitor(t)
keyboard := start_keyboard(t, monitor)
keyboard.SetBrowseNavigation(true)
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) != 0 })
keyboard.SetCommandBypass(true)
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) == 0 })
deadline := time.Now().Add(3 * time.Second)
for !keyboard.CommandBypassEnabled() {
if time.Now().After(deadline) {
t.Fatal("bypass was not applied")
}
time.Sleep(time.Millisecond)
}
// The retained toggle chord still dispatches through the modifier watch.
monitor.key(t, 0xff63, 118, 0, false)
monitor.key(t, 'z', 52, 0, false)
await_command(t, keyboard, input.ToggleCommandBypass)
monitor.key(t, 'z', 52, 0, true)
monitor.key(t, 0xff63, 118, 0, true)
keyboard.SetCommandBypass(false)
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) != 0 })
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
session, err := keyboard.BeginInternalUI(ctx)
if err != nil || session == 0 {
t.Fatalf("begin internal UI: session %d, %v", session, err)
}
if !keyboard.InternalUIActive() || keyboard.InternalUISession() != session {
t.Fatal("internal UI session not published")
}
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.grabbed })
monitor.key(t, 'a', 38, 0, false)
event := await_command(t, keyboard, input.InternalUIKey)
if event.UISession != session || event.Text != "a" {
t.Fatalf("internal UI key = %+v", event)
}
monitor.key(t, 'a', 38, 0, true)
if err := keyboard.EndInternalUI(ctx, session); err != nil {
t.Fatal(err)
}
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return !state.grabbed })
if keyboard.InternalUIActive() || keyboard.InternalUISession() != 0 {
t.Fatal("internal UI session survived end")
}
}
+23 -4
View File
@@ -23,7 +23,7 @@ import (
)
type keyGrab struct{ Symbol, Modifiers uint32 }
type keyboardPolicy struct{ enabled, browse, button, secure, learn bool }
type keyboardPolicy struct{ enabled, browse, button, secure, learn, bypass, ui bool }
func modifier_symbols(names []string) ([]uint32, error) {
var symbols []uint32
@@ -53,6 +53,10 @@ func key_name(symbol uint32) string {
func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.xkb_keysym_t(symbol))) }
func symbol_utf32(symbol uint32) uint32 {
return uint32(C.xkb_keysym_to_utf32(C.xkb_keysym_t(symbol)))
}
// KeyboardMonitor sends keysyms rather than a keymap, and the symbol for a
// shifted key is the shifted keysym: Shift+3 arrives as numbersign on a US
// layout. Letter case is defined by XKB alone, but a digit or punctuation
@@ -165,6 +169,9 @@ func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]u
}
}
}
if policy.bypass {
bindings = input.BypassCommandBindings(bindings)
}
if !policy.enabled || policy.secure {
return []uint32{}, []keyGrab{}, bindings
}
@@ -250,10 +257,13 @@ func (owner *keyboardOwner) handle_key(key keyEvent) error {
}
if key.released {
delete(owner.captured, key.code)
} else if consumed || owner.learning {
} else if consumed || owner.learning || owner.uiSession != 0 {
owner.captured[key.code] = true
}
keyboard.active.Store(len(owner.captured) != 0 || owner.learning)
keyboard.active.Store(len(owner.captured) != 0 || owner.learning || owner.uiSession != 0)
if owner.uiSession != 0 {
return owner.handle_ui_key(key, symbol, chord, repeated, timestamp)
}
if !key.released && !repeated && owner.learning {
command = input.LearnKey
switch {
@@ -276,7 +286,16 @@ func (owner *keyboardOwner) handle_key(key keyEvent) error {
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}
uiSession := uint64(0)
if command == input.ShowActions && !key.released {
// Capture exclusively before reporting the command, as on Xorg.
// An unavailable modal grab is a failed command, not a failed
// reader: session zero makes dispatch announce unavailability.
if session, err := owner.begin_internal_ui(owner.ctx); err == nil {
uiSession = session
}
}
event := input.CommandEvent{Command: command, UISession: uiSession, Timestamp: timestamp, Chord: chord, KeyName: key_name(symbol), LearnModeStarted: command == input.ToggleLearnMode && owner.learning}
select {
case keyboard.commands <- event:
default:
+67
View File
@@ -317,3 +317,70 @@ func TestUnknownModifierBitsDoNotDispatchTruncatedChord(t *testing.T) {
t.Fatal("wide modifier mask became Cthulhu+Q")
}
}
func TestCommandBypassDropsGrabsButKeepsToggle(t *testing.T) {
keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil)
policy := keyboardPolicy{enabled: true, browse: true, bypass: true}
symbols, grabs, bindings := make_grabs(keyboard.configuration, policy)
if len(grabs) != 0 {
t.Fatalf("bypass retained key grabs: %v", grabs)
}
owner := &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()}
// Browse keys are no longer grabbed, so the compositor would deliver
// them to the application; the watched event still reports monitoring.
send_key(t, owner, 'h', 43, 0, false)
send_key(t, owner, 'h', 43, 0, true)
if len(keyboard.commands) != 0 {
t.Fatal("browse key dispatched a command during bypass")
}
if len(keyboard.events) == 0 {
t.Fatal("bypass lost watched typing for echo")
}
// Reader chords other than the retained toggle dispatch nothing.
send_key(t, owner, 0xff63, 118, 0, false)
send_key(t, owner, 't', 20, 0, false)
send_key(t, owner, 't', 20, 0, true)
send_key(t, owner, 0xff63, 118, 0, true)
if len(keyboard.commands) != 0 {
t.Fatal("reader chord dispatched during bypass")
}
send_key(t, owner, 0xff63, 118, 0, false)
send_key(t, owner, 'z', 52, 0, false)
next_command(t, keyboard, input.ToggleCommandBypass)
}
func TestInternalUIKeyRoutingAndEscapeDismiss(t *testing.T) {
owner := test_owner(t, input.DesktopKeyboardLayout, "Insert")
owner.uiSession = 7
owner.keyboard.uiSession.Store(7)
send_key(t, owner, 'a', 38, 0, false)
event := next_command(t, owner.keyboard, input.InternalUIKey)
if event.UISession != 7 || event.Text != "a" {
t.Fatalf("UI text event = %+v", event)
}
send_key(t, owner, 'a', 38, 0, true)
send_key(t, owner, 0xff09, 23, 0, false)
event = next_command(t, owner.keyboard, input.InternalUIKey)
if event.Chord.Symbol != 0xff09 || event.Text != "" {
t.Fatalf("UI tab event = %+v", event)
}
send_key(t, owner, 0xff09, 23, 0, true)
// Reader chords never become field contents.
send_key(t, owner, 0xff63, 118, 0, false)
send_key(t, owner, 'x', 53, 0, false)
send_key(t, owner, 'x', 53, 0, true)
send_key(t, owner, 0xff63, 118, 0, true)
if len(owner.keyboard.commands) != 0 {
t.Fatal("reader chord reached internal UI")
}
send_key(t, owner, 0xff1b, 9, 0, false)
event = next_command(t, owner.keyboard, input.InternalUIKey)
if event.UISession != 7 || event.Chord.Symbol != 0xff1b {
t.Fatalf("UI escape event = %+v", event)
}
if owner.uiSession != 0 || owner.keyboard.InternalUIActive() {
t.Fatal("Escape did not dismiss the UI session")
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package wayland
import (
"context"
"errors"
"git.stormux.org/storm/cthulhu/internal/input"
)
type uiReply struct {
session uint64
err error
}
type uiUpdate struct {
ctx context.Context
closeSession uint64
result chan uiReply
}
func (keyboard *Keyboard) InternalUISession() uint64 { return keyboard.uiSession.Load() }
func (keyboard *Keyboard) InternalUIActive() bool { return keyboard.uiSession.Load() != 0 }
// InternalUIEchoActive follows the session itself; the owner retains the
// compositor grab until the session's last held key is released and the
// ungrab is applied, so echo suppression never outlives key capture by
// more than one policy cycle.
func (keyboard *Keyboard) InternalUIEchoActive() bool { return keyboard.uiSession.Load() != 0 }
// BeginInternalUI acknowledges exclusive capture through the shared
// interface's GrabKeyboard. EndInternalUI waits for physical releases,
// and cannot close a newer UI. There is deliberately no BeginLocalUI:
// the protocol cannot distinguish device-backed from synthesized keys,
// so local approval input stays unavailable on this backend.
func (keyboard *Keyboard) BeginInternalUI(ctx context.Context) (uint64, error) {
return keyboard.update_internal_ui(ctx, 0)
}
func (keyboard *Keyboard) EndInternalUI(ctx context.Context, session uint64) error {
if session == 0 {
return errors.New("missing internal UI session")
}
// Revoke promptly even when the acknowledgement must wait for a held key.
if session > keyboard.uiSequence.Load() {
return errors.New("unknown internal UI session")
}
for previous := keyboard.uiClose.Load(); previous < session; previous = keyboard.uiClose.Load() {
if keyboard.uiClose.CompareAndSwap(previous, session) {
break
}
}
_, err := keyboard.update_internal_ui(ctx, session)
return err
}
func (keyboard *Keyboard) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) {
keyboard.mutex.Lock()
done := keyboard.done
keyboard.mutex.Unlock()
if done == nil {
return 0, errors.New("Wayland keyboard is not running")
}
update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)}
select {
case keyboard.uiUpdates <- update:
case <-ctx.Done():
return 0, ctx.Err()
case <-done:
return 0, errors.New("Wayland keyboard stopped")
}
select {
case result := <-update.result:
return result.session, result.err
case <-ctx.Done():
return 0, ctx.Err()
case <-done:
return 0, errors.New("Wayland keyboard stopped")
}
}
// begin_internal_ui runs on the owner goroutine. The compositor grab is
// applied before the session is acknowledged, mirroring the Xorg owner.
func (owner *keyboardOwner) begin_internal_ui(ctx context.Context) (uint64, error) {
if !owner.policy.enabled || owner.policy.secure || owner.learning || owner.uiSession != 0 {
return 0, errors.New("internal UI input unavailable")
}
policy := owner.policy
policy.ui = true
if err := owner.configure(ctx, owner.configuration, policy); err != nil {
return 0, err
}
owner.uiSession = owner.keyboard.uiSequence.Add(1)
owner.keyboard.uiSession.Store(owner.uiSession)
return owner.uiSession, nil
}
// dismiss_internal_ui only clears session identity. The owner loop's
// next policy sync releases the compositor grab, mirroring how secure
// generations retire held state outside handle_key.
func (owner *keyboardOwner) dismiss_internal_ui() {
owner.uiSession = 0
owner.keyboard.uiSession.Store(0)
}
// handle_ui_key consumes one key for the active session. Reader modifiers
// and their chords never become field contents; Escape reports the key
// and dismisses the session, matching the Xorg owner.
func (owner *keyboardOwner) handle_ui_key(key keyEvent, symbol uint32, chord input.CommandChord, repeated bool, timestamp uint32) error {
if key.released || repeated {
return nil
}
if owner.router.IsModifier(key.symbol) {
return nil
}
if chord.CthulhuModifier && symbol != 0xff1b {
return nil
}
event := input.CommandEvent{
Command: input.InternalUIKey,
UISession: owner.uiSession,
Timestamp: timestamp,
Chord: chord,
Text: key_text(key, symbol),
}
if symbol == 0xff1b {
owner.dismiss_internal_ui()
}
select {
case owner.keyboard.commands <- event:
return nil
default:
return errors.New("Wayland command queue overflow")
}
}
// key_text prefers the character reported by the shared interface and
// falls back to the keysym's Unicode mapping. Control characters never
// become field text.
func key_text(key keyEvent, symbol uint32) string {
for _, candidate := range []uint32{key.character, symbol_utf32(symbol), symbol_utf32(key.symbol)} {
if candidate >= 0x20 && candidate != 0x7f && candidate <= 0x10ffff {
return string(rune(candidate))
}
}
return ""
}