Add Remote controller capture IPC to the host and both SDKs
remote.focus lets a controller keyboard-capture lease ask core to enter or leave remote focus; the reply acknowledges admission only. Core reports the actual state with remote.focus events and forwards routed transitions as remote.input events carrying the source keycode and, on fresh presses, a canonical key name. A full focus mailbox revokes the lease; revocation drains pending focus work. No native capture owner is attached yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27017bef11
commit
2d5327adf4
10 files changed
+583
-10
No files matched your search
@@ -191,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.remoteCapture != nil {
|
||||
drainCapture:
|
||||
for {
|
||||
select {
|
||||
case <-session.remoteCapture:
|
||||
default:
|
||||
break drainCapture
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.remoteKeys != nil {
|
||||
drainKeys:
|
||||
for {
|
||||
@@ -280,6 +290,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
|
||||
return session.remote_present_locked(params)
|
||||
case "remote.key":
|
||||
return session.remote_key_locked(params)
|
||||
case "remote.focus":
|
||||
return session.remote_focus_locked(params)
|
||||
case "remote.open":
|
||||
var request tentaclewire.RemoteOpen
|
||||
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
// RemoteFocusDelivery is an admitted controller focus request. The reader's
|
||||
// capture owner must recheck Context and RemoteContext(keyboard-capture) and
|
||||
// its own focus/security policy before acquiring capture, and must report the
|
||||
// actual result with PublishRemoteFocus. A child request never proves that
|
||||
// capture is physically active.
|
||||
type RemoteFocusDelivery struct {
|
||||
Context context.Context
|
||||
Lease tentaclewire.RemoteLease
|
||||
Sequence uint64
|
||||
Remote bool
|
||||
}
|
||||
|
||||
// EnableRemoteCapture attaches one reader-owned controller capture mailbox.
|
||||
// It performs no native setup and grants no authority.
|
||||
func (session *Session) EnableRemoteCapture() <-chan RemoteFocusDelivery {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.remoteCapture == nil {
|
||||
session.remoteCapture = make(chan RemoteFocusDelivery, tentaclewire.RemoteFocusQueueSize)
|
||||
}
|
||||
return session.remoteCapture
|
||||
}
|
||||
|
||||
func (session *Session) remote_focus_locked(params json.RawMessage) (any, *RPCError) {
|
||||
var request tentaclewire.RemoteFocusRequest
|
||||
if decode_strict(params, &request) != nil {
|
||||
return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"}
|
||||
}
|
||||
lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch}
|
||||
access := session.remote_access_locked(lease, "keyboard-capture")
|
||||
if access == nil || access.role != "controller" || request.Sequence <= access.sequence {
|
||||
return nil, &RPCError{Code: -32001, Message: "Session permission denied"}
|
||||
}
|
||||
if session.remoteCapture == nil {
|
||||
return nil, &RPCError{Code: -32601, Message: "Remote keyboard capture unavailable"}
|
||||
}
|
||||
access.sequence = request.Sequence
|
||||
select {
|
||||
case session.remoteCapture <- RemoteFocusDelivery{Context: access.ctx, Lease: lease, Sequence: request.Sequence, Remote: request.Remote}:
|
||||
return struct{}{}, nil
|
||||
default:
|
||||
// A lost "local" request must never leave capture active.
|
||||
session.revoke_remote_locked("local", true)
|
||||
return nil, &RPCError{Code: -32002, Message: "Remote focus queue full"}
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) capture_access_locked(lease tentaclewire.RemoteLease) *remoteAccess {
|
||||
access := session.remote_access_locked(lease, "keyboard-capture")
|
||||
if access == nil || access.role != "controller" {
|
||||
return nil
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
// PublishRemoteInput forwards one captured transition. Origin is the reader's
|
||||
// exclusive capture owner, never child RPC or network input. A full event
|
||||
// queue fails the session: losing a release must not leave a healthy stream.
|
||||
func (session *Session) PublishRemoteInput(lease tentaclewire.RemoteLease, key tentaclewire.RemoteCapturedKey) error {
|
||||
if !tentaclewire.ValidRemoteCapturedKey(key) {
|
||||
return ErrPermission
|
||||
}
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.capture_access_locked(lease) == nil {
|
||||
return ErrPermission
|
||||
}
|
||||
event := tentaclewire.SessionEvent{Kind: "remote.input", Lease: lease.Lease, Epoch: lease.Epoch, Input: &key}
|
||||
if !session.remote_event_locked(&event) {
|
||||
session.fail_locked(ErrLimit)
|
||||
return ErrLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishRemoteFocus reports the actual capture state for this lease.
|
||||
// Reporting local focus is allowed after the lease was revoked only through
|
||||
// the revocation event itself, so a retired lease returns ErrPermission.
|
||||
func (session *Session) PublishRemoteFocus(lease tentaclewire.RemoteLease, focus, reason string) error {
|
||||
if !tentaclewire.ValidRemoteFocusEvent(focus, reason) {
|
||||
return ErrPermission
|
||||
}
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.capture_access_locked(lease) == nil {
|
||||
return ErrPermission
|
||||
}
|
||||
event := tentaclewire.SessionEvent{Kind: "remote.focus", Lease: lease.Lease, Epoch: lease.Epoch, Focus: focus, Reason: reason}
|
||||
if !session.remote_event_locked(&event) {
|
||||
session.fail_locked(ErrLimit)
|
||||
return ErrLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func focusRequest(lease tentaclewire.RemoteLease, sequence uint64, remote bool) tentaclewire.RemoteFocusRequest {
|
||||
return tentaclewire.RemoteFocusRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Remote: remote}
|
||||
}
|
||||
|
||||
func nextSDKEvent(t *testing.T, session *Session) tentaclewire.SessionEvent {
|
||||
t.Helper()
|
||||
var event tentaclewire.SessionEvent
|
||||
if err := json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func TestRemoteCaptureSDKFocusAndInputEvents(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
session := startSessionSDK(t, manager, language, "capture", "keyboard-capture")
|
||||
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present", "keyboard-capture"})
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32601 {
|
||||
t.Fatal("focus admitted without capture owner", code)
|
||||
}
|
||||
deliveries := session.EnableRemoteCapture()
|
||||
if session.EnableRemoteCapture() != deliveries {
|
||||
t.Fatal("attachment created another capture mailbox")
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != 0 {
|
||||
t.Fatal("valid focus request refused", code)
|
||||
}
|
||||
delivery := <-deliveries
|
||||
if !delivery.Remote || delivery.Lease != lease || delivery.Sequence != 1 || delivery.Context.Err() != nil {
|
||||
t.Fatal("focus delivery changed identity or lifetime", delivery)
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, false)); code != -32001 {
|
||||
t.Fatal("replayed focus sequence accepted", code)
|
||||
}
|
||||
if err := session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event := nextSDKEvent(t, session); event.Kind != "remote.focus" || event.Focus != "remote" || event.Reason != "requested" || event.Lease != lease.Lease {
|
||||
t.Fatal("bad focus event", event)
|
||||
}
|
||||
keys := []tentaclewire.RemoteCapturedKey{
|
||||
{Code: 50, Name: "Shift_L", Pressed: true},
|
||||
{Code: 38, Name: "a", Pressed: true},
|
||||
{Code: 38, Pressed: true, Repeat: true},
|
||||
{Code: 38},
|
||||
{Code: 50},
|
||||
}
|
||||
for _, key := range keys {
|
||||
if err := session.PublishRemoteInput(lease, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
event := nextSDKEvent(t, session)
|
||||
if event.Kind != "remote.input" || event.Input == nil || *event.Input != key || event.Epoch != lease.Epoch {
|
||||
t.Fatal("captured key changed in transit", event)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []tentaclewire.RemoteCapturedKey{
|
||||
{Code: 7, Name: "a", Pressed: true},
|
||||
{Code: 38, Name: "A", Pressed: true},
|
||||
{Code: 38, Name: "a"}, // Release carries no name.
|
||||
{Code: 38, Repeat: true},
|
||||
} {
|
||||
if !errors.Is(session.PublishRemoteInput(lease, invalid), ErrPermission) {
|
||||
t.Fatal("invalid captured key published", invalid)
|
||||
}
|
||||
}
|
||||
if !errors.Is(session.PublishRemoteFocus(lease, "remote", "network"), ErrPermission) {
|
||||
t.Fatal("unknown focus reason published")
|
||||
}
|
||||
session.RevokeRemote()
|
||||
if event := nextSDKEvent(t, session); event.Kind != "remote.revoked" {
|
||||
t.Fatal("missing revocation", event)
|
||||
}
|
||||
if !errors.Is(session.PublishRemoteInput(lease, keys[0]), ErrPermission) || !errors.Is(session.PublishRemoteFocus(lease, "local", "local"), ErrPermission) {
|
||||
t.Fatal("retired lease published capture events")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCaptureSDKPermissionAndParameters(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
session := startSessionSDK(t, manager, language, "capture", "keyboard-capture", "keyboard-inject")
|
||||
deliveries := session.EnableRemoteCapture()
|
||||
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32001 {
|
||||
t.Fatal("speech-only controller requested capture", code)
|
||||
}
|
||||
if !errors.Is(session.PublishRemoteInput(lease, tentaclewire.RemoteCapturedKey{Code: 38, Name: "a", Pressed: true}), ErrPermission) {
|
||||
t.Fatal("input published without keyboard-capture")
|
||||
}
|
||||
lease = speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32001 {
|
||||
t.Fatal("controlled role requested capture", code)
|
||||
}
|
||||
lease = speech_fixture_lease(t, session, "controller", []string{"keyboard-capture"})
|
||||
data, _ := json.Marshal(focusRequest(lease, 1, true))
|
||||
for _, raw := range []string{
|
||||
strings.Replace(string(data), `"remote":true`, `"remote":null`, 1),
|
||||
strings.Replace(string(data), `,"remote":true`, ``, 1),
|
||||
strings.Replace(string(data), `"sequence":1`, `"sequence":1,"origin":"local"`, 1),
|
||||
} {
|
||||
if _, code := remoteCall(t, session, "remote.focus", json.RawMessage(raw)); code != -32602 {
|
||||
t.Fatal("invalid focus parameters admitted", code, raw)
|
||||
}
|
||||
}
|
||||
if len(deliveries) != 0 {
|
||||
t.Fatal("denied focus reached the owner")
|
||||
}
|
||||
for sequence := uint64(1); sequence <= tentaclewire.RemoteFocusQueueSize; sequence++ {
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, sequence, sequence%2 == 1)); code != 0 {
|
||||
t.Fatal("focus request refused before saturation", code)
|
||||
}
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 99, false)); code != -32002 {
|
||||
t.Fatal("full focus queue did not fail", code)
|
||||
}
|
||||
if _, err := session.RemoteContext(lease, "keyboard-capture"); err == nil {
|
||||
t.Fatal("full focus queue left capture consent active")
|
||||
}
|
||||
if len(deliveries) != 0 {
|
||||
t.Fatal("revocation did not drain pending focus work")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ type Session struct {
|
||||
remoteApproval *RemoteApproval
|
||||
remoteSpeech chan RemoteSpeechDelivery
|
||||
remoteKeys chan RemoteKeyDelivery
|
||||
remoteCapture chan RemoteFocusDelivery
|
||||
}
|
||||
|
||||
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentaclewire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
// RemoteFocusQueueSize bounds pending controller focus requests per session.
|
||||
const RemoteFocusQueueSize = 4
|
||||
|
||||
// Focus states and the reasons a controller focus event can report.
|
||||
const (
|
||||
RemoteFocusLocal = "local"
|
||||
RemoteFocusRemote = "remote"
|
||||
)
|
||||
|
||||
var remoteFocusReasons = map[string]bool{
|
||||
"requested": true, // The child asked for this state.
|
||||
"escape": true, // The core escape returned to local focus.
|
||||
"local": true, // Reader policy (lock, handoff, reader UI) ended capture.
|
||||
"unavailable": true, // Capture could not be acquired.
|
||||
}
|
||||
|
||||
// RemoteCapturedKey is one controller transition from the reader's exclusive
|
||||
// capture, after repeat normalization and local-chord routing. Code is the
|
||||
// source Xorg keycode, used only to pair repeats and releases. Name is the
|
||||
// canonical unshifted key name and is present only on a fresh press; releases
|
||||
// and repeats are matched by Code so a layout change cannot strand a key.
|
||||
type RemoteCapturedKey struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Pressed bool `json:"pressed"`
|
||||
Repeat bool `json:"repeat,omitempty"`
|
||||
}
|
||||
|
||||
func ValidRemoteCapturedKey(key RemoteCapturedKey) bool {
|
||||
if key.Code < 8 || key.Code > 255 {
|
||||
return false
|
||||
}
|
||||
if key.Pressed && !key.Repeat {
|
||||
return input.ValidRemoteKeyName(key.Name)
|
||||
}
|
||||
return key.Name == "" && (key.Pressed || !key.Repeat)
|
||||
}
|
||||
|
||||
func (key *RemoteCapturedKey) UnmarshalJSON(data []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if DecodeStrict(data, &fields) != nil || fields["code"] == nil || fields["pressed"] == nil {
|
||||
return ErrProtocol
|
||||
}
|
||||
type plain RemoteCapturedKey
|
||||
var value plain
|
||||
if DecodeStrict(data, &value) != nil || !ValidRemoteCapturedKey(RemoteCapturedKey(value)) {
|
||||
return ErrProtocol
|
||||
}
|
||||
*key = RemoteCapturedKey(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoteFocusRequest asks core to enter or leave remote focus for the current
|
||||
// controller keyboard-capture lease. The reply acknowledges admission only;
|
||||
// the actual state is reported by a remote.focus event.
|
||||
type RemoteFocusRequest struct {
|
||||
RemoteOperation
|
||||
Remote bool `json:"remote"`
|
||||
}
|
||||
|
||||
func ValidRemoteFocusRequest(request RemoteFocusRequest) bool {
|
||||
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0
|
||||
}
|
||||
|
||||
func (request *RemoteFocusRequest) UnmarshalJSON(data []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if DecodeStrict(data, &fields) != nil || len(fields) != 4 || fields["lease"] == nil || fields["epoch"] == nil ||
|
||||
fields["sequence"] == nil || fields["remote"] == nil {
|
||||
return ErrProtocol
|
||||
}
|
||||
var value struct {
|
||||
RemoteOperation
|
||||
Remote *bool `json:"remote"`
|
||||
}
|
||||
if DecodeStrict(data, &value) != nil || value.Remote == nil {
|
||||
return ErrProtocol
|
||||
}
|
||||
result := RemoteFocusRequest{RemoteOperation: value.RemoteOperation, Remote: *value.Remote}
|
||||
if !ValidRemoteFocusRequest(result) {
|
||||
return ErrProtocol
|
||||
}
|
||||
*request = result
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidRemoteFocusEvent(focus, reason string) bool {
|
||||
return (focus == RemoteFocusLocal || focus == RemoteFocusRemote) && remoteFocusReasons[reason]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentaclewire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoteCapturedKeyValidation(t *testing.T) {
|
||||
for _, key := range []RemoteCapturedKey{
|
||||
{Code: 8, Name: "a", Pressed: true}, {Code: 255, Name: "Shift_R", Pressed: true},
|
||||
{Code: 38, Pressed: true, Repeat: true}, {Code: 38},
|
||||
} {
|
||||
if !ValidRemoteCapturedKey(key) {
|
||||
t.Fatal("valid key rejected", key)
|
||||
}
|
||||
data, _ := json.Marshal(key)
|
||||
var decoded RemoteCapturedKey
|
||||
if json.Unmarshal(data, &decoded) != nil || decoded != key {
|
||||
t.Fatal("round trip changed key", key)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"code":7,"name":"a","pressed":true}`, `{"code":256,"name":"a","pressed":true}`,
|
||||
`{"code":38,"name":"Prior","pressed":true}`, `{"code":38,"name":"a","pressed":false}`,
|
||||
`{"code":38,"pressed":false,"repeat":true}`, `{"code":38,"pressed":true}`,
|
||||
`{"name":"a","pressed":true}`, `{"code":38,"name":"a"}`,
|
||||
`{"code":38,"name":"a","pressed":true,"origin":"local"}`, `{"code":38,"code":39,"name":"a","pressed":true}`,
|
||||
} {
|
||||
var key RemoteCapturedKey
|
||||
if json.Unmarshal([]byte(raw), &key) == nil {
|
||||
t.Fatal("invalid key decoded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteFocusRequestAndEventValidation(t *testing.T) {
|
||||
lease := strings.Repeat("a", 32)
|
||||
var request RemoteFocusRequest
|
||||
if json.Unmarshal([]byte(`{"lease":"`+lease+`","epoch":1,"sequence":2,"remote":false}`), &request) != nil || request.Remote || request.Sequence != 2 {
|
||||
t.Fatal("valid focus request rejected", request)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"lease":"` + lease + `","epoch":1,"sequence":2}`,
|
||||
`{"lease":"` + lease + `","epoch":1,"sequence":0,"remote":true}`,
|
||||
`{"lease":"short","epoch":1,"sequence":2,"remote":true}`,
|
||||
`{"lease":"` + lease + `","epoch":1,"sequence":2,"remote":"yes"}`,
|
||||
} {
|
||||
if json.Unmarshal([]byte(raw), &request) == nil {
|
||||
t.Fatal("invalid focus request decoded", raw)
|
||||
}
|
||||
}
|
||||
key := RemoteCapturedKey{Code: 38, Name: "a", Pressed: true}
|
||||
valid := []SessionEvent{
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease, Input: &key},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "remote", Reason: "requested"},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "local", Reason: "escape"},
|
||||
}
|
||||
for _, event := range valid {
|
||||
if !ValidSessionEvent(event) {
|
||||
t.Fatal("valid event rejected", event)
|
||||
}
|
||||
}
|
||||
invalid := []SessionEvent{
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease, Input: &key, Focus: "remote"},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "remote", Reason: "network"},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "elsewhere", Reason: "requested"},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.revoked", Lease: lease, Reason: "closed", Input: &key},
|
||||
{Sequence: 1, Epoch: 1, Kind: "remote.revoked", Lease: lease, Reason: "closed", Focus: "local"},
|
||||
}
|
||||
for _, event := range invalid {
|
||||
if ValidSessionEvent(event) {
|
||||
t.Fatal("invalid event accepted", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,15 +66,17 @@ func ValidNegotiation(minor int, apis, capabilities []string) bool {
|
||||
// SessionEvent carries lifecycle metadata and explicitly typed output. Output
|
||||
// observation still requires a reader owner and a live role-bound lease.
|
||||
type SessionEvent struct {
|
||||
Sequence uint64 `json:"sequence"`
|
||||
Kind string `json:"kind"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Lease string `json:"lease,omitempty"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Grants []string `json:"grants,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Speech *RemoteSpeech `json:"speech,omitempty"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
Kind string `json:"kind"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Lease string `json:"lease,omitempty"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Grants []string `json:"grants,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Speech *RemoteSpeech `json:"speech,omitempty"`
|
||||
Input *RemoteCapturedKey `json:"input,omitempty"`
|
||||
Focus string `json:"focus,omitempty"`
|
||||
}
|
||||
|
||||
func ValidRole(role string) bool { return role == "controller" || role == "controlled" }
|
||||
@@ -95,11 +97,18 @@ func ValidSessionEvent(event SessionEvent) bool {
|
||||
if event.Sequence == 0 || event.Epoch == 0 {
|
||||
return false
|
||||
}
|
||||
if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" {
|
||||
return false
|
||||
}
|
||||
switch event.Kind {
|
||||
case "remote.action":
|
||||
return event.Speech == nil && len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants)
|
||||
case "remote.revoked":
|
||||
return event.Speech == nil && ((event.Action == "" && len(event.Lease) == 32) || (len(event.Action) == 32 && event.Lease == "")) && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason)
|
||||
case "remote.input":
|
||||
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech == nil && event.Input != nil && ValidRemoteCapturedKey(*event.Input)
|
||||
case "remote.focus":
|
||||
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Speech == nil && ValidRemoteFocusEvent(event.Focus, event.Reason)
|
||||
case "remote.output":
|
||||
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user