diff --git a/docs/tentacle-remote-capture.md b/docs/tentacle-remote-capture.md index e453d8c..79e96e4 100644 --- a/docs/tentacle-remote-capture.md +++ b/docs/tentacle-remote-capture.md @@ -1,8 +1,8 @@ # Remote controller keyboard capture IPC Protocol 1.1 now carries controller keyboard capture between the reader and a -Remote Tentacle. This document describes the wire contract and host checks; -native capture is described in the following sections as it is implemented. +Remote Tentacle, and the Xorg command owner provides the exclusive capture +itself. Live physical-keyboard acceptance is pending. ## Wire contract @@ -48,3 +48,48 @@ event kinds. Revocation drains pending focus work. Go/Python SDK-process tests cover owner absence, admission and replay, role/grant denial, invalid parameters, saturation revocation, event delivery and rejection after revocation. + +## Xorg capture owner + +`CommandListener.BeginRemoteCapture` adds a third exclusive mode to the Xorg +command owner, next to internal reader UI and learn mode. It starts only +between chords, while command grabs are allowed and no other mode is active, +and takes `XGrabKeyboard` on the root window without changing input focus. +Each native transition passes through `RemoteRepeatNormalizer` and +`RemoteCaptureRouter`. Ordinary keys and Cthulhu modifier chords become +`remote.input` events; the router releases a forwarded prefix remotely before a +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, +a device hierarchy change, an inconsistent stream or a full 512-event queue end +capture with reason `local`. + +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 +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 +echo and speech interruption are suppressed. + +`start_remote_capture` attaches this owner for sessions installed with +`keyboard-capture`, from both local launch and managed startup. It enters +capture only for a current controller lease while the reader is unlocked, not +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 +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 +reporting, key forwarding, escape and requested exit, revocation, policy +denial and grab failure. + +Known limits: the escape is a fixed chord and is not remappable; a withdrawn +prefix reaches the remote as a modifier tap; XTEST input cannot establish +physical-keyboard behaviour; and Caps Lock as the Cthulhu modifier keeps its +local lock state but is forwarded as a key. diff --git a/internal/app/input_events.go b/internal/app/input_events.go index ce9582a..6aa0251 100644 --- a/internal/app/input_events.go +++ b/internal/app/input_events.go @@ -56,6 +56,12 @@ func processInputEvents( echo.Reset() continue } + // Keys under Remote capture belong to the remote reader: no local + // echo or interruption of locally presented remote speech. + if remote, ok := commands.(interface{ RemoteCaptureActive() bool }); ok && remote.RemoteCaptureActive() { + echo.Reset() + continue + } if overflow := listener.Overflow(); overflow > lastOverflow { logger.Warn("X11 event queue overflow", "dropped_events", overflow-lastOverflow) lastOverflow = overflow diff --git a/internal/app/tentacle_remote_capture.go b/internal/app/tentacle_remote_capture.go new file mode 100644 index 0000000..2231806 --- /dev/null +++ b/internal/app/tentacle_remote_capture.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// remoteCaptureEndTimeout bounds waiting for the native owner, which ends a +// capture only between chords. +const remoteCaptureEndTimeout = 2 * time.Second + +// remoteCaptureInput is the native exclusive-capture owner (Xorg only). +type remoteCaptureInput interface { + BeginRemoteCapture(context.Context, string) (uint64, <-chan input.RemoteCaptureEvent, error) + EndRemoteCapture(context.Context, uint64) error +} + +// start_remote_capture attaches the controller capture owner for a session +// installed with keyboard-capture. Without a native capture owner remote.focus +// stays owner-unavailable. Capture is entered only on a request for the current +// controller lease while the reader is unlocked, not handed off and showing no +// reader UI. Revocation, policy or the core escape end it; ending releases all +// forwarded keys through the stream before the local focus event. +func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, session *tentacle.Session) <-chan struct{} { + done := make(chan struct{}) + capture, ok := runtime.input.(remoteCaptureInput) + owner := runtime.output + if !ok || owner == nil || owner.gate == nil || runtime.remotePolicy == nil || + !slices.Contains(session.RemoteGrants(), "keyboard-capture") { + close(done) + return done + } + requests := session.EnableRemoteCapture() + allowed := func() bool { + owner.gate.mutex.Lock() + defer owner.gate.mutex.Unlock() + return !owner.gate.Locked() && !owner.gate.Suspended() && !owner.gate.InternalUIActive() + } + prefix := "tentacle/" + session.Identity() + "/" + go func() { + defer close(done) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(session.OutputContext(), cancel) + defer stop() + var active uint64 + var lease tentaclewire.RemoteLease + var events <-chan input.RemoteCaptureEvent + var leaseDone <-chan struct{} + end := func() { + if active == 0 { + return + } + // Use a fresh bounded context: ending must not depend on the lease + // or reader context that may already be cancelled. + endCtx, endCancel := context.WithTimeout(context.Background(), remoteCaptureEndTimeout) + _ = capture.EndRemoteCapture(endCtx, active) + endCancel() + } + defer end() + for { + select { + case <-ctx.Done(): + return + case <-leaseDone: + leaseDone = nil + end() + case request := <-requests: + current := request.Context != nil && request.Context.Err() == nil + if current { + _, err := session.RemoteContext(request.Lease, "keyboard-capture") + current = err == nil + } + if !request.Remote { + if active != 0 && lease == request.Lease { + end() // The stream reports "requested" when it closes. + } else if current { + _ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "requested") + } + continue + } + if !current { + continue + } + if active != 0 { + if lease == request.Lease { + _ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested") + continue + } + end() + } + if !allowed() { + _ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "local") + continue + } + id, stream, err := capture.BeginRemoteCapture(request.Context, prefix) + if err != nil { + _ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "unavailable") + continue + } + active, lease, events, leaseDone = id, request.Lease, stream, request.Context.Done() + if session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested") != nil { + end() + } + case event, open := <-events: + if !open { + active, events, leaseDone = 0, nil, nil + continue + } + if event.Ended != "" { + _ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended) + continue + } + key := tentaclewire.RemoteCapturedKey{Code: int(event.Key.Code), Name: event.Key.Name, Pressed: event.Key.Pressed, Repeat: event.Key.Repeat} + if session.PublishRemoteInput(lease, key) != nil && key.Pressed { + // A retired lease cannot carry keys. Releases continue to + // drain the stream so the native owner never blocks. + end() + } + } + } + }() + return done +} diff --git a/internal/app/tentacle_remote_capture_test.go b/internal/app/tentacle_remote_capture_test.go new file mode 100644 index 0000000..3304f95 --- /dev/null +++ b/internal/app/tentacle_remote_capture_test.go @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "sync" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// fakeCaptureInput stands in for the Xorg owner; the native test covers the +// grab itself. Ending sends the "requested" marker and closes, as the owner does. +type fakeCaptureInput struct { + mu sync.Mutex + prefix string + stream chan input.RemoteCaptureEvent + session uint64 + begun chan struct{} + ended chan uint64 + refuse bool +} + +func (fake *fakeCaptureInput) SetTentacleBindings(context.Context, []input.BindingOverride) error { + return nil +} +func (fake *fakeCaptureInput) TentacleBindings() []input.BindingOverride { return nil } + +func (fake *fakeCaptureInput) BeginRemoteCapture(_ context.Context, prefix string) (uint64, <-chan input.RemoteCaptureEvent, error) { + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.refuse { + return 0, nil, errors.New("grab failed") + } + fake.session++ + fake.prefix = prefix + fake.stream = make(chan input.RemoteCaptureEvent, 16) + fake.begun <- struct{}{} + return fake.session, fake.stream, nil +} + +func (fake *fakeCaptureInput) EndRemoteCapture(_ context.Context, session uint64) error { + fake.mu.Lock() + defer fake.mu.Unlock() + if fake.stream != nil && session == fake.session { + fake.stream <- input.RemoteCaptureEvent{Ended: "requested"} + close(fake.stream) + fake.stream = nil + fake.ended <- session + } + return nil +} + +func (fake *fakeCaptureInput) emit(event input.RemoteCaptureEvent) { + fake.mu.Lock() + defer fake.mu.Unlock() + fake.stream <- event + if event.Ended != "" { + close(fake.stream) + fake.stream = nil + } +} + +func capture_next_event(t *testing.T, session *tentacle.Session) tentaclewire.SessionEvent { + t.Helper() + var event tentaclewire.SessionEvent + if err := json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event); err != nil { + t.Fatal(err) + } + return event +} + +func capture_fixture(t *testing.T) (*tentacle.Session, *tentacle.RemotePolicy, *fakeCaptureInput, *tentacleRuntime) { + t.Helper() + policy := tentacle.NewRemotePolicy() + session := keyboard_owner_session(t, policy, []string{"keyboard-capture", "remote-present"}) + fake := &fakeCaptureInput{begun: make(chan struct{}, 4), ended: make(chan uint64, 4)} + runtime := &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, input: fake, + output: &tentacleOutput{gate: &focusGate{}}, announcements: make(chan string, 1)} + ctx, cancel := context.WithCancel(t.Context()) + done := runtime.start_remote_capture(ctx, session) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("capture owner did not join") + } + }) + return session, policy, fake, runtime +} + +func capture_open(t *testing.T, session *tentacle.Session) tentaclewire.RemoteLease { + t.Helper() + grants := []string{"keyboard-capture"} + approval, err := session.BeginRemoteApproval("controller", grants) + if err != nil { + t.Fatal(err) + } + if _, err := session.AuthorizeRemoteWithApproval(approval, "controller", grants); err != nil { + t.Fatal(err) + } + var lease tentaclewire.RemoteLease + if json.Unmarshal(keyboard_owner_invoke(t, session, "open", nil), &lease) != nil || lease.Epoch == 0 { + t.Fatal("controller lease did not open") + } + return lease +} + +func capture_focus(t *testing.T, session *tentacle.Session, lease tentaclewire.RemoteLease, sequence uint64, remote bool) { + t.Helper() + request := tentaclewire.RemoteFocusRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Remote: remote} + keyboard_owner_invoke(t, session, "rpc", map[string]any{"Method": "remote.focus", "Params": request}) +} + +func TestRemoteCaptureForwardsKeysAndReportsFocus(t *testing.T) { + session, _, fake, _ := capture_fixture(t) + lease := capture_open(t, session) + capture_focus(t, session, lease, 1, true) + <-fake.begun + if fake.prefix != "tentacle/"+session.Identity()+"/" { + t.Fatal("capture prefix does not name this Tentacle", fake.prefix) + } + if event := capture_next_event(t, session); event.Kind != "remote.focus" || event.Focus != "remote" || event.Reason != "requested" { + t.Fatal("missing remote focus", event) + } + fake.emit(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: 38, Name: "a", Pressed: true}}) + fake.emit(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: 38, Pressed: true, Repeat: true}}) + fake.emit(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: 38}}) + for _, want := range []tentaclewire.RemoteCapturedKey{{Code: 38, Name: "a", Pressed: true}, {Code: 38, Pressed: true, Repeat: true}, {Code: 38}} { + if event := capture_next_event(t, session); event.Kind != "remote.input" || event.Input == nil || *event.Input != want { + t.Fatal("captured key changed", event) + } + } + fake.emit(input.RemoteCaptureEvent{Ended: "escape"}) + if event := capture_next_event(t, session); event.Kind != "remote.focus" || event.Focus != "local" || event.Reason != "escape" { + t.Fatal("escape not reported", event) + } + // Re-enter, then leave by request. + capture_focus(t, session, lease, 2, true) + <-fake.begun + capture_next_event(t, session) + capture_focus(t, session, lease, 3, false) + <-fake.ended + if event := capture_next_event(t, session); event.Focus != "local" || event.Reason != "requested" { + t.Fatal("requested local focus not reported", event) + } +} + +func TestRemoteCaptureRevocationEndsCapture(t *testing.T) { + session, policy, fake, _ := capture_fixture(t) + lease := capture_open(t, session) + capture_focus(t, session, lease, 1, true) + <-fake.begun + capture_next_event(t, session) + policy.SetAvailable(false) // Lock or handoff. + select { + case <-fake.ended: + case <-time.After(5 * time.Second): + t.Fatal("revocation left capture active") + } + policy.SetAvailable(true) +} + +func TestRemoteCaptureRefusesUnderReaderPolicyOrGrabFailure(t *testing.T) { + session, _, fake, runtime := capture_fixture(t) + lease := capture_open(t, session) + runtime.output.gate.suspended.Store(true) + capture_focus(t, session, lease, 1, true) + if event := capture_next_event(t, session); event.Focus != "local" || event.Reason != "local" { + t.Fatal("suspended reader entered remote focus", event) + } + runtime.output.gate.suspended.Store(false) + fake.mu.Lock() + fake.refuse = true + fake.mu.Unlock() + capture_focus(t, session, lease, 2, true) + if event := capture_next_event(t, session); event.Focus != "local" || event.Reason != "unavailable" { + t.Fatal("failed grab not reported", event) + } + if len(fake.begun) != 0 { + t.Fatal("capture began under policy denial") + } +} + +func TestRemoteCaptureRequiresGrantAndOwner(t *testing.T) { + policy := tentacle.NewRemotePolicy() + session := keyboard_owner_session(t, policy, []string{"remote-present"}) + runtime := &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, + input: &fakeCaptureInput{begun: make(chan struct{}, 1), ended: make(chan uint64, 1)}, output: &tentacleOutput{gate: &focusGate{}}} + select { + case <-runtime.start_remote_capture(t.Context(), session): + case <-time.After(time.Second): + t.Fatal("ungranted session attached capture") + } +} diff --git a/internal/app/tentacle_remote_keyboard_test.go b/internal/app/tentacle_remote_keyboard_test.go index e364a56..619e140 100644 --- a/internal/app/tentacle_remote_keyboard_test.go +++ b/internal/app/tentacle_remote_keyboard_test.go @@ -45,6 +45,14 @@ func TestRemoteKeyboardPeer(t *testing.T) { } } }}, + {ID: "event", Title: "Event", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) { + select { + case event := <-client.Events(): + return event, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }}, {ID: "rpc", Title: "RPC", Handle: func(ctx context.Context, params json.RawMessage) (any, error) { var request struct { Method string diff --git a/internal/app/tentacle_startup.go b/internal/app/tentacle_startup.go index 1e0938f..232ea25 100644 --- a/internal/app/tentacle_startup.go +++ b/internal/app/tentacle_startup.go @@ -137,9 +137,10 @@ func (owner *tentacleLifecycle) startPending() { owner.watchers = append(owner.watchers, done) speechDone := owner.runtime.start_remote_speech(owner.ctx, session) keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session) + captureDone := owner.runtime.start_remote_capture(owner.ctx, session) go func() { defer close(done) - defer func() { <-speechDone; <-keysDone }() + defer func() { <-speechDone; <-keysDone; <-captureDone }() select { case <-session.Revoked(): case <-owner.ctx.Done(): diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index 9117d6e..3dea249 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -190,10 +190,11 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error { current, ready = session, session.Ready() speechDone := runtime.start_remote_speech(ctx, session) keysDone := runtime.start_remote_keyboard(ctx, session) + captureDone := runtime.start_remote_capture(ctx, session) watchers.Add(1) go func() { defer watchers.Done() - defer func() { <-speechDone; <-keysDone }() + defer func() { <-speechDone; <-keysDone; <-captureDone }() select { case <-session.Revoked(): case <-ctx.Done(): diff --git a/internal/input/remote_capture.go b/internal/input/remote_capture.go index fe1930c..60ea508 100644 --- a/internal/input/remote_capture.go +++ b/internal/input/remote_capture.go @@ -17,6 +17,49 @@ type RemoteCaptureKey struct { Symbol uint32 Pressed bool Repeat bool + // Unsupported marks a fresh press the Remote key vocabulary cannot name. + // It is consumed locally with its repeats and release, never forwarded. + Unsupported bool +} + +// RemoteCaptureEvent is one item from a native capture owner to its reader +// consumer. Exactly one of Key or Ended is set. Name is present only on a +// fresh press. Ended reports why capture stopped: "escape", "requested" or +// "local" (reader policy, overflow or an inconsistent native stream); no +// further events follow it. +type RemoteCaptureEvent struct { + Key *RemoteCaptureTransition + Ended string +} + +type RemoteCaptureTransition struct { + Code uint8 + Name string + Pressed bool + Repeat bool +} + +// RemoteCaptureEscape is the core escape chord: F11 with no modifiers. It is +// handled by the native owner without waiting for the network or a Tentacle. +var RemoteCaptureEscape = CommandChord{Symbol: 0xffc8} + +// RemoteCaptureKeyName returns the canonical Remote key name for a +// group-zero, level-zero keysym name, or "" when it has none. +func RemoteCaptureKeyName(keysym string) string { + switch keysym { + case "Prior": + keysym = "Page_Up" + case "Next": + keysym = "Page_Down" + case "KP_Prior": + keysym = "KP_Page_Up" + case "KP_Next": + keysym = "KP_Page_Down" + } + if !ValidRemoteKeyName(keysym) { + return "" + } + return keysym } // RemoteCaptureResult is the routing decision for one captured transition. @@ -164,6 +207,10 @@ func (router *RemoteCaptureRouter) Route(key RemoteCaptureKey) (RemoteCaptureRes if len(router.order) >= RemoteHeldKeyLimit { return router.fail(ErrRemoteKeyLimit) } + if key.Unsupported { + router.hold(code, remoteCaptureLocal) + return RemoteCaptureResult{}, nil + } if mask, modifier := remoteModifierMask(key.Symbol); modifier || router.cthulhu[key.Symbol] { router.hold(code, remoteCaptureForwarded) router.modifier[code] = true diff --git a/internal/input/remote_capture_test.go b/internal/input/remote_capture_test.go index b0885ec..1269ca4 100644 --- a/internal/input/remote_capture_test.go +++ b/internal/input/remote_capture_test.go @@ -305,3 +305,19 @@ func TestBehaviorRemoteCaptureComposesWithNormalizer(t *testing.T) { t.Fatalf("commands = %v", commands) } } + +func TestBehaviorRemoteCaptureConsumesUnsupportedKeys(t *testing.T) { + router := newTestCaptureRouter(t, nil) + runCaptureSteps(t, router, []captureStep{ + {press(codeShift, leftShiftKeySymbol), remote(down(codeShift))}, + {RemoteCaptureKey{Keycode: 121, Symbol: 0x1008ff12, Pressed: true, Unsupported: true}, RemoteCaptureResult{}}, + {hold(121, 0x1008ff12), RemoteCaptureResult{}}, + {release(121), RemoteCaptureResult{}}, + {release(codeShift), remote(up(codeShift))}, + }) + for keysym, want := range map[string]string{"Prior": "Page_Up", "KP_Next": "KP_Page_Down", "a": "a", "A": "", "XF86AudioMute": "", "ISO_Level3_Shift": ""} { + if got := RemoteCaptureKeyName(keysym); got != want { + t.Fatalf("%s = %q, want %q", keysym, got, want) + } + } +} diff --git a/internal/platform/xorg/commands.go b/internal/platform/xorg/commands.go index 7ead9f5..bf53a64 100644 --- a/internal/platform/xorg/commands.go +++ b/internal/platform/xorg/commands.go @@ -27,6 +27,9 @@ type CommandListener struct { uiClose atomic.Uint64 uiEcho atomic.Bool uiUpdates chan uiUpdate + remoteUpdates chan remoteCaptureUpdate + remoteSession atomic.Uint64 + remoteSequence atomic.Uint64 focusChanges chan FocusState modifiers []string layout input.KeyboardLayout @@ -66,6 +69,7 @@ func NewCommands(modifiers []string, layout input.KeyboardLayout) *CommandListen modifiers: append([]string(nil), modifiers...), bindingUpdates: make(chan bindingUpdate), uiUpdates: make(chan uiUpdate), + remoteUpdates: make(chan remoteCaptureUpdate), layout: layout, commands: make(chan input.CommandEvent, 16), lockStates: make(chan input.LockState, 16), diff --git a/internal/platform/xorg/commands_chord.go b/internal/platform/xorg/commands_chord.go index b02a948..5f78106 100644 --- a/internal/platform/xorg/commands_chord.go +++ b/internal/platform/xorg/commands_chord.go @@ -38,7 +38,7 @@ func (owner *commandOwner) handleKey(key C.CommandKey) bool { // Focus policy can change while the native owner waits for an X // event. Replay a stale browse-only press against the requested // policy, including custom symbols outside the default key list. - if !owner.learnMode && owner.uiSession == 0 && len(owner.held) == 0 && key.pressed != 0 && !owner.listener.browse.Load() && !owner.ocrReview && + if !owner.learnMode && owner.uiSession == 0 && owner.remote == nil && len(owner.held) == 0 && key.pressed != 0 && !owner.listener.browse.Load() && !owner.ocrReview && !owner.router.IsModifier(uint32(key.symbol)) && owner.globalBindings[input.CommandChord{Symbol: uint32(key.symbol), Modifiers: uint16(key.modifiers)}] == "" { owner.passThrough = true } @@ -48,7 +48,9 @@ func (owner *commandOwner) handleKey(key C.CommandKey) bool { delete(owner.held, int(key.code)) } owner.buffered = append(owner.buffered, key) - if owner.uiSession != 0 { + if owner.remote != nil { + owner.handle_remote_key(key) + } else if owner.uiSession != 0 { if !owner.handle_internal_key(key) { return false } @@ -157,7 +159,7 @@ func (owner *commandOwner) finishChord() bool { owner.listener.failures <- fmt.Errorf("replay unbound X11 chord: native error %d", int(result)) return false } - } else if !owner.learnMode && owner.uiSession == 0 { + } else if !owner.learnMode && owner.uiSession == 0 && owner.remote == nil { C.release_command_keyboard(owner.device) } owner.router.Reset() @@ -170,7 +172,7 @@ func (owner *commandOwner) finishChord() bool { return false } } - owner.listener.grabActive.Store(owner.learnMode || owner.uiSession != 0) + owner.listener.grabActive.Store(owner.learnMode || owner.uiSession != 0 || owner.remote != nil) owner.listener.uiEcho.Store(owner.uiSession != 0) owner.listener.learnMode.Store(owner.learnMode) owner.listener.learnEcho.Store(owner.learnMode) diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 59aa6a4..82e9c7c 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -695,6 +695,17 @@ void close_commands(CommandDevice *device) { XSync(device->display,False); XCloseDisplay(device->display); free(device->bindings); free(device); } +// Remote capture needs only the exclusive grab: no text translation or +// compose state, because keys leave the reader as logical transitions. +int acquire_remote_keyboard(CommandDevice *device) { + if (!device->active) { + int status=XGrabKeyboard(device->display,device->root,False,GrabModeAsync,GrabModeAsync,CurrentTime); + if (status!=GrabSuccess) return status; + device->active=1; + } + return 0; +} + int acquire_internal_keyboard(CommandDevice *device) { if (!device->active) { int status=XGrabKeyboard(device->display,device->root,False,GrabModeAsync,GrabModeAsync,CurrentTime); diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 32b5a02..b48681f 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -57,6 +57,7 @@ int command_lock_state(CommandDevice *device); void toggle_command_caps_lock(CommandDevice *device); int next_command_key(CommandDevice *device, CommandKey *key); int acquire_internal_keyboard(CommandDevice *device); +int acquire_remote_keyboard(CommandDevice *device); int acquire_local_keyboard(CommandDevice *device); int next_local_key(CommandDevice *device, XEvent *event); void release_local_keyboard(CommandDevice *device); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index c1b498a..a7bbe54 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -35,6 +35,7 @@ type commandOwner struct { ocrReview, dismissOCR bool ocrPending bool uiSession uint64 + remote *remoteCaptureOwner uiWindow, uiFocus C.Window lastLocks, lastFocusMatch C.int lastFocusCheck, drainDeadline time.Time @@ -68,6 +69,7 @@ func (owner *commandOwner) run() { if !owner.sync_internal_ui() { return } + owner.sync_remote_capture() if !owner.applyBindingUpdate() { return } @@ -95,6 +97,7 @@ func (owner *commandOwner) run() { // A changed XI2 hierarchy invalidates the local source set. A // removed device may never release its held keys; retire that chord. session := owner.uiSession + owner.end_remote_capture("local") clear(owner.held) owner.buffered = nil owner.router.Reset() @@ -198,7 +201,8 @@ func (owner *commandOwner) applyBindingUpdate() bool { } func (owner *commandOwner) resetForSecureInput() { - if owner.listener.secure.Load() && (owner.ocrReview || owner.device.commands != 0 || owner.device.active != 0 || len(owner.held) != 0) { + if owner.listener.secure.Load() && (owner.ocrReview || owner.device.commands != 0 || owner.device.active != 0 || len(owner.held) != 0 || owner.remote != nil) { + owner.end_remote_capture("local") C.release_command_keyboard(owner.device) C.set_command_keys(owner.device, 0) owner.listener.commandsApplied.Store(false) diff --git a/internal/platform/xorg/commands_remote.go b/internal/platform/xorg/commands_remote.go new file mode 100644 index 0000000..6b2e971 --- /dev/null +++ b/internal/platform/xorg/commands_remote.go @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +/* +#include "commands_native.h" +#include +*/ +import "C" + +import ( + "context" + "errors" + "strings" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +// remoteCaptureEvents bounds one capture's queued events. The reserve keeps +// room for the final release plan and end marker after an overflow. +const ( + remoteCaptureEvents = 512 + remoteCaptureReserve = input.RemoteHeldKeyLimit*2 + 2 +) + +type remoteCaptureUpdate struct { + ctx context.Context + session uint64 // Nonzero ends this session; zero begins capture. + prefix string + result chan remoteCaptureReply +} + +type remoteCaptureReply struct { + session uint64 + events <-chan input.RemoteCaptureEvent + err error +} + +// remoteCaptureOwner is confined to the command goroutine. It owns the +// exclusive grab for one capture lifetime, the repeat normalizer and the +// bypass router. Names are recorded at fresh press so a re-pressed withdrawn +// modifier carries the name it was first forwarded with. +type remoteCaptureOwner struct { + session uint64 + normalizer *input.RemoteRepeatNormalizer + router *input.RemoteCaptureRouter + events chan input.RemoteCaptureEvent + names [256]string + ending bool +} + +// RemoteCaptureActive reports whether keys currently leave for a remote peer. +// Local key echo and interruption must stay quiet while it is set. +func (listener *CommandListener) RemoteCaptureActive() bool { + return listener.remoteSession.Load() != 0 +} + +// 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 +// 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, "/") { + return 0, nil, errors.New("invalid remote capture command prefix") + } + reply, err := listener.update_remote_capture(ctx, 0, localPrefix) + return reply.session, reply.events, err +} + +// EndRemoteCapture stops this session if it is still active. Owned remote keys +// are released through the event stream before it closes. It cannot end a +// newer session. +func (listener *CommandListener) EndRemoteCapture(ctx context.Context, session uint64) error { + if session == 0 { + return errors.New("missing remote capture session") + } + _, err := listener.update_remote_capture(ctx, session, "") + return err +} + +func (listener *CommandListener) update_remote_capture(ctx context.Context, session uint64, prefix string) (remoteCaptureReply, error) { + listener.mutex.Lock() + done := listener.done + listener.mutex.Unlock() + if done == nil { + return remoteCaptureReply{}, errors.New("command listener is not running") + } + update := remoteCaptureUpdate{ctx: ctx, session: session, prefix: prefix, result: make(chan remoteCaptureReply, 1)} + select { + case listener.remoteUpdates <- update: + case <-ctx.Done(): + return remoteCaptureReply{}, ctx.Err() + case <-done: + return remoteCaptureReply{}, errors.New("command listener stopped") + } + select { + case reply := <-update.result: + return reply, reply.err + case <-done: + return remoteCaptureReply{}, errors.New("command listener stopped") + } +} + +// sync_remote_capture ends capture on reader policy and services begin/end +// requests between chords. The core escape never passes through here. +func (owner *commandOwner) sync_remote_capture() { + if owner.remote != nil && (!owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview) { + owner.end_remote_capture("local") + } + if owner.remote != nil { + owner.flush_remote(time.Now()) + } + if len(owner.held) != 0 { + return + } + select { + case update := <-owner.listener.remoteUpdates: + reply := remoteCaptureReply{err: update.ctx.Err()} + if reply.err == nil { + if update.session != 0 { + if owner.remote != nil && owner.remote.session == update.session { + owner.end_remote_capture("requested") + } + } else { + reply.session, reply.events, reply.err = owner.begin_remote_capture(update.prefix) + } + } + update.result <- reply + default: + } +} + +func (owner *commandOwner) begin_remote_capture(prefix string) (uint64, <-chan input.RemoteCaptureEvent, error) { + if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 || owner.remote != nil { + return 0, nil, errors.New("remote capture unavailable") + } + local := input.CommandBindings{} + for chord, command := range resolvedBindings(owner.layout, false, owner.overrides) { + if strings.HasPrefix(string(command), prefix) { + local[chord] = command + } + } + router, err := input.NewRemoteCaptureRouter(input.RemoteCaptureEscape, local, owner.symbols) + if err != nil { + return 0, nil, err + } + normalizer, err := input.NewRemoteRepeatNormalizer(input.RemoteRepeatReleaseDelay) + if err != nil { + return 0, nil, err + } + if status := C.acquire_remote_keyboard(owner.device); status != 0 { + return 0, nil, errors.New("remote keyboard capture failed") + } + session := owner.listener.remoteSequence.Add(1) + owner.remote = &remoteCaptureOwner{session: session, normalizer: normalizer, router: router, + events: make(chan input.RemoteCaptureEvent, remoteCaptureEvents)} + owner.listener.remoteSession.Store(session) + owner.listener.grabActive.Store(true) + return session, owner.remote.events, nil +} + +// end_remote_capture releases every owned remote key through the event +// stream, then the end marker, and closes the stream. Physically held keys +// keep the grab until their releases arrive, so none leak to an application +// that never received the press. +func (owner *commandOwner) end_remote_capture(reason string) { + capture := owner.remote + if capture == nil || capture.ending { + return + } + capture.ending = true + for _, transition := range capture.normalizer.Retire() { + owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}, 0) + } + for _, transition := range capture.router.Retire().Remote { + owner.send_remote(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: transition.Keycode}}) + } + capture.events <- input.RemoteCaptureEvent{Ended: reason} // Reserved capacity. + close(capture.events) + owner.remote = nil + owner.listener.remoteSession.Store(0) + if len(owner.held) == 0 { + C.release_command_keyboard(owner.device) + owner.listener.grabActive.Store(owner.learnMode || owner.uiSession != 0) + } else { + owner.bound = true + } +} + +// send_remote never blocks the X thread. Running into the reserve ends +// capture; the reserve still holds the release plan and end marker. +func (owner *commandOwner) send_remote(event input.RemoteCaptureEvent) bool { + capture := owner.remote + if capture == nil { + return false + } + if !capture.ending && len(capture.events) >= cap(capture.events)-remoteCaptureReserve && event.Key != nil && event.Key.Pressed { + owner.end_remote_capture("local") + return false + } + capture.events <- event + return true +} + +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) + } +} + +func (owner *commandOwner) handle_remote_key(key C.CommandKey) { + owner.bound = true + capture := owner.remote + event := input.Event{Keycode: uint8(key.code), Pressed: key.pressed != 0, Timestamp: uint32(key.timestamp)} + transitions, err := capture.normalizer.Observe(event, time.Now()) + for _, transition := range transitions { + if owner.remote == nil { + return + } + routed := input.RemoteCaptureKey{Keycode: transition.Keycode, Pressed: transition.Pressed, Repeat: transition.Repeat} + if transition.Pressed && !transition.Repeat { + routed.Symbol = uint32(key.symbol) + name := "" + if native := C.XKeysymToString(C.KeySym(key.symbol)); native != nil { + name = input.RemoteCaptureKeyName(C.GoString(native)) + } + capture.names[transition.Keycode] = name + routed.Unsupported = name == "" + } + owner.route_remote(routed, uint32(key.timestamp)) + } + if err != nil && owner.remote != nil { + owner.end_remote_capture("local") + } +} + +func (owner *commandOwner) route_remote(key input.RemoteCaptureKey, timestamp uint32) { + capture := owner.remote + if capture == nil { + return + } + result, err := capture.router.Route(key) + if err != nil { + owner.end_remote_capture("local") // No-op while already ending. + return + } + for _, transition := range result.Remote { + forwarded := &input.RemoteCaptureTransition{Code: transition.Keycode, Pressed: transition.Pressed, Repeat: transition.Repeat} + if transition.Pressed && !transition.Repeat { + forwarded.Name = capture.names[transition.Keycode] + } + if !owner.send_remote(input.RemoteCaptureEvent{Key: forwarded}) { + return + } + } + if capture.ending { + return + } + if result.Escape { + 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") + } + } +} diff --git a/internal/platform/xorg/commands_ui.go b/internal/platform/xorg/commands_ui.go index c300f96..f0ef377 100644 --- a/internal/platform/xorg/commands_ui.go +++ b/internal/platform/xorg/commands_ui.go @@ -86,7 +86,7 @@ func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSe } func (owner *commandOwner) begin_internal_ui_mode(localOnly bool) (uint64, error) { - if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 { + if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 || owner.remote != nil { return 0, errors.New("internal UI input unavailable") } var revert C.int diff --git a/internal/platform/xorg/remote_capture_native_test.go b/internal/platform/xorg/remote_capture_native_test.go new file mode 100644 index 0000000..e4e05bb --- /dev/null +++ b/internal/platform/xorg/remote_capture_native_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +// The focused xev window stands in for a local application: it must receive +// nothing while keys are captured for a remote peer. Keys are synthetic XTEST +// events on a private display; physical keyboards are not exercised. +// Run only through tests/native_remote_input.py. +func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" { + t.Skip("requires private Remote input harness") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + log, err := os.CreateTemp(t.TempDir(), "keys-") + if err != nil { + t.Fatal(err) + } + defer log.Close() + probe := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-name", "Remote capture probe", "-event", "keyboard") + probe.Stdout = log + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + var window string + for window == "" { + data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Remote capture probe$").Output() + if err == nil { + window = strings.TrimSpace(string(data)) + } + if ctx.Err() != nil { + t.Fatal("probe did not open") + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + const toggle = input.Command("tentacle/remote-1/toggle-remote-focus") + if err := listener.SetTentacleBindings(ctx, []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: toggle, Keys: []string{"Cthulhu+Ctrl+r"}}}); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool %v: %v %s", args, err, data) + } + } + session, events, err := listener.BeginRemoteCapture(ctx, "tentacle/remote-1/") + if err != nil { + t.Fatal(err) + } + if !listener.RemoteCaptureActive() || !listener.CommandGrabActive() { + t.Fatal("capture not reported active") + } + next := func() input.RemoteCaptureEvent { + t.Helper() + select { + case event, ok := <-events: + if !ok { + t.Fatal("capture stream closed early") + } + return event + case err := <-listener.Failures(): + t.Fatal(err) + case <-ctx.Done(): + t.Fatal("missing capture event") + } + return input.RemoteCaptureEvent{} + } + expect := func(name string, pressed, named bool) uint8 { + t.Helper() + event := next() + if event.Key == nil || event.Key.Pressed != pressed || event.Key.Repeat || named && event.Key.Name != name || !named && event.Key.Name != "" { + t.Fatalf("event %+v, want %s pressed=%t", event, name, pressed) + } + return event.Key.Code + } + run("key", "a") + a := expect("a", true, true) + if expect("", false, false) != a { + t.Fatal("release paired with another keycode") + } + run("key", "shift+Tab") + shift := expect("Shift_L", true, true) + tab := expect("Tab", true, true) + r1, r2 := expect("", false, false), expect("", false, false) + if r1 == r2 || (r1 != shift && r1 != tab) || (r2 != shift && r2 != tab) { + t.Fatal("Shift+Tab releases changed", shift, tab, r1, r2) + } + // A reader command chord goes to the remote reader. + run("key", "Insert+t") + expect("Insert", true, true) + expect("t", true, true) + expect("", false, false) + expect("", false, false) + // Remote's own shortcut stays local and withdraws the forwarded prefix. + 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") + } + select { + case event := <-listener.Commands(): + if event.Command != toggle { + t.Fatalf("local command = %s", event.Command) + } + case <-ctx.Done(): + t.Fatal("local Remote shortcut did not dispatch") + } + // 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) + } + if _, ok := <-events; ok { + t.Fatal("capture stream stayed open after escape") + } + for listener.RemoteCaptureActive() || listener.CommandGrabActive() { + if ctx.Err() != nil { + t.Fatal("escape did not release the grab") + } + time.Sleep(time.Millisecond) + } + if listener.EndRemoteCapture(ctx, session) != nil { + t.Fatal("ending a finished session failed") + } + run("key", "b") + + // Reader policy ends capture with keys held, releasing them remotely and + // keeping the grab until the physical release. + second, events, err := listener.BeginRemoteCapture(ctx, "tentacle/remote-1/") + if err != nil || second == session { + t.Fatal("fresh capture refused", err) + } + run("keydown", "Control_L") + control = expect("Control_L", true, true) + listener.SetSecureInputActive(true) + if expect("", false, false) != control { + t.Fatal("policy end did not release the owned modifier") + } + if event := next(); event.Ended != "local" { + t.Fatalf("policy end event = %+v", event) + } + run("keyup", "Control_L") + listener.SetSecureInputActive(false) + if _, _, err := listener.BeginRemoteCapture(ctx, "invalid"); err == nil { + t.Fatal("invalid prefix accepted") + } + time.Sleep(100 * time.Millisecond) + data, _ := os.ReadFile(log.Name()) + text := string(data) + if !strings.Contains(text, "(keysym 0x62, b)") { + t.Fatal("probe never received input after capture; log:", text) + } + for _, leaked := range []string{"(keysym 0x61, a)", "(keysym 0xff09, Tab)", "(keysym 0x74, t)", "(keysym 0x72, r)", "(keysym 0xffc8, F11)"} { + if strings.Contains(text, leaked) { + t.Fatalf("captured key %s reached the local application", leaked) + } + } +} diff --git a/tests/native_remote_input.py b/tests/native_remote_input.py index 3d06471..7b53217 100644 --- a/tests/native_remote_input.py +++ b/tests/native_remote_input.py @@ -395,7 +395,7 @@ def main(): # paths as well as the standalone recovery experiment. subprocess.run([ "go", "test", "-race", "./internal/platform/xorg", "-count=1", "-timeout=60s", - "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|RemoteRepeatNormalizesHeldKey|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$", + "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|RemoteRepeatNormalizesHeldKey|RemoteCaptureForwardsBypassesAndEscapes|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$", "-v"], cwd=Path(__file__).resolve().parents[1], check=True, env=dict(os.environ, CTHULHU_NATIVE_X11="1", CTHULHU_NATIVE_REMOTE_INPUT="1")) else: