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 = 10;
pub const UNASSIGNED: &str = "Unassigned";
#[derive(Debug, Clone, PartialEq)]
pub struct LaunchContext {
pub env: Vec<(OsString, OsString)>,
pub cwd: PathBuf,
}
impl LaunchContext {
pub fn here() -> Self {
Self {
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>, attached: bool },
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 },
LoadRecovery { stem: 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 {
names: Vec<String>,
recovery: Vec<RecoveryEntry>,
},
ClipboardCopy {
id: u64,
kind: ClipboardKind,
text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardKind {
Clipboard,
Primary,
Selection,
}
impl ClipboardKind {
pub fn selector(self) -> &'static str {
match self {
Self::Clipboard => "c",
Self::Primary => "p",
Self::Selection => "s",
}
}
pub fn from_selector(sel: &[u8]) -> Option<Self> {
match sel {
b"c" => Some(Self::Clipboard),
b"p" => Some(Self::Primary),
b"s" => Some(Self::Selection),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveryEntry {
pub stem: String,
pub label: String,
pub tasks: u32,
pub age_secs: u64,
}
#[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 {
Self::Floor => "floor",
Self::Marker => "marker",
Self::Title => "title",
Self::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) -> Self {
Self {
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 num_from<T: TryFrom<u64>>(v: &jzon::JsonValue) -> Option<T> {
T::try_from(v.as_u64()?).ok()
}
fn bool_flag(v: &jzon::JsonValue) -> Option<bool> {
if v.is_null() {
return Some(false);
}
v.as_bool()
}
pub(crate) fn opt_str(v: &jzon::JsonValue) -> Option<Option<String>> {
if v.is_null() {
return Some(None);
}
Some(Some(v.as_str()?.to_string()))
}
pub(crate) 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 recovery_vec(v: &jzon::JsonValue) -> Vec<RecoveryEntry> {
let mut out = Vec::new();
for m in v.members() {
let entry = || -> Option<RecoveryEntry> {
Some(RecoveryEntry {
stem: m["stem"].as_str()?.to_string(),
label: m["label"].as_str()?.to_string(),
tasks: num_from(&m["tasks"])?,
age_secs: m["age"].as_u64()?,
})
};
if let Some(e) = entry() {
out.push(e);
}
}
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 pairs = jzon::JsonValue::new_array();
for (k, v) in &ctx.env {
let _ = pairs.push(jzon::array![os_b64(k), os_b64(v)]);
}
let o = jzon::object! {
"v": PROTOCOL_VERSION,
"cwd": path_b64(&ctx.cwd),
"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 o = match cmd {
Command::Spawn {
command,
cwd,
group,
} => {
let mut o = jzon::object! {
"t": "spawn",
"command": command.as_str(),
"cwd": path_b64(cwd),
};
insert_opt_str(&mut o, "group", group);
o
}
Command::Kill { id } => jzon::object! { "t": "kill", "id": *id },
Command::Remove { id } => jzon::object! { "t": "remove", "id": *id },
Command::Restart { id } => jzon::object! { "t": "restart", "id": *id },
Command::Tag { id, on } => jzon::object! { "t": "tag", "id": *id, "on": *on },
Command::SetGroup { id, group } => {
let mut o = jzon::object! { "t": "group", "id": *id };
insert_opt_str(&mut o, "g", group);
o
}
Command::SetName { id, name } => {
let mut o = jzon::object! { "t": "name", "id": *id };
insert_opt_str(&mut o, "n", name);
o
}
Command::Resize { rows, cols } => jzon::object! {
"t": "resize",
"rows": *rows as u64,
"cols": *cols as u64,
},
Command::Watch { id, attached } => {
jzon::object! { "t": "watch", "id": *id, "attached": *attached }
}
Command::Input { id, bytes } => jzon::object! {
"t": "input",
"id": *id,
"bytes": B64.encode(bytes),
},
Command::Paste { id, bytes } => jzon::object! {
"t": "paste",
"id": *id,
"bytes": B64.encode(bytes),
},
Command::Mouse { id, kind, col, row } => {
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 mut o = jzon::object! { "t": "mouse", "id": *id, "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);
o
}
Command::Key { id, code, mods } => {
let mut o = jzon::object! { "t": "key", "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);
}
o
}
Command::Scrollback { id, action } => {
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 mut o = jzon::object! { "t": "sb", "id": *id, "a": a };
if let Some(n) = n {
let _ = o.insert("n", n as u64);
}
o
}
Command::SaveSession { name } => jzon::object! { "t": "save", "name": name.as_str() },
Command::LoadSession { name } => jzon::object! { "t": "load", "name": name.as_str() },
Command::LoadRecovery { stem } => jzon::object! { "t": "recover", "stem": stem.as_str() },
Command::ListSessions => jzon::object! { "t": "list" },
Command::Shutdown => jzon::object! { "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: num_from(&v["rows"])?,
cols: num_from(&v["cols"])?,
},
"watch" => Command::Watch {
id: if v["id"].is_null() {
None
} else {
Some(v["id"].as_u64()?)
},
attached: v["attached"].as_bool()?,
},
"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: num_from(&v["col"])?,
row: num_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(num_from(&v["n"])?),
"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(num_from(&v["n"])?),
"d" => ScrollAction::Down(num_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(),
},
"recover" => Command::LoadRecovery {
stem: v["stem"].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 o = jzon::object! { "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 root = jzon::object! { "t": "tasks", "tasks": arr };
(KIND_CONTROL, root.dump().into_bytes())
}
Event::Status(msg) => {
let o = jzon::object! { "t": "status", "msg": msg.as_str() };
(KIND_CONTROL, o.dump().into_bytes())
}
Event::Sessions { names, recovery } => {
let mut rec = jzon::JsonValue::new_array();
for r in recovery {
let _ = rec.push(jzon::object! {
"stem": r.stem.as_str(),
"label": r.label.as_str(),
"tasks": u64::from(r.tasks),
"age": r.age_secs,
});
}
let o = jzon::object! {
"t": "sessions",
"names": names.iter().map(String::as_str).collect::<Vec<_>>(),
"recovery": rec,
};
(KIND_CONTROL, o.dump().into_bytes())
}
Event::ClipboardCopy { id, kind, text } => {
let o = jzon::object! {
"t": "clip",
"id": *id,
"k": kind.selector(),
"text": B64.encode(text.as_bytes()),
};
(KIND_CONTROL, o.dump().into_bytes())
}
Event::Screen(sv) => {
let header = jzon::object! {
"id": sv.id,
"cursor": [sv.cursor.0 as u64, sv.cursor.1 as u64],
"hide": sv.hide_cursor,
"mouse": sv.wants_mouse,
"alt": sv.alt_screen,
"ascr": sv.alt_scroll,
"sb": sv.scrollback as u64,
"lines": sv.lines.iter().map(String::as_str).collect::<Vec<_>>(),
};
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 {
names: str_vec(&v["names"])?,
recovery: recovery_vec(&v["recovery"]),
}),
"clip" => {
let id = v["id"].as_u64()?;
let kind = ClipboardKind::from_selector(v["k"].as_str()?.as_bytes())?;
let text = String::from_utf8(B64.decode(v["text"].as_str()?).ok()?).ok()?;
Some(Event::ClipboardCopy { id, kind, text })
}
_ => 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 = (num_from(&h["cursor"][0])?, num_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: num_from(&h["sb"])?,
}))
}
_ => None,
}
}
#[cfg(test)]
#[path = "protocol_tests.rs"]
mod tests;