Honor Num Lock during Remote keyboard capture

Forward numeric keypad identities with Num Lock enabled and preserve held-key pairing across lock changes. Suppress local lock feedback during remote capture so it cannot interrupt incoming speech. Add debug speech and capture-end diagnostics, expand private keypad checks, and repair the suspension probe source list.

Validated affected Go packages, focused remote speech race checks, the private X11 Remote input harness, and a reader build. Live physical keyboard, audible PowerShell speech, Braille, and disconnect-cause verification remain pending; the active reader session was left running.
This commit is contained in:
ak47 committed 2026-11-07 22:37:39 -05:00
1 parent abb4890e8a
commit 64f60fe4e8
12 files changed
+120 -18

No files matched your search

+13 -1
View File
@@ -38,6 +38,12 @@ releases. `name` is a canonical unshifted key name from
repeats omit it so a layout change cannot strand a key. A full event queue repeats omit it so a layout change cannot strand a key. A full event queue
fails the session rather than losing a release. fails the session rather than losing a release.
Local Num Lock selects numeric keypad names (`KP_0`–`KP_9`, `KP_Decimal`)
instead of navigation names. Shift remains a separate key transition. A keypad
press retains its original identity through repeats and release even if Num Lock
changes while it is held. Numeric `KP_0` does not act as the reader's Insert
modifier; keypad Insert with Num Lock off still does.
Reader code attaches the mailbox with `Session.EnableRemoteCapture` and Reader code attaches the mailbox with `Session.EnableRemoteCapture` and
publishes with `PublishRemoteInput` and `PublishRemoteFocus`; both require the publishes with `PublishRemoteInput` and `PublishRemoteFocus`; both require the
live controller `keyboard-capture` lease. Go exposes `RemoteCapturedKey`, live controller `keyboard-capture` lease. Go exposes `RemoteCapturedKey`,
@@ -73,7 +79,13 @@ Ending releases every forwarded key through the stream, newest first, then
sends the end marker and closes the stream. Keys still physically held keep the sends the end marker and closes the stream. Keys still physically held keep the
grab until their releases arrive, so no release reaches an application that grab until their releases arrive, so no release reaches an application that
never saw the press. While capture is active the XRecord listener's local key never saw the press. While capture is active the XRecord listener's local key
echo and speech interruption are suppressed. echo, speech interruption and local lock-key announcements are suppressed.
The peer supplies its own lock feedback. Local lock tracking continues, and
announcements resume for subsequent changes after returning to local control.
Debug logs record capture-ending reasons and remote speech text handed to the
software speech backend, with spelling/language state and priority. Remote speech
text is omitted at ordinary log levels, as for other private reader output.
`start_remote_capture` attaches this owner for sessions installed with `start_remote_capture` attaches this owner for sessions installed with
`keyboard-capture`, from both local launch and managed startup. It enters `keyboard-capture`, from both local launch and managed startup. It enters
+13 -2
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"time" "time"
"git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentacle"
@@ -43,7 +44,7 @@ func (err *remoteSpeechStreamError) Unwrap() error { return err.cause }
// The caller supplies original-lease policy and optional local-output admission. // The caller supplies original-lease policy and optional local-output admission.
func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker, func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
incoming <-chan tentacle.RemoteSpeechDelivery, allow func(tentacle.RemoteSpeechDelivery) bool, incoming <-chan tentacle.RemoteSpeechDelivery, allow func(tentacle.RemoteSpeechDelivery) bool,
admit remoteSpeechAdmission) error { admit remoteSpeechAdmission, logger *slog.Logger) error {
if speaker == nil || allow == nil { if speaker == nil || allow == nil {
return errors.New("Remote speech playback requires a backend and delivery policy") return errors.New("Remote speech playback requires a backend and delivery policy")
} }
@@ -87,7 +88,17 @@ func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
if !allow(delivery) { if !allow(delivery) {
return errors.New("Remote speech delivery policy changed") return errors.New("Remote speech delivery policy changed")
} }
return voice.SpeakUtteranceAndWait(request, utterance) if logger != nil && logger.Enabled(request, slog.LevelDebug) {
for _, fragment := range utterance.fragments {
logger.DebugContext(request, "delivering remote speech", "utterance", fragment.Text,
"characters", fragment.Characters, "language", fragment.Language, "priority", utterance.priority)
}
}
err := voice.SpeakUtteranceAndWait(request, utterance)
if err != nil && request.Err() == nil && logger != nil {
logger.Debug("remote speech rendering failed", "error", err)
}
return err
}) })
} }
+4 -4
View File
@@ -320,7 +320,7 @@ func TestRemoteSpeechStreamIsolatedBackendCancellation(t *testing.T) {
incoming := make(chan tentacle.RemoteSpeechDelivery, 8) incoming := make(chan tentacle.RemoteSpeechDelivery, 8)
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil) done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil, nil)
}() }()
lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1} lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1}
send := func(effect tentaclewire.RemoteSpeech) <-chan struct{} { send := func(effect tentaclewire.RemoteSpeech) <-chan struct{} {
@@ -377,7 +377,7 @@ func TestRemoteSpeechStreamEarlyBackendCompletionKeepsReading(t *testing.T) {
incoming := make(chan tentacle.RemoteSpeechDelivery, 8) incoming := make(chan tentacle.RemoteSpeechDelivery, 8)
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil) done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil, nil)
}() }()
lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1} lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1}
for _, text := range []string{"first", "second", "third"} { for _, text := range []string{"first", "second", "third"} {
@@ -422,7 +422,7 @@ func TestRemoteSpeechStreamPolicyLossDuringAcquisition(t *testing.T) {
Release: func() { close(released) }} Release: func() { close(released) }}
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
done <- run_remote_speech_playback(t.Context(), speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return allowed.Load() }, nil) done <- run_remote_speech_playback(t.Context(), speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return allowed.Load() }, nil, nil)
}() }()
remote_stream_wait(t, held) remote_stream_wait(t, held)
allowed.Store(false) allowed.Store(false)
@@ -457,7 +457,7 @@ func test_remote_stream_lease_voice(t *testing.T, active bool) {
incoming := make(chan tentacle.RemoteSpeechDelivery, 4) incoming := make(chan tentacle.RemoteSpeechDelivery, 4)
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
done <- run_remote_speech_playback(ctx, speaker, incoming, func(delivery tentacle.RemoteSpeechDelivery) bool { return delivery.Context.Err() == nil }, nil) done <- run_remote_speech_playback(ctx, speaker, incoming, func(delivery tentacle.RemoteSpeechDelivery) bool { return delivery.Context.Err() == nil }, nil, nil)
}() }()
send := func(lifetime context.Context, epoch uint64, text string) <-chan struct{} { send := func(lifetime context.Context, epoch uint64, text string) <-chan struct{} {
released := make(chan struct{}) released := make(chan struct{})
+1
View File
@@ -119,6 +119,7 @@ func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, sessio
continue continue
} }
if event.Ended != "" { if event.Ended != "" {
runtime.logger.Debug("remote keyboard capture ended", "reason", event.Ended)
_ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended) _ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended)
runtime.report("Keyboard returned to this computer.") runtime.report("Keyboard returned to this computer.")
continue continue
+1 -1
View File
@@ -86,7 +86,7 @@ func (runtime *tentacleRuntime) start_remote_speech(ctx context.Context, session
defer cancel() defer cancel()
defer stop() defer stop()
for ctx.Err() == nil { for ctx.Err() == nil {
err := run_remote_speech_playback(ctx, speaker, incoming, allow, owner.speech.admit_remote_speech) err := run_remote_speech_playback(ctx, speaker, incoming, allow, owner.speech.admit_remote_speech, runtime.logger)
if ctx.Err() != nil || err == nil { if ctx.Err() != nil || err == nil {
return return
} }
+4 -4
View File
@@ -9,8 +9,8 @@ import "errors"
var ErrRemoteCaptureChord = errors.New("remote capture local shortcut is invalid") var ErrRemoteCaptureChord = errors.New("remote capture local shortcut is invalid")
// RemoteCaptureKey is one normalized captured transition. Symbol is the // RemoteCaptureKey is one normalized captured transition. Symbol is the
// group-zero, level-zero keysym the native owner resolved for a press, the // group-zero, unshifted keysym the native owner resolved for a press, with
// same form the command owner matches against bindings; releases and repeats are // Num Lock selecting numeric keypad identities; releases and repeats are
// matched by keycode, so a later layout change cannot alter ownership. // matched by keycode, so a later layout change cannot alter ownership.
type RemoteCaptureKey struct { type RemoteCaptureKey struct {
Keycode uint8 Keycode uint8
@@ -43,8 +43,8 @@ type RemoteCaptureTransition struct {
// handled by the native owner without waiting for the network or a Tentacle. // handled by the native owner without waiting for the network or a Tentacle.
var RemoteCaptureEscape = CommandChord{Symbol: 0xffc8} var RemoteCaptureEscape = CommandChord{Symbol: 0xffc8}
// RemoteCaptureKeyName returns the canonical Remote key name for a // RemoteCaptureKeyName returns the canonical Remote key name for an unshifted
// group-zero, level-zero keysym name, or "" when it has none. // keysym name (with Num Lock selecting keypad identities), or "" when it has none.
func RemoteCaptureKeyName(keysym string) string { func RemoteCaptureKeyName(keysym string) string {
switch keysym { switch keysym {
case "Prior": case "Prior":
+11
View File
@@ -634,6 +634,17 @@ int next_command_key(CommandDevice *device, CommandKey *key) {
} }
key->pressed=(event.type==KeyPress); key->code=event.xkey.keycode; key->pressed=(event.type==KeyPress); key->code=event.xkey.keycode;
key->symbol=XLookupKeysym(&event.xkey,0); key->symbol=XLookupKeysym(&event.xkey,0);
key->remoteSymbol=key->symbol;
// Remote keypad identities must follow Num Lock. Keep ordinary command
// matching at level zero, and forward Shift separately like other keys.
// Retain this identity in the capture owner until the physical release.
if (IsKeypadKey(key->symbol)) {
unsigned int consumed;
KeySym translated;
if (XkbLookupKeySym(device->display,event.xkey.keycode,
event.xkey.state & device->numlockMask,&consumed,&translated))
key->remoteSymbol=translated;
}
key->modifiers=event.xkey.state & ~device->ignoredModifierMask; key->modifiers=event.xkey.state & ~device->ignoredModifierMask;
key->text[0]=0; key->textSymbol=0; key->text[0]=0; key->textSymbol=0;
if (device->internalUI && event.type==KeyPress) { if (device->internalUI && event.type==KeyPress) {
+1 -1
View File
@@ -27,7 +27,7 @@ typedef struct {
int nativeButtonFocus; int nativeButtonFocus;
Window browseWindow; Window browseWindow;
} CommandDevice; } CommandDevice;
typedef struct { int pressed, code, symbol, textSymbol, modifiers, locks; unsigned long timestamp; char text[64]; } CommandKey; typedef struct { int pressed, code, symbol, remoteSymbol, textSymbol, modifiers, locks; unsigned long timestamp; char text[64]; } CommandKey;
typedef struct { typedef struct {
int known, pid, x, y, width, height, outerX, outerY, outerWidth, outerHeight; int known, pid, x, y, width, height, outerX, outerY, outerWidth, outerHeight;
int frameX, frameY, frameWidth, frameHeight; int frameX, frameY, frameWidth, frameHeight;
@@ -309,7 +309,9 @@ func (owner *commandOwner) syncGrabs() bool {
func (owner *commandOwner) publishLocks(key C.CommandKey) { func (owner *commandOwner) publishLocks(key C.CommandKey) {
for index, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} { for index, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} {
mask := C.int(1 << index) mask := C.int(1 << index)
if (key.locks^owner.lastLocks)&mask != 0 { // Remote's lock keys belong to the peer, including its feedback. Local
// announcements would cancel incoming speech and repeat the peer's echo.
if owner.remote == nil && (key.locks^owner.lastLocks)&mask != 0 {
owner.listener.publish_lock_state(input.LockState{Name: name, Locked: key.locks&mask != 0, Timestamp: uint32(key.timestamp)}) owner.listener.publish_lock_state(input.LockState{Name: name, Locked: key.locks&mask != 0, Timestamp: uint32(key.timestamp)})
} }
} }
+2 -2
View File
@@ -223,9 +223,9 @@ func (owner *commandOwner) handle_remote_key(key C.CommandKey) {
} }
routed := input.RemoteCaptureKey{Keycode: transition.Keycode, Pressed: transition.Pressed, Repeat: transition.Repeat} routed := input.RemoteCaptureKey{Keycode: transition.Keycode, Pressed: transition.Pressed, Repeat: transition.Repeat}
if transition.Pressed && !transition.Repeat { if transition.Pressed && !transition.Repeat {
routed.Symbol = uint32(key.symbol) routed.Symbol = uint32(key.remoteSymbol)
name := "" name := ""
if native := C.XKeysymToString(C.KeySym(key.symbol)); native != nil { if native := C.XKeysymToString(C.KeySym(key.remoteSymbol)); native != nil {
name = input.RemoteCaptureKeyName(C.GoString(native)) name = input.RemoteCaptureKeyName(C.GoString(native))
} }
capture.names[transition.Keycode] = name capture.names[transition.Keycode] = name
@@ -50,7 +50,7 @@ func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) {
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
if err := listener.Start(ctx); err != nil { if err := listener.Start(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,6 +100,60 @@ func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) {
if expect("", false, false) != a { if expect("", false, false) != a {
t.Fatal("release paired with another keycode") t.Fatal("release paired with another keycode")
} }
// The same physical keypad key changes identity with Num Lock. Lock changes
// during a held key must not lose its release or make KP_0 a
// reader modifier. No local lock speech may interrupt the peer's output.
remoteNativeState(t, "num-off")
run("key", "KP_End")
keypad := expect("KP_End", true, true)
if expect("", false, false) != keypad {
t.Fatal("keypad navigation release changed code")
}
remoteNativeState(t, "num-on")
run("keydown", "KP_End")
if expect("KP_1", true, true) != keypad {
t.Fatal("Num Lock changed physical keypad code")
}
remoteNativeState(t, "num-off")
run("keyup", "KP_End")
if expect("", false, false) != keypad {
t.Fatal("Num Lock change lost held keypad release")
}
remoteNativeState(t, "num-on")
for _, key := range []struct{ physical, name string }{
{"KP_Insert", "KP_0"}, {"KP_End", "KP_1"}, {"KP_Down", "KP_2"},
{"KP_Next", "KP_3"}, {"KP_Left", "KP_4"}, {"KP_Begin", "KP_5"},
{"KP_Right", "KP_6"}, {"KP_Home", "KP_7"}, {"KP_Up", "KP_8"},
{"KP_Prior", "KP_9"}, {"KP_Delete", "KP_Decimal"},
} {
run("key", key.physical)
code := expect(key.name, true, true)
if expect("", false, false) != code {
t.Fatalf("numeric %s release changed code", key.name)
}
}
run("key", "KP_Insert")
zero := expect("KP_0", true, true)
if expect("", false, false) != zero {
t.Fatal("numeric keypad zero was treated as a modifier")
}
run("key", "KP_Insert+ctrl+r")
expect("KP_0", true, true)
expect("Control_L", true, true)
expect("r", true, true)
expect("", false, false)
expect("", false, false)
expect("", false, false)
remoteNativeState(t, "num-off")
// A key event is a barrier behind the XKB notifications above.
run("key", "c")
expect("c", true, true)
expect("", false, false)
select {
case state := <-listener.LockStates():
t.Fatalf("local lock feedback during Remote capture: %+v", state)
default:
}
run("key", "shift+Tab") run("key", "shift+Tab")
shift := expect("Shift_L", true, true) shift := expect("Shift_L", true, true)
tab := expect("Tab", true, true) tab := expect("Tab", true, true)
@@ -166,6 +220,17 @@ func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) {
t.Fatal("ending a finished session failed") t.Fatal("ending a finished session failed")
} }
run("key", "b") run("key", "b")
// Lock feedback resumes after capture, without replaying remote changes.
run("key", "Num_Lock")
select {
case state := <-listener.LockStates():
if state.Name != "Num Lock" || !state.Locked {
t.Fatalf("local lock feedback after capture: %+v", state)
}
case <-ctx.Done():
t.Fatal("lock feedback did not resume after capture")
}
remoteNativeState(t, "num-off")
// Reader policy ends capture with keys held, releasing them remotely and // Reader policy ends capture with keys held, releasing them remotely and
// keeping the grab until the physical release. // keeping the grab until the physical release.
@@ -21,7 +21,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
probe := filepath.Join(t.TempDir(), "suspension-probe") probe := filepath.Join(t.TempDir(), "suspension-probe")
if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "commands_local.c", "-o", probe, "-lX11", "-lXtst", "-lXi", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "commands_local.c", "i3_native.c", "-o", probe, "-lX11", "-lXtst", "-lXi", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil {
t.Fatalf("compile native probe: %v: %s", err, out) t.Fatalf("compile native probe: %v: %s", err, out)
} }
for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} { for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} {