From c234cb82a889e8427e336f954776d48df11d6333 Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:15:17 -0600 Subject: [PATCH] Add Xorg Remote controller capture with a core F11 escape The Xorg command owner gains an exclusive Remote capture mode. Native transitions pass through the repeat normalizer and capture router; forwarded keys leave as remote.input events, Remote's own shortcuts are dispatched locally, and F11 ends capture on the X thread. Reader policy, overflow or inconsistent input also end it. Ending releases forwarded keys first and keeps the grab until held keys are released. Local key echo is suppressed during capture. The app attaches the owner to sessions installed with keyboard-capture. Co-Authored-By: Claude Opus 5.5 --- docs/tentacle-remote-capture.md | 49 +++- internal/app/input_events.go | 6 + internal/app/tentacle_remote_capture.go | 133 +++++++++ internal/app/tentacle_remote_capture_test.go | 205 +++++++++++++ internal/app/tentacle_remote_keyboard_test.go | 8 + internal/app/tentacle_startup.go | 3 +- internal/app/tentacles.go | 3 +- internal/input/remote_capture.go | 47 +++ internal/input/remote_capture_test.go | 16 + internal/platform/xorg/commands.go | 4 + internal/platform/xorg/commands_chord.go | 10 +- internal/platform/xorg/commands_native.c | 11 + internal/platform/xorg/commands_native.h | 1 + internal/platform/xorg/commands_owner_loop.go | 6 +- internal/platform/xorg/commands_remote.go | 273 ++++++++++++++++++ internal/platform/xorg/commands_ui.go | 2 +- .../xorg/remote_capture_native_test.go | 182 ++++++++++++ tests/native_remote_input.py | 2 +- 18 files changed, 950 insertions(+), 11 deletions(-) create mode 100644 internal/app/tentacle_remote_capture.go create mode 100644 internal/app/tentacle_remote_capture_test.go create mode 100644 internal/platform/xorg/commands_remote.go create mode 100644 internal/platform/xorg/remote_capture_native_test.go 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: