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>
206 lines
7.2 KiB
Go
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")
|
|
}
|
|
}
|