Files
cthulhu/internal/app/tentacle_remote_capture_test.go
Chase CichorzandClaude Opus 5.5 c234cb82a8 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>
2026-11-06 16:06:38 -05:00

206 lines
7.2 KiB
Go

// 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")
}
}