use std::{
ffi::{OsStr, OsString},
os::unix::ffi::{OsStrExt, OsStringExt},
path::{Path, PathBuf},
time::Duration,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN};
pub const PROTOCOL_VERSION: u32 = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct LaunchContext {
pub env: Vec<(OsString, OsString)>,
pub cwd: PathBuf,
}
impl LaunchContext {
pub fn here() -> LaunchContext {
LaunchContext {
env: std::env::vars_os().collect(),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
}
pub fn env_get<'a>(env: &'a [(OsString, OsString)], key: &str) -> Option<&'a OsStr> {
env.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_os_str())
}
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Spawn {
command: String,
cwd: PathBuf,
group: Option<String>,
},
Kill { id: u64 },
Remove { id: u64 },
Restart { id: u64 },
Tag { id: u64, on: bool },
SetGroup { id: u64, group: Option<String> },
SetName { id: u64, name: Option<String> },
Resize { rows: u16, cols: u16 },
Watch { id: Option<u64> },
Input { id: u64, bytes: Vec<u8> },
Paste { id: u64, bytes: Vec<u8> },
Mouse {
id: u64,
kind: MouseKind,
col: u16,
row: u16,
},
Key { id: u64, code: Key, mods: Mods },
Scrollback { id: u64, action: ScrollAction },
SaveSession { name: String },
LoadSession { name: String },
ListSessions,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAction {
Up(u16),
Down(u16),
Top,
Live,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseBtn {
Left = 0,
Middle = 1,
Right = 2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseKind {
WheelUp,
WheelDown,
Press(MouseBtn),
Drag(MouseBtn),
Release(MouseBtn),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Char(char),
F(u8),
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Insert,
Delete,
Enter,
Tab,
BackTab,
Backspace,
Esc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Mods {
pub shift: bool,
pub alt: bool,
pub ctrl: bool,
}
impl Mods {
pub fn param(self) -> Option<u8> {
let bits = self.shift as u8 + 2 * self.alt as u8 + 4 * self.ctrl as u8;
(bits != 0).then_some(1 + bits)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
HelloOk,
Tasks(Vec<TaskView>),
Screen(ScreenView),
Status(String),
Sessions(Vec<String>),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Lifecycle {
Active,
Idle,
Ok,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PreviewSource {
Floor,
Marker,
Title,
Anchor,
}
impl PreviewSource {
pub fn label(self) -> &'static str {
match self {
PreviewSource::Floor => "floor",
PreviewSource::Marker => "marker",
PreviewSource::Title => "title",
PreviewSource::Anchor => "anchor",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Preview {
pub text: String,
pub source: PreviewSource,
pub rule: Option<&'static str>,
pub frozen: bool,
}
impl Preview {
pub(crate) fn floor(text: String) -> Preview {
Preview {
text,
source: PreviewSource::Floor,
rule: None,
frozen: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskView {
pub id: u64,
pub command: String,
pub cwd: PathBuf,
pub tagged: bool,
pub group: Option<String>,
pub name: Option<String>,
pub lifecycle: Lifecycle,
pub parked: bool,
pub preview: Preview,
pub started_ago: Duration,
pub quiet_ago: Option<Duration>,
pub finished_ago: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScreenView {
pub id: u64,
pub lines: Vec<String>,
pub formatted: Vec<u8>,
pub cursor: (u16, u16),
pub hide_cursor: bool,
pub wants_mouse: bool,
pub alt_screen: bool,
pub alt_scroll: bool,
pub scrollback: usize,
}
fn os_b64(s: &OsStr) -> String {
B64.encode(s.as_bytes())
}
fn os_from_b64(v: &jzon::JsonValue) -> Option<OsString> {
Some(OsString::from_vec(B64.decode(v.as_str()?).ok()?))
}
fn path_b64(p: &Path) -> String {
os_b64(p.as_os_str())
}
fn path_from_b64(v: &jzon::JsonValue) -> Option<PathBuf> {
Some(PathBuf::from(os_from_b64(v)?))
}
fn u16_from(v: &jzon::JsonValue) -> Option<u16> {
u16::try_from(v.as_u64()?).ok()
}
fn bool_flag(v: &jzon::JsonValue) -> Option<bool> {
if v.is_null() {
return Some(false);
}
v.as_bool()
}
fn opt_str(v: &jzon::JsonValue) -> Option<Option<String>> {
if v.is_null() {
return Some(None);
}
Some(Some(v.as_str()?.to_string()))
}
fn insert_opt_str(o: &mut jzon::JsonValue, key: &str, val: &Option<String>) {
if let Some(s) = val {
let _ = o.insert(key, s.as_str());
}
}
fn opt_ms(v: &jzon::JsonValue) -> Option<Option<Duration>> {
if v.is_null() {
return Some(None);
}
Some(Some(Duration::from_millis(v.as_u64()?)))
}
fn insert_opt_ms(o: &mut jzon::JsonValue, key: &str, val: Option<Duration>) {
if let Some(d) = val {
let _ = o.insert(key, d.as_millis() as u64);
}
}
fn str_vec(v: &jzon::JsonValue) -> Option<Vec<String>> {
let mut out = Vec::with_capacity(v.len());
for m in v.members() {
out.push(m.as_str()?.to_string());
}
Some(out)
}
fn lifecycle_str(l: Lifecycle) -> &'static str {
match l {
Lifecycle::Active => "active",
Lifecycle::Idle => "idle",
Lifecycle::Ok => "ok",
Lifecycle::Failed => "failed",
}
}
fn lifecycle_from(s: &str) -> Option<Lifecycle> {
match s {
"active" => Some(Lifecycle::Active),
"idle" => Some(Lifecycle::Idle),
"ok" => Some(Lifecycle::Ok),
"failed" => Some(Lifecycle::Failed),
_ => None,
}
}
fn source_from(s: &str) -> Option<PreviewSource> {
match s {
"floor" => Some(PreviewSource::Floor),
"marker" => Some(PreviewSource::Marker),
"title" => Some(PreviewSource::Title),
"anchor" => Some(PreviewSource::Anchor),
_ => None,
}
}
pub fn encode_hello(ctx: &LaunchContext) -> (u8, Vec<u8>) {
let mut o = jzon::JsonValue::new_object();
let _ = o.insert("v", PROTOCOL_VERSION);
let _ = o.insert("cwd", path_b64(&ctx.cwd));
let mut pairs = jzon::JsonValue::new_array();
for (k, v) in &ctx.env {
let mut pair = jzon::JsonValue::new_array();
let _ = pair.push(os_b64(k));
let _ = pair.push(os_b64(v));
let _ = pairs.push(pair);
}
let _ = o.insert("env", pairs);
(KIND_HELLO, o.dump().into_bytes())
}
pub fn decode_hello(kind: u8, payload: &[u8]) -> Option<(u32, LaunchContext)> {
if kind != KIND_HELLO {
return None;
}
let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?;
let mut env = Vec::new();
for pair in v["env"].members() {
env.push((os_from_b64(&pair[0])?, os_from_b64(&pair[1])?));
}
Some((
v["v"].as_u32()?,
LaunchContext {
env,
cwd: path_from_b64(&v["cwd"])?,
},
))
}
pub fn hello_version(kind: u8, payload: &[u8]) -> Option<u32> {
let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?;
match kind {
KIND_HELLO => v["v"].as_u32(),
KIND_CONTROL if v["t"].as_str() == Some("hello") => v["v"].as_u32(),
_ => None,
}
}
pub fn encode_command(cmd: &Command) -> (u8, Vec<u8>) {
let mut o = jzon::JsonValue::new_object();
match cmd {
Command::Spawn {
command,
cwd,
group,
} => {
let _ = o.insert("t", "spawn");
let _ = o.insert("command", command.as_str());
let _ = o.insert("cwd", path_b64(cwd));
insert_opt_str(&mut o, "group", group);
}
Command::Kill { id } => {
let _ = o.insert("t", "kill");
let _ = o.insert("id", *id);
}
Command::Remove { id } => {
let _ = o.insert("t", "remove");
let _ = o.insert("id", *id);
}
Command::Restart { id } => {
let _ = o.insert("t", "restart");
let _ = o.insert("id", *id);
}
Command::Tag { id, on } => {
let _ = o.insert("t", "tag");
let _ = o.insert("id", *id);
let _ = o.insert("on", *on);
}
Command::SetGroup { id, group } => {
let _ = o.insert("t", "group");
let _ = o.insert("id", *id);
insert_opt_str(&mut o, "g", group);
}
Command::SetName { id, name } => {
let _ = o.insert("t", "name");
let _ = o.insert("id", *id);
insert_opt_str(&mut o, "n", name);
}
Command::Resize { rows, cols } => {
let _ = o.insert("t", "resize");
let _ = o.insert("rows", *rows as u64);
let _ = o.insert("cols", *cols as u64);
}
Command::Watch { id } => {
let _ = o.insert("t", "watch");
match id {
Some(n) => {
let _ = o.insert("id", *n);
}
None => {
let _ = o.insert("id", jzon::JsonValue::Null);
}
}
}
Command::Input { id, bytes } => {
let _ = o.insert("t", "input");
let _ = o.insert("id", *id);
let _ = o.insert("bytes", B64.encode(bytes));
}
Command::Paste { id, bytes } => {
let _ = o.insert("t", "paste");
let _ = o.insert("id", *id);
let _ = o.insert("bytes", B64.encode(bytes));
}
Command::Mouse { id, kind, col, row } => {
let _ = o.insert("t", "mouse");
let _ = o.insert("id", *id);
let (k, btn) = match kind {
MouseKind::WheelUp => ("wu", None),
MouseKind::WheelDown => ("wd", None),
MouseKind::Press(b) => ("p", Some(*b)),
MouseKind::Drag(b) => ("d", Some(*b)),
MouseKind::Release(b) => ("r", Some(*b)),
};
let _ = o.insert("k", k);
if let Some(b) = btn {
let _ = o.insert("b", b as u64);
}
let _ = o.insert("col", *col as u64);
let _ = o.insert("row", *row as u64);
}
Command::Key { id, code, mods } => {
let _ = o.insert("t", "key");
let _ = o.insert("id", *id);
let tag = match code {
Key::Char(c) => {
let mut b = [0u8; 4];
let _ = o.insert("ch", &*c.encode_utf8(&mut b));
"ch"
}
Key::F(n) => {
let _ = o.insert("n", *n as u64);
"f"
}
Key::Up => "up",
Key::Down => "dn",
Key::Left => "lt",
Key::Right => "rt",
Key::Home => "home",
Key::End => "end",
Key::PageUp => "pgup",
Key::PageDown => "pgdn",
Key::Insert => "ins",
Key::Delete => "del",
Key::Enter => "ent",
Key::Tab => "tab",
Key::BackTab => "btab",
Key::Backspace => "bs",
Key::Esc => "esc",
};
let _ = o.insert("k", tag);
if mods.shift {
let _ = o.insert("sh", true);
}
if mods.alt {
let _ = o.insert("al", true);
}
if mods.ctrl {
let _ = o.insert("ct", true);
}
}
Command::Scrollback { id, action } => {
let _ = o.insert("t", "sb");
let _ = o.insert("id", *id);
let (a, n) = match action {
ScrollAction::Up(n) => ("u", Some(*n)),
ScrollAction::Down(n) => ("d", Some(*n)),
ScrollAction::Top => ("t", None),
ScrollAction::Live => ("l", None),
};
let _ = o.insert("a", a);
if let Some(n) = n {
let _ = o.insert("n", n as u64);
}
}
Command::SaveSession { name } => {
let _ = o.insert("t", "save");
let _ = o.insert("name", name.as_str());
}
Command::LoadSession { name } => {
let _ = o.insert("t", "load");
let _ = o.insert("name", name.as_str());
}
Command::ListSessions => {
let _ = o.insert("t", "list");
}
Command::Shutdown => {
let _ = o.insert("t", "shutdown");
}
}
(KIND_CONTROL, o.dump().into_bytes())
}
pub fn decode_command(kind: u8, payload: &[u8]) -> Option<Command> {
if kind != KIND_CONTROL {
return None;
}
let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?;
let cmd = match v["t"].as_str()? {
"spawn" => Command::Spawn {
command: v["command"].as_str()?.to_string(),
cwd: path_from_b64(&v["cwd"])?,
group: opt_str(&v["group"])?,
},
"kill" => Command::Kill {
id: v["id"].as_u64()?,
},
"remove" => Command::Remove {
id: v["id"].as_u64()?,
},
"restart" => Command::Restart {
id: v["id"].as_u64()?,
},
"tag" => Command::Tag {
id: v["id"].as_u64()?,
on: v["on"].as_bool()?,
},
"group" => Command::SetGroup {
id: v["id"].as_u64()?,
group: opt_str(&v["g"])?,
},
"name" => Command::SetName {
id: v["id"].as_u64()?,
name: opt_str(&v["n"])?,
},
"resize" => Command::Resize {
rows: u16_from(&v["rows"])?,
cols: u16_from(&v["cols"])?,
},
"watch" => Command::Watch {
id: if v["id"].is_null() {
None
} else {
Some(v["id"].as_u64()?)
},
},
"input" => Command::Input {
id: v["id"].as_u64()?,
bytes: B64.decode(v["bytes"].as_str()?).ok()?,
},
"paste" => Command::Paste {
id: v["id"].as_u64()?,
bytes: B64.decode(v["bytes"].as_str()?).ok()?,
},
"mouse" => {
let btn = || -> Option<MouseBtn> {
match v["b"].as_u64()? {
0 => Some(MouseBtn::Left),
1 => Some(MouseBtn::Middle),
2 => Some(MouseBtn::Right),
_ => None,
}
};
Command::Mouse {
id: v["id"].as_u64()?,
kind: match v["k"].as_str()? {
"wu" => MouseKind::WheelUp,
"wd" => MouseKind::WheelDown,
"p" => MouseKind::Press(btn()?),
"d" => MouseKind::Drag(btn()?),
"r" => MouseKind::Release(btn()?),
_ => return None,
},
col: u16_from(&v["col"])?,
row: u16_from(&v["row"])?,
}
}
"key" => {
let code = match v["k"].as_str()? {
"ch" => {
let mut it = v["ch"].as_str()?.chars();
let c = it.next()?;
if it.next().is_some() {
return None;
}
Key::Char(c)
}
"f" => Key::F(u8::try_from(v["n"].as_u64()?).ok()?),
"up" => Key::Up,
"dn" => Key::Down,
"lt" => Key::Left,
"rt" => Key::Right,
"home" => Key::Home,
"end" => Key::End,
"pgup" => Key::PageUp,
"pgdn" => Key::PageDown,
"ins" => Key::Insert,
"del" => Key::Delete,
"ent" => Key::Enter,
"tab" => Key::Tab,
"btab" => Key::BackTab,
"bs" => Key::Backspace,
"esc" => Key::Esc,
_ => return None,
};
Command::Key {
id: v["id"].as_u64()?,
code,
mods: Mods {
shift: bool_flag(&v["sh"])?,
alt: bool_flag(&v["al"])?,
ctrl: bool_flag(&v["ct"])?,
},
}
}
"sb" => Command::Scrollback {
id: v["id"].as_u64()?,
action: match v["a"].as_str()? {
"u" => ScrollAction::Up(u16_from(&v["n"])?),
"d" => ScrollAction::Down(u16_from(&v["n"])?),
"t" => ScrollAction::Top,
"l" => ScrollAction::Live,
_ => return None,
},
},
"save" => Command::SaveSession {
name: v["name"].as_str()?.to_string(),
},
"load" => Command::LoadSession {
name: v["name"].as_str()?.to_string(),
},
"list" => Command::ListSessions,
"shutdown" => Command::Shutdown,
_ => return None,
};
Some(cmd)
}
pub fn encode_event(ev: &Event) -> (u8, Vec<u8>) {
match ev {
Event::HelloOk => {
let mut o = jzon::JsonValue::new_object();
let _ = o.insert("t", "hello_ok");
(KIND_CONTROL, o.dump().into_bytes())
}
Event::Tasks(views) => {
let mut arr = jzon::JsonValue::new_array();
for tv in views {
let mut o = jzon::JsonValue::new_object();
let _ = o.insert("id", tv.id);
let _ = o.insert("command", tv.command.as_str());
let _ = o.insert("cwd", path_b64(&tv.cwd));
let _ = o.insert("tagged", tv.tagged);
insert_opt_str(&mut o, "group", &tv.group);
insert_opt_str(&mut o, "name", &tv.name);
let _ = o.insert("life", lifecycle_str(tv.lifecycle));
let _ = o.insert("preview", tv.preview.text.as_str());
let _ = o.insert("src", tv.preview.source.label());
let _ = o.insert("frozen", tv.preview.frozen);
let _ = o.insert("started_ms", tv.started_ago.as_millis() as u64);
let _ = o.insert("parked", tv.parked);
insert_opt_ms(&mut o, "quiet_ms", tv.quiet_ago);
insert_opt_ms(&mut o, "finished_ms", tv.finished_ago);
let _ = arr.push(o);
}
let mut root = jzon::JsonValue::new_object();
let _ = root.insert("t", "tasks");
let _ = root.insert("tasks", arr);
(KIND_CONTROL, root.dump().into_bytes())
}
Event::Status(msg) => {
let mut o = jzon::JsonValue::new_object();
let _ = o.insert("t", "status");
let _ = o.insert("msg", msg.as_str());
(KIND_CONTROL, o.dump().into_bytes())
}
Event::Sessions(names) => {
let mut arr = jzon::JsonValue::new_array();
for n in names {
let _ = arr.push(n.as_str());
}
let mut o = jzon::JsonValue::new_object();
let _ = o.insert("t", "sessions");
let _ = o.insert("names", arr);
(KIND_CONTROL, o.dump().into_bytes())
}
Event::Screen(sv) => {
let mut header = jzon::JsonValue::new_object();
let _ = header.insert("id", sv.id);
let mut cur = jzon::JsonValue::new_array();
let _ = cur.push(sv.cursor.0 as u64);
let _ = cur.push(sv.cursor.1 as u64);
let _ = header.insert("cursor", cur);
let _ = header.insert("hide", sv.hide_cursor);
let _ = header.insert("mouse", sv.wants_mouse);
let _ = header.insert("alt", sv.alt_screen);
let _ = header.insert("ascr", sv.alt_scroll);
let _ = header.insert("sb", sv.scrollback as u64);
let mut lines = jzon::JsonValue::new_array();
for l in &sv.lines {
let _ = lines.push(l.as_str());
}
let _ = header.insert("lines", lines);
let hbytes = header.dump().into_bytes();
let mut payload = Vec::with_capacity(4 + hbytes.len() + sv.formatted.len());
payload.extend_from_slice(&(hbytes.len() as u32).to_be_bytes());
payload.extend_from_slice(&hbytes);
payload.extend_from_slice(&sv.formatted);
(KIND_SCREEN, payload)
}
}
}
pub fn decode_event(kind: u8, payload: &[u8]) -> Option<Event> {
match kind {
KIND_CONTROL => {
let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?;
match v["t"].as_str()? {
"hello_ok" => Some(Event::HelloOk),
"tasks" => {
let mut views = Vec::new();
for tv in v["tasks"].members() {
let lifecycle = lifecycle_from(tv["life"].as_str()?)?;
let parked = if tv["parked"].is_null() {
lifecycle == Lifecycle::Idle
} else {
tv["parked"].as_bool()?
};
let source = if tv["src"].is_null() {
PreviewSource::Floor
} else {
source_from(tv["src"].as_str()?)?
};
views.push(TaskView {
id: tv["id"].as_u64()?,
command: tv["command"].as_str()?.to_string(),
cwd: path_from_b64(&tv["cwd"])?,
tagged: tv["tagged"].as_bool()?,
group: opt_str(&tv["group"])?,
name: opt_str(&tv["name"])?,
lifecycle,
parked,
preview: Preview {
text: tv["preview"].as_str()?.to_string(),
source,
rule: None,
frozen: bool_flag(&tv["frozen"])?,
},
started_ago: Duration::from_millis(tv["started_ms"].as_u64()?),
quiet_ago: opt_ms(&tv["quiet_ms"])?,
finished_ago: opt_ms(&tv["finished_ms"])?,
});
}
Some(Event::Tasks(views))
}
"status" => Some(Event::Status(v["msg"].as_str()?.to_string())),
"sessions" => Some(Event::Sessions(str_vec(&v["names"])?)),
_ => None,
}
}
KIND_SCREEN => {
let hlen = u32::from_be_bytes(payload.get(0..4)?.try_into().ok()?) as usize;
let header_bytes = payload.get(4..4 + hlen)?;
let formatted = payload.get(4 + hlen..)?.to_vec();
let h = jzon::parse(std::str::from_utf8(header_bytes).ok()?).ok()?;
let cursor = (u16_from(&h["cursor"][0])?, u16_from(&h["cursor"][1])?);
let lines = str_vec(&h["lines"])?;
Some(Event::Screen(ScreenView {
id: h["id"].as_u64()?,
lines,
formatted,
cursor,
hide_cursor: h["hide"].as_bool()?,
wants_mouse: h["mouse"].as_bool()?,
alt_screen: h["alt"].as_bool()?,
alt_scroll: h["ascr"].as_bool()?,
scrollback: usize::try_from(h["sb"].as_u64()?).ok()?,
}))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_round_trips() {
let cases = [
Command::Spawn {
command: "echo hi".into(),
cwd: PathBuf::from("/tmp"),
group: None,
},
Command::Spawn {
command: "ls".into(),
cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())),
group: None,
},
Command::Spawn {
command: "make".into(),
cwd: PathBuf::from("/tmp"),
group: Some("build".into()),
},
Command::Kill { id: 7 },
Command::Remove { id: 3 },
Command::Restart { id: 4 },
Command::Tag { id: 2, on: true },
Command::SetGroup {
id: 2,
group: Some("infra".into()),
},
Command::SetGroup { id: 2, group: None },
Command::SetName {
id: 2,
name: Some("api server".into()),
},
Command::SetName { id: 2, name: None },
Command::Resize {
rows: 30,
cols: 100,
},
Command::Watch { id: Some(5) },
Command::Watch { id: None },
Command::Input {
id: 1,
bytes: vec![0, 27, 91, 255],
},
Command::Paste {
id: 6,
bytes: b"line1\nline2\x1b[201~\xff".to_vec(),
},
Command::Mouse {
id: 8,
kind: MouseKind::WheelDown,
col: 79,
row: 23,
},
Command::Mouse {
id: 8,
kind: MouseKind::Press(MouseBtn::Left),
col: 0,
row: 0,
},
Command::Mouse {
id: 8,
kind: MouseKind::Drag(MouseBtn::Middle),
col: 10,
row: 5,
},
Command::Mouse {
id: 8,
kind: MouseKind::Release(MouseBtn::Right),
col: 10,
row: 5,
},
Command::Key {
id: 9,
code: Key::Char('λ'),
mods: Mods::default(),
},
Command::Key {
id: 9,
code: Key::F(7),
mods: Mods {
ctrl: true,
..Mods::default()
},
},
Command::Key {
id: 9,
code: Key::Left,
mods: Mods {
shift: true,
alt: true,
ctrl: true,
},
},
Command::Key {
id: 9,
code: Key::Enter,
mods: Mods::default(),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Up(23),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Down(1),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Top,
},
Command::Scrollback {
id: 3,
action: ScrollAction::Live,
},
Command::SaveSession {
name: "work".into(),
},
Command::LoadSession {
name: "home".into(),
},
Command::ListSessions,
Command::Shutdown,
];
for c in cases {
let (k, p) = encode_command(&c);
assert_eq!(decode_command(k, &p).as_ref(), Some(&c), "round-trip {c:?}");
}
}
#[test]
fn hello_ok_round_trips() {
let ack = Event::HelloOk;
let (k, p) = encode_event(&ack);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p), Some(ack));
}
#[test]
fn hello_round_trips() {
let ctx = LaunchContext {
env: vec![
("PATH".into(), "/usr/bin:/bin".into()),
(
OsString::from_vec(b"BAD\xff\xfe".to_vec()),
OsString::from_vec(b"v\xff".to_vec()),
),
],
cwd: PathBuf::from(OsString::from_vec(b"/home/x\xff\xfe".to_vec())),
};
let (k, p) = encode_hello(&ctx);
assert_eq!(k, KIND_HELLO);
assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, ctx)));
let empty = LaunchContext {
env: Vec::new(),
cwd: PathBuf::from("/"),
};
let (k, p) = encode_hello(&empty);
assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, empty)));
}
#[test]
fn hello_requires_its_own_frame_kind() {
let (_, p) = encode_hello(&LaunchContext {
env: Vec::new(),
cwd: PathBuf::from("/"),
});
assert_eq!(decode_hello(KIND_CONTROL, &p), None);
assert_eq!(decode_command(KIND_CONTROL, &p), None);
}
#[test]
fn hello_with_malformed_env_is_rejected() {
for env in [
r#"[["P@TH","L2Jpbg=="]]"#, r#"[["QUFBQUE","L2Jpbg=="]]"#, r#"[["UEFUSA==","AAAA="]]"#, r#"[[[80],[65]]]"#, r#"["PATH=/bin"]"#, ] {
let json = format!(r#"{{"v":4,"cwd":"Lw==","env":{env}}}"#);
assert_eq!(
decode_hello(KIND_HELLO, json.as_bytes()),
None,
"should reject env {env}"
);
}
}
#[test]
fn hello_with_malformed_cwd_is_rejected() {
let json = r#"{"v":3,"cwd":"/home/user","env":[]}"#;
assert_eq!(decode_hello(KIND_HELLO, json.as_bytes()), None);
}
#[test]
fn out_of_range_numerics_are_rejected() {
for json in [
r#"{"t":"resize","rows":65536,"cols":100}"#,
r#"{"t":"resize","rows":30,"cols":65536}"#,
r#"{"t":"mouse","id":1,"k":"wu","col":65536,"row":0}"#,
r#"{"t":"mouse","id":1,"k":"wu","col":0,"row":65536}"#,
r#"{"t":"sb","id":1,"a":"u","n":65536}"#,
r#"{"t":"sb","id":1,"a":"d","n":-1}"#,
] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn invalid_base64_is_rejected() {
for json in [
r#"{"t":"input","id":1,"bytes":"!!!"}"#,
r#"{"t":"input","id":1,"bytes":[0,27]}"#, r#"{"t":"paste","id":1,"bytes":"AAAA="}"#, r#"{"t":"spawn","command":"ls","cwd":"/tmp/x"}"#, ] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
fn screen_payload(header: &str) -> Vec<u8> {
let mut p = Vec::with_capacity(4 + header.len());
p.extend_from_slice(&(header.len() as u32).to_be_bytes());
p.extend_from_slice(header.as_bytes());
p
}
fn tv(id: u64) -> TaskView {
TaskView {
id,
command: "x".into(),
cwd: PathBuf::from("/"),
tagged: false,
group: None,
name: None,
lifecycle: Lifecycle::Ok,
parked: false,
preview: Preview::floor(String::new()),
started_ago: Duration::from_millis(0),
quiet_ago: None,
finished_ago: None,
}
}
#[test]
fn mistyped_event_members_are_rejected() {
for header in [
r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":["ok",5]}"#,
r#"{"id":1,"cursor":[65536,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":[]}"#,
] {
assert_eq!(
decode_event(KIND_SCREEN, &screen_payload(header)),
None,
"should reject header {header}"
);
}
for json in [
r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"life":"ok","preview":"","started_ms":0}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","started_ms":0,"group":5}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","started_ms":0,"name":5}]}"#,
r#"{"t":"tasks","tasks":["flat"]}"#,
r#"{"t":"sessions","names":["ok",5]}"#,
] {
assert_eq!(
decode_event(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn screen_header_without_alt_scroll_is_rejected() {
let header =
r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":true,"sb":0,"lines":[]}"#;
assert_eq!(decode_event(KIND_SCREEN, &screen_payload(header)), None);
}
#[test]
fn tasks_and_status_round_trip() {
let tasks = Event::Tasks(vec![
TaskView {
id: 1,
command: "vim".into(),
cwd: PathBuf::from("/home/x"),
tagged: true,
group: Some("x".into()),
name: Some("editor".into()),
lifecycle: Lifecycle::Idle,
parked: false,
preview: Preview {
text: "~ line".into(),
source: PreviewSource::Title,
rule: None,
frozen: false,
},
started_ago: Duration::from_millis(4200),
quiet_ago: Some(Duration::from_millis(700)),
finished_ago: None,
},
TaskView {
command: "make".into(),
cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())),
lifecycle: Lifecycle::Active,
started_ago: Duration::from_millis(10),
..tv(2)
},
]);
let (k, p) = encode_event(&tasks);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p), Some(tasks));
let status = Event::Status("saved 'x'".into());
let (k, p) = encode_event(&status);
assert_eq!(decode_event(k, &p), Some(status));
}
#[test]
fn set_group_wire_form() {
let (k, p) = encode_command(&Command::SetGroup {
id: 3,
group: Some("infra".into()),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"group","id":3,"g":"infra"}"#
);
let (_, p) = encode_command(&Command::SetGroup { id: 3, group: None });
assert!(!String::from_utf8(p).unwrap().contains("\"g\""));
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":null}"#),
Some(Command::SetGroup { id: 3, group: None })
);
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":5}"#),
None
);
}
#[test]
fn set_name_wire_form() {
let (k, p) = encode_command(&Command::SetName {
id: 3,
name: Some("api".into()),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"name","id":3,"n":"api"}"#
);
let (_, p) = encode_command(&Command::SetName { id: 3, name: None });
assert!(!String::from_utf8(p).unwrap().contains("\"n\""));
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":null}"#),
Some(Command::SetName { id: 3, name: None })
);
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":5}"#),
None
);
}
#[test]
fn tasks_frame_group_key_is_optional() {
let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, ungrouped.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].group, None),
other => panic!("expected tasks event, got {other:?}"),
}
let (_, p) = encode_event(&Event::Tasks(vec![tv(1)]));
assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped);
let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"group":"infra","life":"ok","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, grouped.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("infra")),
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn tasks_frame_name_key_is_optional() {
let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, unnamed.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].name, None),
other => panic!("expected tasks event, got {other:?}"),
}
let (_, p) = encode_event(&Event::Tasks(vec![tv(1)]));
assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed);
let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"name":"build","life":"ok","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, named.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].name.as_deref(), Some("build")),
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn parked_and_age_fields_round_trip() {
let tasks = Event::Tasks(vec![
TaskView {
command: "top".into(),
lifecycle: Lifecycle::Idle,
parked: true,
started_ago: Duration::from_millis(60_000),
quiet_ago: Some(Duration::from_millis(12_000)),
..tv(1)
},
TaskView {
command: "make".into(),
started_ago: Duration::from_millis(60_000),
finished_ago: Some(Duration::from_millis(3_000)),
..tv(2)
},
]);
let (k, p) = encode_event(&tasks);
let s = std::str::from_utf8(&p).unwrap();
assert_eq!(s.matches("\"quiet_ms\"").count(), 1, "frame was {s}");
assert_eq!(s.matches("\"finished_ms\"").count(), 1, "frame was {s}");
assert_eq!(decode_event(k, &p), Some(tasks));
}
#[test]
fn tasks_frame_without_parked_keys_decodes_with_defaults() {
let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"idle","preview":"","started_ms":0},{"id":2,"command":"y","cwd":"Lw==","tagged":false,"life":"active","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, old.as_bytes()) {
Some(Event::Tasks(v)) => {
assert!(v[0].parked, "idle derives parked");
assert!(!v[1].parked, "active derives not-parked");
assert_eq!((v[0].quiet_ago, v[0].finished_ago), (None, None));
assert_eq!((v[1].quiet_ago, v[1].finished_ago), (None, None));
}
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn preview_source_and_frozen_round_trip() {
let base = TaskView {
lifecycle: Lifecycle::Active,
preview: Preview::floor("p".into()),
quiet_ago: Some(Duration::from_millis(1)),
..tv(1)
};
let tasks = Event::Tasks(vec![
base.clone(),
TaskView {
id: 2,
preview: Preview {
source: PreviewSource::Marker,
..base.preview.clone()
},
..base.clone()
},
TaskView {
id: 3,
preview: Preview {
source: PreviewSource::Title,
frozen: true,
..base.preview.clone()
},
..base.clone()
},
TaskView {
id: 4,
preview: Preview {
source: PreviewSource::Anchor,
..base.preview.clone()
},
..base.clone()
},
]);
let (k, p) = encode_event(&tasks);
assert_eq!(decode_event(k, &p), Some(tasks));
let ruled = Event::Tasks(vec![TaskView {
preview: Preview {
source: PreviewSource::Anchor,
rule: Some("claude-status"),
..base.preview.clone()
},
..base.clone()
}]);
let (k, p) = encode_event(&ruled);
assert!(
!String::from_utf8(p.clone())
.unwrap()
.contains("claude-status")
);
match decode_event(k, &p) {
Some(Event::Tasks(v)) => {
assert_eq!(v[0].preview.source, PreviewSource::Anchor);
assert_eq!(v[0].preview.rule, None, "rule must stay daemon-side");
}
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() {
let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, old.as_bytes()) {
Some(Event::Tasks(v)) => {
assert_eq!(v[0].preview.source, PreviewSource::Floor);
assert!(!v[0].preview.frozen);
assert_eq!(v[0].preview.rule, None);
}
other => panic!("expected tasks event, got {other:?}"),
}
let bad = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","src":"vibes","frozen":false,"started_ms":0,"parked":false}]}"#;
assert_eq!(decode_event(KIND_CONTROL, bad.as_bytes()), None);
}
#[test]
fn sessions_event_round_trips() {
for names in [
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
Vec::new(),
vec!["my session".to_string(), "café ☕".to_string()],
] {
let ev = Event::Sessions(names);
let (k, p) = encode_event(&ev);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}");
}
}
#[test]
fn screen_round_trips_raw_bytes() {
let screen = Event::Screen(ScreenView {
id: 9,
lines: vec!["row0".into(), "row1".into()],
formatted: vec![0x1b, b'[', b'm', 0, 255, b'x'],
cursor: (3, 12),
hide_cursor: false,
wants_mouse: true,
alt_screen: false,
alt_scroll: true,
scrollback: 42,
});
let (k, p) = encode_event(&screen);
assert_eq!(k, KIND_SCREEN);
assert_eq!(decode_event(k, &p), Some(screen));
}
}