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