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:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent 27017bef11
commit 2d5327adf4
10 files changed
+583 -10

No files matched your search

+100
View File
@@ -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)
}
}
}
+18 -9
View File
@@ -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)
}