From 477d860fb8bbbeddeddc4d3153e5965afbb56f38 Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:17:18 -0600 Subject: [PATCH] Return the keyboard on Remote shortcuts and announce focus changes During remote focus a Remote shortcut now ends capture, like F11, instead of running its command, which would race with the focus change. The reader announces when the keyboard starts and stops controlling the remote computer, and the consent wording names the F11 escape. Co-Authored-By: Claude Opus 5.5 --- docs/tentacle-remote-capture.md | 14 ++++++---- internal/app/tentacle_remote_capture.go | 3 ++ internal/app/tentacle_remote_consent.go | 2 +- internal/platform/xorg/commands_remote.go | 21 +++++++------- .../xorg/remote_capture_native_test.go | 28 ++++++++++++++----- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/docs/tentacle-remote-capture.md b/docs/tentacle-remote-capture.md index 79e96e4..9880715 100644 --- a/docs/tentacle-remote-capture.md +++ b/docs/tentacle-remote-capture.md @@ -61,10 +61,11 @@ Each native transition passes through `RemoteRepeatNormalizer` and local chord. Keys outside the Remote vocabulary are consumed locally. Only two things stay local. The core escape, F11 with no modifiers, ends capture -on the X thread without waiting for a Tentacle or the network. Commands whose -IDs start with the requesting Tentacle's `tentacle//` prefix, as -resolved from current bindings including remaps, are dispatched through the -normal command queue. Lock, secure input, XTerm handoff, learn mode, OCR review, +on the X thread without waiting for a Tentacle or the network. Shortcuts for +commands whose IDs start with the requesting Tentacle's `tentacle//` +prefix, as resolved from current bindings including remaps, also return the +keyboard here (reason `requested`) without running the command; running it +would race with the focus change. Pressed again under local focus they run. Lock, secure input, XTerm handoff, learn mode, OCR review, a device hierarchy change, an inconsistent stream or a full 512-event queue end capture with reason `local`. @@ -81,8 +82,9 @@ handed off and showing no reader UI. Revocation of the lease ends capture. A private Xvfb test drives XTEST keys into the real owner with a focused `xev` window as the local application: plain keys, Shift+Tab and a reader-modifier -chord are forwarded with correct names and pairing; the Remote shortcut stays -local and withdraws its prefix; unsupported keys and F11 are consumed; and the +chord are forwarded with correct names and pairing; the Remote shortcut +withdraws its prefix and ends capture without running; unsupported keys and F11 +are consumed; and the window receives none of the captured keys but does receive input afterwards. Lock ends capture with a held modifier released remotely. Five repeated runs passed. App tests with a synthetic owner and a real Go SDK child cover focus diff --git a/internal/app/tentacle_remote_capture.go b/internal/app/tentacle_remote_capture.go index 2231806..f06a5b1 100644 --- a/internal/app/tentacle_remote_capture.go +++ b/internal/app/tentacle_remote_capture.go @@ -105,8 +105,10 @@ func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, sessio id, stream, err := capture.BeginRemoteCapture(request.Context, prefix) if err != nil { _ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "unavailable") + runtime.report("Remote keyboard control is unavailable.") continue } + runtime.report("Keyboard controls the remote computer. Press F11 to return.") active, lease, events, leaseDone = id, request.Lease, stream, request.Context.Done() if session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested") != nil { end() @@ -118,6 +120,7 @@ func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, sessio } if event.Ended != "" { _ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended) + runtime.report("Keyboard returned to this computer.") continue } key := tentaclewire.RemoteCapturedKey{Code: int(event.Key.Code), Name: event.Key.Name, Pressed: event.Key.Pressed, Repeat: event.Key.Repeat} diff --git a/internal/app/tentacle_remote_consent.go b/internal/app/tentacle_remote_consent.go index 977c5fb..93792da 100644 --- a/internal/app/tentacle_remote_consent.go +++ b/internal/app/tentacle_remote_consent.go @@ -118,7 +118,7 @@ func remote_consent_form(id, role string, grants []string) interaction.Form { labels := map[string]string{ "output-observe": "Share local reader output with the remote controller", "remote-present": "Present output from the controlled computer", - "keyboard-capture": "Forward local keyboard input to the controlled computer", + "keyboard-capture": "Forward local keyboard input to the controlled computer while remote focus is on. Press F11 to return to this computer", "keyboard-inject": "Allow the remote controller to type on this computer", "clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request", "braille-stream": "Exchange remote braille cells and supported display gestures", diff --git a/internal/platform/xorg/commands_remote.go b/internal/platform/xorg/commands_remote.go index 6b2e971..70bdde6 100644 --- a/internal/platform/xorg/commands_remote.go +++ b/internal/platform/xorg/commands_remote.go @@ -60,8 +60,8 @@ func (listener *CommandListener) RemoteCaptureActive() bool { // BeginRemoteCapture takes an exclusive keyboard grab and returns the session // and its event stream. Only the core escape (F11) and commands whose IDs start -// with localPrefix stay local; everything else is forwarded, including Cthulhu -// modifier chords. It waits until no key is held. It grants no consent: the +// with localPrefix stay local: either returns the keyboard to this computer. +// Everything else is forwarded, including Cthulhu modifier chords. It waits until no key is held. It grants no consent: the // caller must hold a live keyboard-capture lease and end capture on revocation. func (listener *CommandListener) BeginRemoteCapture(ctx context.Context, localPrefix string) (uint64, <-chan input.RemoteCaptureEvent, error) { if !strings.HasPrefix(localPrefix, "tentacle/") || !strings.HasSuffix(localPrefix, "/") { @@ -174,7 +174,7 @@ func (owner *commandOwner) end_remote_capture(reason string) { } capture.ending = true for _, transition := range capture.normalizer.Retire() { - owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}, 0) + owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}) } for _, transition := range capture.router.Retire().Remote { owner.send_remote(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: transition.Keycode}}) @@ -208,7 +208,7 @@ func (owner *commandOwner) send_remote(event input.RemoteCaptureEvent) bool { func (owner *commandOwner) flush_remote(now time.Time) { for _, transition := range owner.remote.normalizer.Flush(now) { - owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}, 0) + owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}) } } @@ -231,14 +231,14 @@ func (owner *commandOwner) handle_remote_key(key C.CommandKey) { capture.names[transition.Keycode] = name routed.Unsupported = name == "" } - owner.route_remote(routed, uint32(key.timestamp)) + owner.route_remote(routed) } if err != nil && owner.remote != nil { owner.end_remote_capture("local") } } -func (owner *commandOwner) route_remote(key input.RemoteCaptureKey, timestamp uint32) { +func (owner *commandOwner) route_remote(key input.RemoteCaptureKey) { capture := owner.remote if capture == nil { return @@ -264,10 +264,9 @@ func (owner *commandOwner) route_remote(key input.RemoteCaptureKey, timestamp ui owner.end_remote_capture("escape") return } - if result.Local != "" && owner.workerContext.Err() == nil { - chord := input.CommandChord{Symbol: key.Symbol} - if !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: result.Local, Timestamp: timestamp, Chord: chord}) { - owner.end_remote_capture("local") - } + // A Remote shortcut returns the keyboard here instead of running: running it + // would race with the focus change. Press it again under local focus. + if result.Local != "" { + owner.end_remote_capture("requested") } } diff --git a/internal/platform/xorg/remote_capture_native_test.go b/internal/platform/xorg/remote_capture_native_test.go index e4e05bb..4383a98 100644 --- a/internal/platform/xorg/remote_capture_native_test.go +++ b/internal/platform/xorg/remote_capture_native_test.go @@ -113,23 +113,37 @@ func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) { expect("t", true, true) expect("", false, false) expect("", false, false) - // Remote's own shortcut stays local and withdraws the forwarded prefix. + // Unsupported keys are consumed locally, not forwarded. + run("key", "XF86AudioMute") + // Remote's own shortcut stays local: it withdraws the forwarded prefix and + // returns the keyboard here without running the command. run("key", "Insert+ctrl+r") insert := expect("Insert", true, true) control := expect("Control_L", true, true) if expect("", false, false) != control || expect("", false, false) != insert { t.Fatal("prefix not withdrawn newest first") } + if event := next(); event.Ended != "requested" { + t.Fatalf("shortcut end event = %+v", event) + } select { case event := <-listener.Commands(): - if event.Command != toggle { - t.Fatalf("local command = %s", event.Command) + t.Fatalf("shortcut ran %s during remote focus", event.Command) + default: + } + if _, ok := <-events; ok { + t.Fatal("capture stream stayed open after shortcut") + } + for listener.RemoteCaptureActive() { + if ctx.Err() != nil { + t.Fatal("shortcut did not end capture") } - case <-ctx.Done(): - t.Fatal("local Remote shortcut did not dispatch") + time.Sleep(time.Millisecond) + } + session, events, err = listener.BeginRemoteCapture(ctx, "tentacle/remote-1/") + if err != nil { + t.Fatal(err) } - // Unsupported keys are consumed locally, not forwarded. - run("key", "XF86AudioMute") run("key", "F11") if event := next(); event.Ended != "escape" { t.Fatalf("escape event = %+v", event)