Add command bypass and internal UI sessions to Wayland keyboard backend
This commit is contained in:
1 parent
bebde8fd5e
commit
fc25f7e7e8
9 files changed
+426
-25
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in new issue
Block a user