diff --git a/muxterm/protocol.go b/muxterm/protocol.go new file mode 100644 index 0000000..b5167fe --- /dev/null +++ b/muxterm/protocol.go @@ -0,0 +1,94 @@ +package main + +import "encoding/json" + +// --- Client-to-server message types --- + +// AttachMsg requests attaching to a named tmux session. +type AttachMsg struct { + Attach string `json:"attach"` +} + +// ResizeMsg requests a PTY resize. +type ResizeMsg struct { + Resize struct { + Cols int `json:"cols"` + Rows int `json:"rows"` + } `json:"resize"` +} + +// DetachMsg requests detaching from the current session. +type DetachMsg struct { + Detach bool `json:"detach"` +} + +// --- Server-to-client message types --- + +// AttachedMsg confirms a successful attach. +type AttachedMsg struct { + Attached string `json:"attached"` +} + +// ErrorMsg reports an error to the client. +type ErrorMsg struct { + Error string `json:"error"` +} + +// ExitedMsg reports that a session's process has exited. +type ExitedMsg struct { + Exited string `json:"exited"` +} + +// ParseControlMessage unmarshals a JSON control message and returns the +// message kind and a pointer to the typed value. Returns ("invalid", nil) +// for unparseable JSON and ("unknown", nil) for unrecognised keys. +func ParseControlMessage(data []byte) (string, interface{}) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return "invalid", nil + } + + if _, ok := raw["attach"]; ok { + var msg AttachMsg + if err := json.Unmarshal(data, &msg); err != nil { + return "invalid", nil + } + return "attach", &msg + } + + if _, ok := raw["resize"]; ok { + var msg ResizeMsg + if err := json.Unmarshal(data, &msg); err != nil { + return "invalid", nil + } + return "resize", &msg + } + + if _, ok := raw["detach"]; ok { + var msg DetachMsg + if err := json.Unmarshal(data, &msg); err != nil { + return "invalid", nil + } + return "detach", &msg + } + + return "unknown", nil +} + +// MarshalAttached returns JSON for an AttachedMsg. +func MarshalAttached(session string) []byte { + b, _ := json.Marshal(AttachedMsg{Attached: session}) + return b +} + +// MarshalError returns JSON for an ErrorMsg. +func MarshalError(msg string) []byte { + b, _ := json.Marshal(ErrorMsg{Error: msg}) + return b +} + +// MarshalExited returns JSON for an ExitedMsg. +func MarshalExited(session string) []byte { + b, _ := json.Marshal(ExitedMsg{Exited: session}) + return b +} \ No newline at end of file diff --git a/muxterm/protocol_test.go b/muxterm/protocol_test.go new file mode 100644 index 0000000..596e393 --- /dev/null +++ b/muxterm/protocol_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// --- Client-to-server message type tests --- + +func TestAttachMsgJSON(t *testing.T) { + msg := AttachMsg{Attach: "my-session"} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal AttachMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + if m["attach"] != "my-session" { + t.Errorf("expected attach=my-session, got %v", m["attach"]) + } +} + +func TestResizeMsgJSON(t *testing.T) { + msg := ResizeMsg{Resize: struct { + Cols int `json:"cols"` + Rows int `json:"rows"` + }{Cols: 80, Rows: 24}} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal ResizeMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + resize, ok := m["resize"].(map[string]interface{}) + if !ok { + t.Fatalf("expected resize to be a map, got %T", m["resize"]) + } + if resize["cols"].(float64) != 80 { + t.Errorf("expected cols=80, got %v", resize["cols"]) + } + if resize["rows"].(float64) != 24 { + t.Errorf("expected rows=24, got %v", resize["rows"]) + } +} + +func TestDetachMsgJSON(t *testing.T) { + msg := DetachMsg{Detach: true} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal DetachMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + if m["detach"] != true { + t.Errorf("expected detach=true, got %v", m["detach"]) + } +} + +// --- Server-to-client message type tests --- + +func TestAttachedMsgJSON(t *testing.T) { + msg := AttachedMsg{Attached: "sess1"} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal AttachedMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + if m["attached"] != "sess1" { + t.Errorf("expected attached=sess1, got %v", m["attached"]) + } +} + +func TestErrorMsgJSON(t *testing.T) { + msg := ErrorMsg{Error: "something broke"} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal ErrorMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + if m["error"] != "something broke" { + t.Errorf("expected error='something broke', got %v", m["error"]) + } +} + +func TestExitedMsgJSON(t *testing.T) { + msg := ExitedMsg{Exited: "sess1"} + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal ExitedMsg: %v", err) + } + var m map[string]interface{} + json.Unmarshal(data, &m) + if m["exited"] != "sess1" { + t.Errorf("expected exited=sess1, got %v", m["exited"]) + } +} + +// --- ParseControlMessage tests --- + +func TestParseControlMessage_Attach(t *testing.T) { + data := []byte(`{"attach":"dev"}`) + kind, val := ParseControlMessage(data) + if kind != "attach" { + t.Fatalf("expected kind=attach, got %q", kind) + } + msg, ok := val.(*AttachMsg) + if !ok { + t.Fatalf("expected *AttachMsg, got %T", val) + } + if msg.Attach != "dev" { + t.Errorf("expected Attach=dev, got %q", msg.Attach) + } +} + +func TestParseControlMessage_Resize(t *testing.T) { + data := []byte(`{"resize":{"cols":120,"rows":40}}`) + kind, val := ParseControlMessage(data) + if kind != "resize" { + t.Fatalf("expected kind=resize, got %q", kind) + } + msg, ok := val.(*ResizeMsg) + if !ok { + t.Fatalf("expected *ResizeMsg, got %T", val) + } + if msg.Resize.Cols != 120 || msg.Resize.Rows != 40 { + t.Errorf("expected 120x40, got %dx%d", msg.Resize.Cols, msg.Resize.Rows) + } +} + +func TestParseControlMessage_Detach(t *testing.T) { + data := []byte(`{"detach":true}`) + kind, val := ParseControlMessage(data) + if kind != "detach" { + t.Fatalf("expected kind=detach, got %q", kind) + } + msg, ok := val.(*DetachMsg) + if !ok { + t.Fatalf("expected *DetachMsg, got %T", val) + } + if !msg.Detach { + t.Errorf("expected Detach=true") + } +} + +func TestParseControlMessage_Unknown(t *testing.T) { + data := []byte(`{"foo":"bar"}`) + kind, val := ParseControlMessage(data) + if kind != "unknown" { + t.Errorf("expected kind=unknown, got %q", kind) + } + if val != nil { + t.Errorf("expected nil val for unknown, got %v", val) + } +} + +func TestParseControlMessage_Invalid(t *testing.T) { + data := []byte(`not json at all`) + kind, val := ParseControlMessage(data) + if kind != "invalid" { + t.Errorf("expected kind=invalid, got %q", kind) + } + if val != nil { + t.Errorf("expected nil val for invalid, got %v", val) + } +} + +// --- Marshal function tests --- + +func TestMarshalAttached(t *testing.T) { + data := MarshalAttached("sess1") + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["attached"] != "sess1" { + t.Errorf("expected attached=sess1, got %v", m["attached"]) + } +} + +func TestMarshalError(t *testing.T) { + data := MarshalError("bad thing") + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["error"] != "bad thing" { + t.Errorf("expected error='bad thing', got %v", m["error"]) + } +} + +func TestMarshalExited(t *testing.T) { + data := MarshalExited("sess1") + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["exited"] != "sess1" { + t.Errorf("expected exited=sess1, got %v", m["exited"]) + } +} \ No newline at end of file