diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go new file mode 100644 index 0000000..26e5964 --- /dev/null +++ b/internal/protocol/frame.go @@ -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"` +} diff --git a/internal/protocol/loopback.go b/internal/protocol/loopback.go new file mode 100644 index 0000000..d8f61ae --- /dev/null +++ b/internal/protocol/loopback.go @@ -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 +} diff --git a/internal/protocol/loopback_test.go b/internal/protocol/loopback_test.go new file mode 100644 index 0000000..ab001d9 --- /dev/null +++ b/internal/protocol/loopback_test.go @@ -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)) + } +} diff --git a/internal/protocol/transport.go b/internal/protocol/transport.go new file mode 100644 index 0000000..8d431b5 --- /dev/null +++ b/internal/protocol/transport.go @@ -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() +}