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 <noreply@anthropic.com>
This commit is contained in:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent 2d5327adf4
commit c234cb82a8
18 files changed
+950 -11

No files matched your search

+6
View File
@@ -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
+133
View File
@@ -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
}
@@ -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")
}
}
@@ -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
+2 -1
View File
@@ -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():
+2 -1
View File
@@ -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():