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

+12 -7
View File
@@ -185,17 +185,22 @@ switch back to browse mode when finished interacting.
## Pass command keys to an application
On Xorg, **Cthulhu+Z** toggles command bypass in either keyboard layout.
Cthulhu announces and displays **Cthulhu command keys off.** While bypass is
active, application shortcuts receive keys normally used for Cthulhu commands,
including the configured Cthulhu modifier keys. Automatic speech, key echo,
and Braille continue under their existing settings. Cthulhu+Z remains available
to restore commands and announces **Cthulhu command keys on.**
**Cthulhu+Z** toggles command bypass in either keyboard layout, on Xorg
and on Wayland sessions providing the shared accessibility keyboard
interface. Cthulhu announces and displays **Cthulhu command keys off.**
While bypass is active, application shortcuts receive keys normally used
for Cthulhu commands, including the configured Cthulhu modifier keys.
Automatic speech, key echo, and Braille continue under their existing
settings. Cthulhu+Z remains available to restore commands and announces
**Cthulhu command keys on.**
Switching applications or locking the screen also restores commands. Moving
between windows in the same application keeps bypass active. Bypass is temporary
and does not change your saved preferences or browse/focus mode. Existing XTerm
handoff still takes precedence. Wayland command bypass is not yet supported.
handoff still takes precedence. On Wayland, reader-modifier chords other than
Cthulhu+Z are swallowed rather than replayed to the application because the
shared interface cannot synthesize keys; unmodified and browse keys pass
through normally.
See [command bypass verification](docs/testing/command-bypass.md).
+13
View File
@@ -53,3 +53,16 @@ buttons, Tab/Shift+Tab, and reading through a continuously updating page.
The user's installed Brave session, physical input, audible speech, and physical
Braille remain live acceptance checks. Human diff review and live testing
precede submission.
## Known follow-up: browse anchoring across Electron panels
Observed 2026-11-11 in an Electron game with separate code and upgrades
panels: browse-mode button and form-field navigation starting from the
code panel reported no targets although the upgrades panel contains real
buttons, while native Tab reached those buttons immediately. After Tab
moved native focus into the upgrades panel, browse navigation found the
buttons as well. The browse cursor appears anchored to the document that
owned focus when browsing began and does not cross into the sibling panel
document on its own. Reproduce with a multi-panel Electron document whose
panels capture as separate documents, then check whether structural and
caret navigation should cross that boundary or re-anchor explicitly.
+28 -7
View File
@@ -1,7 +1,8 @@
# Command bypass
Cthulhu+Z temporarily passes reader command keys to the current application on
Xorg. It retains the bypass shortcut and keeps speech and Braille running.
Cthulhu+Z temporarily passes reader command keys to the current application
on Xorg and on the Wayland shared accessibility keyboard interface. It
retains the bypass shortcut and keeps speech and Braille running.
Switching applications, leaving the foreground client, or locking restores
commands. Same-process window changes and browser content changes preserve
bypass. Accessible application identity is the fallback when native process
@@ -11,18 +12,30 @@ The shared focus gate owns bypass state; command dispatch changes it and the
accessibility event loop observes foreground/application departure. The X11
command owner applies requested routing and grabs after held keys release.
Unbound modifier chords use existing replay, with Caps Lock's native action
included while bypass is active. OCR review ends on entry. Secure input and
XTerm handoff retain their existing precedence. Reader shutdown discards state.
Wayland reports bypass unavailable; its current backend has no replay path.
included while bypass is active. The Wayland owner instead re-registers an
empty grab set (retaining only the reader-modifier watch that carries the
toggle) after held keys release, so unmodified and browse keys reach the
application while watched keys still report monitoring and echo. The shared
interface cannot synthesize keys, so on Wayland reader-modifier chords
other than the retained shortcut are swallowed rather than replayed. OCR
review ends on entry. Secure input and XTerm handoff retain their existing
precedence. Reader shutdown discards state.
The Wayland backend also implements internal UI sessions through the
interface's GrabKeyboard, so the Actions list, browser lists, and the emoji
menu work there. ShowActions captures its session at the chord, Escape
reports and dismisses, and secure-input transitions revoke the session.
Local-only approval input remains unavailable on Wayland because the
protocol cannot distinguish device-backed from synthesized keys.
## Automated checks
Run affected Go packages and build the native listener test executable:
```sh
go test ./internal/input ./internal/app ./internal/platform/xorg -count=1
go test ./internal/input ./internal/app ./internal/platform/xorg ./internal/platform/wayland -count=1
dbus-run-session -- env CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/platform/wayland -count=1
go test -c -o /tmp/cthulhu-bypass-xorg.test ./internal/platform/xorg
CTHULHU_XRECORD_TEST=/tmp/cthulhu-bypass-xorg.test CTHULHU_NATIVE_LISTENER_ONLY=1 CTHULHU_NATIVE_X11_TESTS='^TestNativeCommandBypass$' python3 tests/native_x11.py
```
The native harness owns a private Xvfb display and D-Bus session. It checks both
@@ -32,6 +45,9 @@ Caps Lock's actual lock state; and restoration of browse and global commands.
Go tests check stale-command suppression, continued speech/Braille output,
application departure without accessibility events, unknown foreground samples,
same-application windows, and recovery when bindings are changed during bypass.
The Wayland fake-monitor tests check that bypass empties the grab set while
the retained toggle still dispatches, and that an internal UI session grabs
and releases the keyboard while routing keys only to the session.
These checks do not establish installed-binary behavior, audible speech,
physical keyboard input, or physical Braille output.
@@ -59,6 +75,11 @@ physical keyboard input, or physical Braille output.
bypass to remain active. If Caps Lock is a configured modifier, press and
release it alone during bypass: expect normal lock behavior. Turn it off
again before restoring reader commands.
7. On Wayland, repeat steps 2-6 with these differences: in step 2,
Cthulhu+T during bypass must not read the time and is swallowed rather
than reaching the terminal. Also verify Cthulhu+Shift+A opens the
Actions list, arrows and Enter work inside it, and Escape returns keys
to the application.
Report the exact speech, Braille text, focused control, and application key
behavior for any mismatch. Inspect ~/devel/cthulhu-go.log after an unexpected
+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 ""
}