Add daemon client protocol frames

This commit is contained in:
2026-05-27 13:19:42 +01:00
parent b72a32bbc6
commit e63bdad5e1
4 changed files with 355 additions and 0 deletions

164
internal/protocol/frame.go Normal file
View File

@@ -0,0 +1,164 @@
// Package protocol defines the daemon/client control frames shared by
// transports. It intentionally contains data shapes only; app behavior stays
// in internal/app until the headless daemon split is complete.
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// FrameType identifies one protocol message kind.
type FrameType string
const (
FrameHello FrameType = "hello"
FrameAuthChallenge FrameType = "auth_challenge"
FrameAuthOK FrameType = "auth_ok"
FrameAttach FrameType = "attach"
FrameDetach FrameType = "detach"
FrameProjectList FrameType = "project_list"
FrameChrome FrameType = "chrome"
FramePaneSnapshot FrameType = "pane_snapshot"
FramePaneChunk FrameType = "pane_chunk"
FrameLifecycle FrameType = "lifecycle"
FrameAttention FrameType = "attention"
FrameTrustPrompt FrameType = "trust_prompt"
FrameInput FrameType = "input"
FrameFocus FrameType = "focus"
FrameSwitchProject FrameType = "switch_project"
FrameOpenProject FrameType = "open_project"
FramePaletteCommand FrameType = "palette_command"
FrameTrustResponse FrameType = "trust_response"
FrameResize FrameType = "resize"
)
// Frame is the transport envelope. Payload is deliberately raw JSON so
// network transports can frame without knowing every message type; loopback
// transports may pass the same bytes without JSON re-encoding.
type Frame struct {
Type FrameType `json:"type"`
RequestID string `json:"request_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewFrame marshals payload into a protocol frame.
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
b, err := json.Marshal(payload)
if err != nil {
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
}
return Frame{Type: typ, Payload: b}, nil
}
// Decode unmarshals f.Payload into v.
func Decode[T any](f Frame) (T, error) {
var v T
if len(f.Payload) == 0 {
return v, nil
}
if err := json.Unmarshal(f.Payload, &v); err != nil {
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
}
return v, nil
}
type Hello struct {
Version int `json:"version"`
DaemonID string `json:"daemon_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
TermSize Size `json:"term_size"`
}
type Detach struct {
ClientID string `json:"client_id,omitempty"`
}
type Size struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
type Project struct {
Key string `json:"key"`
Path string `json:"path"`
Name string `json:"name"`
LastActive time.Time `json:"last_active,omitempty"`
TabCount int `json:"tab_count"`
}
type ProjectList struct {
Projects []Project `json:"projects"`
}
type Chrome struct {
ProjectKey string `json:"project_key"`
Model json.RawMessage `json:"model"`
}
type PaneSnapshot struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type PaneChunk struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type LifecycleKind string
const (
LifecycleSpawned LifecycleKind = "spawned"
LifecycleExited LifecycleKind = "exited"
LifecycleClosed LifecycleKind = "closed"
LifecycleStateChanged LifecycleKind = "state_changed"
)
type Lifecycle struct {
Kind LifecycleKind `json:"kind"`
ProjectKey string `json:"project_key,omitempty"`
ChildID string `json:"child_id,omitempty"`
Child json.RawMessage `json:"child,omitempty"`
State string `json:"state,omitempty"`
}
type Input struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type Focus struct {
PaneID string `json:"pane_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type SwitchProject struct {
Key string `json:"key"`
}
type OpenProject struct {
Path string `json:"path"`
}
type PaletteCommand struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
}
type TrustResponse struct {
ProcessID string `json:"process_id"`
Preset string `json:"preset"`
Allow bool `json:"allow"`
}
type Resize struct {
Size Size `json:"size"`
}

View File

@@ -0,0 +1,67 @@
package protocol
import (
"sync"
)
const defaultLoopbackBuffer = 64
// NewLoopbackPair returns connected in-process transports. Frames cross the
// same Send/Recv boundary as network transports, but payload bytes are passed
// directly without JSON re-encoding.
func NewLoopbackPair() (client Transport, daemon Transport) {
c2d := make(chan Frame, defaultLoopbackBuffer)
d2c := make(chan Frame, defaultLoopbackBuffer)
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
}
type loopbackTransport struct {
send chan<- Frame
recv <-chan Frame
once sync.Once
done chan struct{}
}
func (t *loopbackTransport) init() {
if t.done == nil {
t.done = make(chan struct{})
}
}
func (t *loopbackTransport) Send(f Frame) error {
t.init()
select {
case <-t.done:
return ErrTransportClosed
case t.send <- cloneFrame(f):
return nil
}
}
func (t *loopbackTransport) Recv() (Frame, error) {
t.init()
select {
case <-t.done:
return Frame{}, ErrTransportClosed
case f, ok := <-t.recv:
if !ok {
return Frame{}, ErrTransportClosed
}
return f, nil
}
}
func (t *loopbackTransport) Close() error {
t.init()
t.once.Do(func() {
close(t.done)
})
return nil
}
func cloneFrame(f Frame) Frame {
if len(f.Payload) > 0 {
f.Payload = append([]byte(nil), f.Payload...)
}
return f
}

View File

@@ -0,0 +1,51 @@
package protocol
import "testing"
func TestLoopbackUsesFramePayload(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
if err != nil {
t.Fatalf("NewFrame: %v", err)
}
if err := client.Send(sent); err != nil {
t.Fatalf("Send: %v", err)
}
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Type != FrameInput {
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
}
payload, err := Decode[Input](got)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
t.Fatalf("payload = %#v", payload)
}
}
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
if err := client.Send(f); err != nil {
t.Fatalf("Send: %v", err)
}
f.Payload[0] = 'x'
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Payload[0] != '{' {
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
}
}

View File

@@ -0,0 +1,73 @@
package protocol
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
)
var ErrTransportClosed = errors.New("protocol: transport closed")
// Transport carries framed daemon/client protocol messages.
type Transport interface {
Send(Frame) error
Recv() (Frame, error)
Close() error
}
// ConnTransport is a JSON-lines implementation over a stream connection.
type ConnTransport struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
func NewConnTransport(conn net.Conn) *ConnTransport {
return &ConnTransport{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
}
func (t *ConnTransport) Send(f Frame) error {
if t == nil || t.conn == nil {
return ErrTransportClosed
}
b, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("protocol: encode frame: %w", err)
}
if _, err := t.w.Write(append(b, '\n')); err != nil {
return err
}
return t.w.Flush()
}
func (t *ConnTransport) Recv() (Frame, error) {
if t == nil || t.conn == nil {
return Frame{}, ErrTransportClosed
}
line, err := t.r.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return Frame{}, ErrTransportClosed
}
return Frame{}, err
}
var f Frame
if err := json.Unmarshal(line, &f); err != nil {
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
}
return f, nil
}
func (t *ConnTransport) Close() error {
if t == nil || t.conn == nil {
return nil
}
return t.conn.Close()
}