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:
1 parent
2d5327adf4
commit
c234cb82a8
18 files changed
+950
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user