Compare commits

...

5 Commits

Author SHA1 Message Date
Marcin Kulik
fc824b1877 Bump version 2024-07-06 22:29:16 +02:00
Marcin Kulik
4da196b6ec Fix test 2024-07-06 22:27:07 +02:00
Andy Konwinski
c52027e1bd
Merge pull request #14 from andyk/events
Use the same event subscription model in both STDIO API and WS API
2024-07-06 13:23:17 -07:00
Marcin Kulik
a9de581618 Fix --subscribe example syntax 2024-07-05 17:14:14 +02:00
Marcin Kulik
5ccc6290a2 Use the same event subscription model in both STDIO API and WS API 2024-07-05 16:56:51 +02:00
9 changed files with 637 additions and 564 deletions

2
Cargo.lock generated
View File

@ -415,7 +415,7 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "ht"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"avt",

View File

@ -1,6 +1,6 @@
[package]
name = "ht"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.74"

View File

@ -95,7 +95,13 @@ ht uses simple JSON-based protocol for sending commands to its STDIN. Each
command must be sent on a separate line and be a JSON object having `"type"`
field set to one of the supported commands (below).
ht sends responses (where applicable) to its STDOUT, as JSON-encoded objects.
Some of the commands trigger [events](#events). ht may also internally trigger
various events on its own. To subscribe to desired events use `--subscribe
[<event-name>,<event-name>,...]` option when starting ht. This will print the
events as they occur to ht's STDOUT, as JSON-encoded objects. For example, to
subscribe to view snapshots (triggered by sending `takeSnapshot` command) use
`--subscribe snapshot` option. See [events](#events) below for a list of
available event types and their payloads.
Diagnostic messages (notices, errors) are printed to STDERR.
@ -114,8 +120,6 @@ Each element of the `keys` array can be either a key name or an arbitrary text.
If a key is not matched by any supported key name then the text is sent to the
process as is, i.e. like when using the `input` command.
This command doesn't produce any output on STDOUT.
The key and modifier specifications were inspired by
[tmux](https://github.com/tmux/tmux/wiki/Modifier-Keys).
@ -155,6 +159,8 @@ etc. For text characters, instead of specifying e.g. `S-a` just use upper case
Alt modifier can be used with any Unicode character and most special key names.
This command doesn't trigger any event.
#### input
`input` command allows sending arbitrary raw input to a process running in the
@ -176,22 +182,17 @@ payload:
{ "type": "input", "payload": "\u0003" }
```
This command doesn't produce any output on STDOUT.
This command doesn't trigger any event.
#### getView
#### takeSnapshot
`getView` command allows obtaining a textual view of a terminal window.
`takeSnapshot` command allows taking a textual snapshot of the the terminal view.
```json
{ "type": "getView" }
{ "type": "takeSnapshot" }
```
This command responds with the current view on STDOUT. The view is a multi-line
string, where each line represents a terminal row.
```json
{ "view": "[user@host dir]$ \n \n..." }
```
This command triggers `snapshot` event.
#### resize
@ -202,7 +203,7 @@ specifying new width (`cols`) and height (`rows`).
{ "type": "resize", "cols": 80, "rows": 24 }
```
This command doesn't produce any output on STDOUT.
This command triggers `resize` event.
### WebSocket API
@ -217,19 +218,7 @@ E.g. `/ws/events?sub=init,snapshot`.
Events are delivered as JSON encoded strings, using WebSocket text message type.
Every event contains 2 fields:
- `type` - type of event,
- `data` - associated data, specific to each event type.
Supported events:
- `init` - similar to `snapshot` (see below) but sent only once, as the first event after establishing connection
- `output` - terminal output
- `resize` - terminal resize
- `snapshot` - view snapshot taken (e.g. with `getView`)
TODO: describe the associated data for the above event types.
See [events](#events) section below for the description of all available events.
#### `/ws/alis`
@ -239,6 +228,54 @@ therefore allows pointing asciinema player directly to ht to get a real-time
terminal preview. This endpoint is used by the live terminal preview page
mentioned above.
### Events
The events emitted to STDOUT and via `/ws/events` WebSocket endpoint are
identical, i.e. they are JSON-encoded objects with the same fields and payloads.
Every event contains 2 top-level fields:
- `type` - type of event,
- `data` - associated data, specific to each event type.
The following event types are currently available:
#### `init`
Same as `snapshot` event (see below) but sent only once, as the first event
after ht's start (when sent to STDOUT) and upon establishing of WebSocket
connection.
#### `output`
Terminal output. Sent when an application (e.g. shell) running under ht prints
something to the terminal.
Event data is an object with the following fields:
- `seq` - a raw sequence of characters written to a terminal, potentially including control sequences (colors, cursor positioning, etc.)
#### `resize`
Terminal resize. Send when the terminal is resized with the `resize` command.
Event data is an object with the following fields:
- `cols` - current terminal width, number of columns
- `rows` - current terminal height, number of rows
#### `snapshot`
Terminal window snapshot. Sent when the terminal snapshot is taken with the
`takeSnapshot` command.
Event data is an object with the following fields:
- `cols` - current terminal width, number of columns
- `rows` - current terminal height, number of rows
- `text` - plain text snapshot as multi-line string, where each line represents a terminal row
- `seq` - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as [ht's virtual terminal](https://github.com/asciinema/avt)
## Testing on command line
ht is aimed at programmatic use given its JSON-based API, however one can play

View File

@ -1,473 +1,31 @@
use crate::command::{self, Command, InputSeq};
use crate::session::{self, Event};
use anyhow::Result;
use serde::{de::DeserializeOwned, Deserialize};
use std::io;
use std::thread;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
pub mod http;
pub mod stdio;
use std::str::FromStr;
#[derive(Debug, Deserialize)]
struct InputArgs {
payload: String,
#[derive(Debug, Default, Copy, Clone)]
pub struct Subscription {
init: bool,
snapshot: bool,
resize: bool,
output: bool,
}
#[derive(Debug, Deserialize)]
struct SendKeysArgs {
keys: Vec<String>,
}
impl FromStr for Subscription {
type Err = String;
#[derive(Debug, Deserialize)]
struct ResizeArgs {
cols: usize,
rows: usize,
}
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut sub = Subscription::default();
pub async fn start(
command_tx: mpsc::Sender<Command>,
clients_tx: mpsc::Sender<session::Client>,
) -> Result<()> {
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
thread::spawn(|| read_stdin(input_tx));
let mut events = session::stream(&clients_tx).await?;
loop {
tokio::select! {
line = input_rx.recv() => {
match line {
Some(line) => {
match parse_line(&line) {
Ok(command) => command_tx.send(command).await?,
Err(e) => eprintln!("command parse error: {e}"),
}
}
None => break
}
}
event = events.next() => {
match event {
Some(Ok(Event::Snapshot(_cols, _rows, _seq, text))) => {
let msg = serde_json::json!({ "view": text });
println!("{}", serde_json::to_string(&msg).unwrap());
}
Some(_) => (),
None => break
}
for event in s.split(',') {
match event {
"init" => sub.init = true,
"output" => sub.output = true,
"resize" => sub.resize = true,
"snapshot" => sub.snapshot = true,
_ => return Err(format!("invalid event name: {event}")),
}
}
}
Ok(())
}
fn read_stdin(input_tx: mpsc::UnboundedSender<String>) -> Result<()> {
for line in io::stdin().lines() {
input_tx.send(line?)?;
}
Ok(())
}
fn parse_line(line: &str) -> Result<command::Command, String> {
serde_json::from_str::<serde_json::Value>(line)
.map_err(|e| e.to_string())
.and_then(build_command)
}
fn build_command(value: serde_json::Value) -> Result<Command, String> {
match value["type"].as_str() {
Some("input") => {
let args: InputArgs = args_from_json_value(value)?;
Ok(Command::Input(vec![standard_key(args.payload)]))
}
Some("sendKeys") => {
let args: SendKeysArgs = args_from_json_value(value)?;
let seqs = args.keys.into_iter().map(parse_key).collect();
Ok(Command::Input(seqs))
}
Some("resize") => {
let args: ResizeArgs = args_from_json_value(value)?;
Ok(Command::Resize(args.cols, args.rows))
}
Some("getView") => Ok(Command::Snapshot),
other => Err(format!("invalid command type: {other:?}")),
}
}
fn args_from_json_value<T>(value: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
{
serde_json::from_value(value).map_err(|e| e.to_string())
}
fn standard_key<S: ToString>(seq: S) -> InputSeq {
InputSeq::Standard(seq.to_string())
}
fn cursor_key<S: ToString>(seq1: S, seq2: S) -> InputSeq {
InputSeq::Cursor(seq1.to_string(), seq2.to_string())
}
fn parse_key(key: String) -> InputSeq {
let seq = match key.as_str() {
"C-@" | "C-Space" | "^@" => "\x00",
"C-[" | "Escape" | "^[" => "\x1b",
"C-\\" | "^\\" => "\x1c",
"C-]" | "^]" => "\x1d",
"C-^" | "C-/" => "\x1e",
"C--" | "C-_" => "\x1f",
"Tab" => "\x09", // same as C-i
"Enter" => "\x0d", // same as C-m
"Space" => " ",
"Left" => return cursor_key("\x1b[D", "\x1bOD"),
"Right" => return cursor_key("\x1b[C", "\x1bOC"),
"Up" => return cursor_key("\x1b[A", "\x1bOA"),
"Down" => return cursor_key("\x1b[B", "\x1bOB"),
"C-Left" => "\x1b[1;5D",
"C-Right" => "\x1b[1;5C",
"S-Left" => "\x1b[1;2D",
"S-Right" => "\x1b[1;2C",
"C-Up" => "\x1b[1;5A",
"C-Down" => "\x1b[1;5B",
"S-Up" => "\x1b[1;2A",
"S-Down" => "\x1b[1;2B",
"A-Left" => "\x1b[1;3D",
"A-Right" => "\x1b[1;3C",
"A-Up" => "\x1b[1;3A",
"A-Down" => "\x1b[1;3B",
"C-S-Left" | "S-C-Left" => "\x1b[1;6D",
"C-S-Right" | "S-C-Right" => "\x1b[1;6C",
"C-S-Up" | "S-C-Up" => "\x1b[1;6A",
"C-S-Down" | "S-C-Down" => "\x1b[1;6B",
"C-A-Left" | "A-C-Left" => "\x1b[1;7D",
"C-A-Right" | "A-C-Right" => "\x1b[1;7C",
"C-A-Up" | "A-C-Up" => "\x1b[1;7A",
"C-A-Down" | "A-C-Down" => "\x1b[1;7B",
"A-S-Left" | "S-A-Left" => "\x1b[1;4D",
"A-S-Right" | "S-A-Right" => "\x1b[1;4C",
"A-S-Up" | "S-A-Up" => "\x1b[1;4A",
"A-S-Down" | "S-A-Down" => "\x1b[1;4B",
"C-A-S-Left" | "C-S-A-Left" | "A-C-S-Left" | "S-C-A-Left" | "A-S-C-Left" | "S-A-C-Left" => {
"\x1b[1;8D"
}
"C-A-S-Right" | "C-S-A-Right" | "A-C-S-Right" | "S-C-A-Right" | "A-S-C-Right"
| "S-A-C-Right" => "\x1b[1;8C",
"C-A-S-Up" | "C-S-A-Up" | "A-C-S-Up" | "S-C-A-Up" | "A-S-C-Up" | "S-A-C-Up" => "\x1b[1;8A",
"C-A-S-Down" | "C-S-A-Down" | "A-C-S-Down" | "S-C-A-Down" | "A-S-C-Down" | "S-A-C-Down" => {
"\x1b[1;8B"
}
"F1" => "\x1bOP",
"F2" => "\x1bOQ",
"F3" => "\x1bOR",
"F4" => "\x1bOS",
"F5" => "\x1b[15~",
"F6" => "\x1b[17~",
"F7" => "\x1b[18~",
"F8" => "\x1b[19~",
"F9" => "\x1b[20~",
"F10" => "\x1b[21~",
"F11" => "\x1b[23~",
"F12" => "\x1b[24~",
"C-F1" => "\x1b[1;5P",
"C-F2" => "\x1b[1;5Q",
"C-F3" => "\x1b[1;5R",
"C-F4" => "\x1b[1;5S",
"C-F5" => "\x1b[15;5~",
"C-F6" => "\x1b[17;5~",
"C-F7" => "\x1b[18;5~",
"C-F8" => "\x1b[19;5~",
"C-F9" => "\x1b[20;5~",
"C-F10" => "\x1b[21;5~",
"C-F11" => "\x1b[23;5~",
"C-F12" => "\x1b[24;5~",
"S-F1" => "\x1b[1;2P",
"S-F2" => "\x1b[1;2Q",
"S-F3" => "\x1b[1;2R",
"S-F4" => "\x1b[1;2S",
"S-F5" => "\x1b[15;2~",
"S-F6" => "\x1b[17;2~",
"S-F7" => "\x1b[18;2~",
"S-F8" => "\x1b[19;2~",
"S-F9" => "\x1b[20;2~",
"S-F10" => "\x1b[21;2~",
"S-F11" => "\x1b[23;2~",
"S-F12" => "\x1b[24;2~",
"A-F1" => "\x1b[1;3P",
"A-F2" => "\x1b[1;3Q",
"A-F3" => "\x1b[1;3R",
"A-F4" => "\x1b[1;3S",
"A-F5" => "\x1b[15;3~",
"A-F6" => "\x1b[17;3~",
"A-F7" => "\x1b[18;3~",
"A-F8" => "\x1b[19;3~",
"A-F9" => "\x1b[20;3~",
"A-F10" => "\x1b[21;3~",
"A-F11" => "\x1b[23;3~",
"A-F12" => "\x1b[24;3~",
"Home" => return cursor_key("\x1b[H", "\x1bOH"),
"C-Home" => "\x1b[1;5H",
"S-Home" => "\x1b[1;2H",
"A-Home" => "\x1b[1;3H",
"End" => return cursor_key("\x1b[F", "\x1bOF"),
"C-End" => "\x1b[1;5F",
"S-End" => "\x1b[1;2F",
"A-End" => "\x1b[1;3F",
"PageUp" => "\x1b[5~",
"C-PageUp" => "\x1b[5;5~",
"S-PageUp" => "\x1b[5;2~",
"A-PageUp" => "\x1b[5;3~",
"PageDown" => "\x1b[6~",
"C-PageDown" => "\x1b[6;5~",
"S-PageDown" => "\x1b[6;2~",
"A-PageDown" => "\x1b[6;3~",
k => {
let chars: Vec<char> = k.chars().collect();
match chars.as_slice() {
['C', '-', k @ 'a'..='z'] => {
return standard_key((*k as u8 - 0x60) as char);
}
['C', '-', k @ 'A'..='Z'] => {
return standard_key((*k as u8 - 0x40) as char);
}
['^', k @ 'a'..='z'] => {
return standard_key((*k as u8 - 0x60) as char);
}
['^', k @ 'A'..='Z'] => {
return standard_key((*k as u8 - 0x40) as char);
}
['A', '-', k] => {
return standard_key(format!("\x1b{}", k));
}
_ => &key,
}
}
};
standard_key(seq)
}
#[cfg(test)]
mod test {
use super::{cursor_key, parse_line, standard_key, Command};
use crate::command::InputSeq;
#[test]
fn parse_input() {
let command = parse_line(r#"{ "type": "input", "payload": "hello" }"#).unwrap();
assert!(matches!(command, Command::Input(input) if input == vec![standard_key("hello")]));
}
#[test]
fn parse_input_missing_args() {
parse_line(r#"{ "type": "input" }"#).expect_err("should fail");
}
#[test]
fn parse_send_keys() {
let examples = [
["hello", "hello"],
["C-@", "\x00"],
["C-a", "\x01"],
["C-A", "\x01"],
["^a", "\x01"],
["^A", "\x01"],
["C-z", "\x1a"],
["C-Z", "\x1a"],
["C-[", "\x1b"],
["Space", " "],
["C-Space", "\x00"],
["Tab", "\x09"],
["Enter", "\x0d"],
["Escape", "\x1b"],
["^[", "\x1b"],
["C-Left", "\x1b[1;5D"],
["C-Right", "\x1b[1;5C"],
["S-Left", "\x1b[1;2D"],
["S-Right", "\x1b[1;2C"],
["C-Up", "\x1b[1;5A"],
["C-Down", "\x1b[1;5B"],
["S-Up", "\x1b[1;2A"],
["S-Down", "\x1b[1;2B"],
["A-Left", "\x1b[1;3D"],
["A-Right", "\x1b[1;3C"],
["A-Up", "\x1b[1;3A"],
["A-Down", "\x1b[1;3B"],
["C-S-Left", "\x1b[1;6D"],
["C-S-Right", "\x1b[1;6C"],
["C-S-Up", "\x1b[1;6A"],
["C-S-Down", "\x1b[1;6B"],
["C-A-Left", "\x1b[1;7D"],
["C-A-Right", "\x1b[1;7C"],
["C-A-Up", "\x1b[1;7A"],
["C-A-Down", "\x1b[1;7B"],
["S-A-Left", "\x1b[1;4D"],
["S-A-Right", "\x1b[1;4C"],
["S-A-Up", "\x1b[1;4A"],
["S-A-Down", "\x1b[1;4B"],
["C-A-S-Left", "\x1b[1;8D"],
["C-A-S-Right", "\x1b[1;8C"],
["C-A-S-Up", "\x1b[1;8A"],
["C-A-S-Down", "\x1b[1;8B"],
["A-a", "\x1ba"],
["A-A", "\x1bA"],
["A-z", "\x1bz"],
["A-Z", "\x1bZ"],
["A-1", "\x1b1"],
["A-!", "\x1b!"],
["F1", "\x1bOP"],
["F2", "\x1bOQ"],
["F3", "\x1bOR"],
["F4", "\x1bOS"],
["F5", "\x1b[15~"],
["F6", "\x1b[17~"],
["F7", "\x1b[18~"],
["F8", "\x1b[19~"],
["F9", "\x1b[20~"],
["F10", "\x1b[21~"],
["F11", "\x1b[23~"],
["F12", "\x1b[24~"],
["C-F1", "\x1b[1;5P"],
["C-F2", "\x1b[1;5Q"],
["C-F3", "\x1b[1;5R"],
["C-F4", "\x1b[1;5S"],
["C-F5", "\x1b[15;5~"],
["C-F6", "\x1b[17;5~"],
["C-F7", "\x1b[18;5~"],
["C-F8", "\x1b[19;5~"],
["C-F9", "\x1b[20;5~"],
["C-F10", "\x1b[21;5~"],
["C-F11", "\x1b[23;5~"],
["C-F12", "\x1b[24;5~"],
["S-F1", "\x1b[1;2P"],
["S-F2", "\x1b[1;2Q"],
["S-F3", "\x1b[1;2R"],
["S-F4", "\x1b[1;2S"],
["S-F5", "\x1b[15;2~"],
["S-F6", "\x1b[17;2~"],
["S-F7", "\x1b[18;2~"],
["S-F8", "\x1b[19;2~"],
["S-F9", "\x1b[20;2~"],
["S-F10", "\x1b[21;2~"],
["S-F11", "\x1b[23;2~"],
["S-F12", "\x1b[24;2~"],
["A-F1", "\x1b[1;3P"],
["A-F2", "\x1b[1;3Q"],
["A-F3", "\x1b[1;3R"],
["A-F4", "\x1b[1;3S"],
["A-F5", "\x1b[15;3~"],
["A-F6", "\x1b[17;3~"],
["A-F7", "\x1b[18;3~"],
["A-F8", "\x1b[19;3~"],
["A-F9", "\x1b[20;3~"],
["A-F10", "\x1b[21;3~"],
["A-F11", "\x1b[23;3~"],
["A-F12", "\x1b[24;3~"],
["C-Home", "\x1b[1;5H"],
["S-Home", "\x1b[1;2H"],
["A-Home", "\x1b[1;3H"],
["C-End", "\x1b[1;5F"],
["S-End", "\x1b[1;2F"],
["A-End", "\x1b[1;3F"],
["PageUp", "\x1b[5~"],
["C-PageUp", "\x1b[5;5~"],
["S-PageUp", "\x1b[5;2~"],
["A-PageUp", "\x1b[5;3~"],
["PageDown", "\x1b[6~"],
["C-PageDown", "\x1b[6;5~"],
["S-PageDown", "\x1b[6;2~"],
["A-PageDown", "\x1b[6;3~"],
];
for [key, chars] in examples {
let command = parse_line(&format!(
"{{ \"type\": \"sendKeys\", \"keys\": [\"{key}\"] }}"
))
.unwrap();
assert!(matches!(command, Command::Input(input) if input == vec![standard_key(chars)]));
}
let command = parse_line(
r#"{ "type": "sendKeys", "keys": ["hello", "Enter", "C-c", "A-^", "Left"] }"#,
)
.unwrap();
assert!(
matches!(command, Command::Input(input) if input == vec![standard_key("hello"), standard_key("\x0d"), standard_key("\x03"), standard_key("\x1b^"), cursor_key("\x1b[D", "\x1bOD")])
);
}
#[test]
fn parse_cursor_keys() {
let examples = [
["Left", "\x1b[D", "\x1bOD"],
["Right", "\x1b[C", "\x1bOC"],
["Up", "\x1b[A", "\x1bOA"],
["Down", "\x1b[B", "\x1bOB"],
["Home", "\x1b[H", "\x1bOH"],
["End", "\x1b[F", "\x1bOF"],
];
for [key, seq1, seq2] in examples {
let command = parse_line(&format!(
"{{ \"type\": \"sendKeys\", \"keys\": [\"{key}\"] }}"
))
.unwrap();
if let Command::Input(seqs) = command {
if let InputSeq::Cursor(seq3, seq4) = &seqs[0] {
if seq1 == seq3 && seq2 == seq4 {
continue;
}
panic!("expected {:?} {:?}, got {:?} {:?}", seq1, seq2, seq3, seq4);
}
}
panic!("expected {:?} {:?}", seq1, seq2);
}
}
#[test]
fn parse_send_keys_missing_args() {
parse_line(r#"{ "type": "sendKeys" }"#).expect_err("should fail");
}
#[test]
fn parse_resize() {
let command = parse_line(r#"{ "type": "resize", "cols": 80, "rows": 24 }"#).unwrap();
assert!(matches!(command, Command::Resize(80, 24)));
}
#[test]
fn parse_resize_missing_args() {
parse_line(r#"{ "type": "resize" }"#).expect_err("should fail");
}
#[test]
fn parse_get_view() {
let command = parse_line(r#"{ "type": "getView" }"#).unwrap();
assert!(matches!(command, Command::Snapshot));
}
#[test]
fn parse_invalid_json() {
parse_line("{").expect_err("should fail");
Ok(sub)
}
}

View File

@ -1,3 +1,4 @@
use super::Subscription;
use crate::session;
use anyhow::Result;
use axum::{
@ -111,32 +112,6 @@ struct EventsParams {
sub: Option<String>,
}
#[derive(Default, Copy, Clone)]
struct EventSubscription {
init: bool,
snapshot: bool,
resize: bool,
output: bool,
}
impl From<String> for EventSubscription {
fn from(value: String) -> Self {
let mut sub = EventSubscription::default();
for s in value.split(',') {
match s {
"init" => sub.init = true,
"output" => sub.output = true,
"resize" => sub.resize = true,
"snapshot" => sub.snapshot = true,
_ => (),
}
}
sub
}
}
/// Event stream handler
///
/// This endpoint allows the client to subscribe to selected events and have them delivered as they occur.
@ -148,7 +123,7 @@ async fn event_stream_handler(
ConnectInfo(_addr): ConnectInfo<SocketAddr>,
State(clients_tx): State<mpsc::Sender<session::Client>>,
) -> impl IntoResponse {
let sub = params.sub.unwrap_or_default().into();
let sub: Subscription = params.sub.unwrap_or_default().parse().unwrap_or_default();
ws.on_upgrade(move |socket| async move {
let _ = handle_event_stream_socket(socket, clients_tx, sub).await;
@ -158,7 +133,7 @@ async fn event_stream_handler(
async fn handle_event_stream_socket(
socket: ws::WebSocket,
clients_tx: mpsc::Sender<session::Client>,
sub: EventSubscription,
sub: Subscription,
) -> Result<()> {
let (sink, stream) = socket.split();
let drainer = tokio::spawn(stream.map(Ok).forward(sink::drain()));
@ -178,48 +153,16 @@ async fn handle_event_stream_socket(
async fn event_stream_message(
event: Result<session::Event, BroadcastStreamRecvError>,
sub: EventSubscription,
sub: Subscription,
) -> Option<Result<ws::Message, axum::Error>> {
use session::Event::*;
match event {
Ok(Init(_time, cols, rows, seq, text)) if sub.init => Some(Ok(json_message(json!({
"type": "init",
"data": json!({
"cols": cols,
"rows": rows,
"seq": seq,
"text": text,
})
})))),
Ok(Output(_time, data)) if sub.output => Some(Ok(json_message(json!({
"type": "output",
"data": json!({
"seq": data
})
})))),
Ok(Resize(_time, cols, rows)) if sub.resize => Some(Ok(json_message(json!({
"type": "resize",
"data": json!({
"cols": cols,
"rows": rows,
})
})))),
Ok(Snapshot(cols, rows, seq, text)) if sub.snapshot => Some(Ok(json_message(json!({
"type": "snapshot",
"data": json!({
"cols": cols,
"rows": rows,
"seq": seq,
"text": text,
})
})))),
Ok(e @ Init(_, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))),
Ok(e @ Output(_, _)) if sub.output => Some(Ok(json_message(e.to_json()))),
Ok(e @ Resize(_, _, _)) if sub.resize => Some(Ok(json_message(e.to_json()))),
Ok(e @ Snapshot(_, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))),
Ok(_) => None,
Err(e) => Some(Err(axum::Error::new(e))),
}
}

488
src/api/stdio.rs Normal file
View File

@ -0,0 +1,488 @@
use super::Subscription;
use crate::command::{self, Command, InputSeq};
use crate::session;
use anyhow::Result;
use serde::{de::DeserializeOwned, Deserialize};
use std::io;
use std::thread;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
#[derive(Debug, Deserialize)]
struct InputArgs {
payload: String,
}
#[derive(Debug, Deserialize)]
struct SendKeysArgs {
keys: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ResizeArgs {
cols: usize,
rows: usize,
}
pub async fn start(
command_tx: mpsc::Sender<Command>,
clients_tx: mpsc::Sender<session::Client>,
sub: Subscription,
) -> Result<()> {
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
thread::spawn(|| read_stdin(input_tx));
let mut events = session::stream(&clients_tx).await?;
loop {
tokio::select! {
line = input_rx.recv() => {
match line {
Some(line) => {
match parse_line(&line) {
Ok(command) => command_tx.send(command).await?,
Err(e) => eprintln!("command parse error: {e}"),
}
}
None => break
}
}
event = events.next() => {
use session::Event::*;
match event {
Some(Ok(e @ Init(_, _, _, _, _))) if sub.init => {
println!("{}", e.to_json().to_string());
}
Some(Ok(e @ Output(_, _))) if sub.output => {
println!("{}", e.to_json().to_string());
}
Some(Ok(e @ Resize(_, _, _))) if sub.resize => {
println!("{}", e.to_json().to_string());
}
Some(Ok(e @ Snapshot(_, _, _, _))) if sub.snapshot => {
println!("{}", e.to_json().to_string());
}
Some(_) => (),
None => break
}
}
}
}
Ok(())
}
fn read_stdin(input_tx: mpsc::UnboundedSender<String>) -> Result<()> {
for line in io::stdin().lines() {
input_tx.send(line?)?;
}
Ok(())
}
fn parse_line(line: &str) -> Result<command::Command, String> {
serde_json::from_str::<serde_json::Value>(line)
.map_err(|e| e.to_string())
.and_then(build_command)
}
fn build_command(value: serde_json::Value) -> Result<Command, String> {
match value["type"].as_str() {
Some("input") => {
let args: InputArgs = args_from_json_value(value)?;
Ok(Command::Input(vec![standard_key(args.payload)]))
}
Some("sendKeys") => {
let args: SendKeysArgs = args_from_json_value(value)?;
let seqs = args.keys.into_iter().map(parse_key).collect();
Ok(Command::Input(seqs))
}
Some("resize") => {
let args: ResizeArgs = args_from_json_value(value)?;
Ok(Command::Resize(args.cols, args.rows))
}
Some("takeSnapshot") => Ok(Command::Snapshot),
other => Err(format!("invalid command type: {other:?}")),
}
}
fn args_from_json_value<T>(value: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
{
serde_json::from_value(value).map_err(|e| e.to_string())
}
fn standard_key<S: ToString>(seq: S) -> InputSeq {
InputSeq::Standard(seq.to_string())
}
fn cursor_key<S: ToString>(seq1: S, seq2: S) -> InputSeq {
InputSeq::Cursor(seq1.to_string(), seq2.to_string())
}
fn parse_key(key: String) -> InputSeq {
let seq = match key.as_str() {
"C-@" | "C-Space" | "^@" => "\x00",
"C-[" | "Escape" | "^[" => "\x1b",
"C-\\" | "^\\" => "\x1c",
"C-]" | "^]" => "\x1d",
"C-^" | "C-/" => "\x1e",
"C--" | "C-_" => "\x1f",
"Tab" => "\x09", // same as C-i
"Enter" => "\x0d", // same as C-m
"Space" => " ",
"Left" => return cursor_key("\x1b[D", "\x1bOD"),
"Right" => return cursor_key("\x1b[C", "\x1bOC"),
"Up" => return cursor_key("\x1b[A", "\x1bOA"),
"Down" => return cursor_key("\x1b[B", "\x1bOB"),
"C-Left" => "\x1b[1;5D",
"C-Right" => "\x1b[1;5C",
"S-Left" => "\x1b[1;2D",
"S-Right" => "\x1b[1;2C",
"C-Up" => "\x1b[1;5A",
"C-Down" => "\x1b[1;5B",
"S-Up" => "\x1b[1;2A",
"S-Down" => "\x1b[1;2B",
"A-Left" => "\x1b[1;3D",
"A-Right" => "\x1b[1;3C",
"A-Up" => "\x1b[1;3A",
"A-Down" => "\x1b[1;3B",
"C-S-Left" | "S-C-Left" => "\x1b[1;6D",
"C-S-Right" | "S-C-Right" => "\x1b[1;6C",
"C-S-Up" | "S-C-Up" => "\x1b[1;6A",
"C-S-Down" | "S-C-Down" => "\x1b[1;6B",
"C-A-Left" | "A-C-Left" => "\x1b[1;7D",
"C-A-Right" | "A-C-Right" => "\x1b[1;7C",
"C-A-Up" | "A-C-Up" => "\x1b[1;7A",
"C-A-Down" | "A-C-Down" => "\x1b[1;7B",
"A-S-Left" | "S-A-Left" => "\x1b[1;4D",
"A-S-Right" | "S-A-Right" => "\x1b[1;4C",
"A-S-Up" | "S-A-Up" => "\x1b[1;4A",
"A-S-Down" | "S-A-Down" => "\x1b[1;4B",
"C-A-S-Left" | "C-S-A-Left" | "A-C-S-Left" | "S-C-A-Left" | "A-S-C-Left" | "S-A-C-Left" => {
"\x1b[1;8D"
}
"C-A-S-Right" | "C-S-A-Right" | "A-C-S-Right" | "S-C-A-Right" | "A-S-C-Right"
| "S-A-C-Right" => "\x1b[1;8C",
"C-A-S-Up" | "C-S-A-Up" | "A-C-S-Up" | "S-C-A-Up" | "A-S-C-Up" | "S-A-C-Up" => "\x1b[1;8A",
"C-A-S-Down" | "C-S-A-Down" | "A-C-S-Down" | "S-C-A-Down" | "A-S-C-Down" | "S-A-C-Down" => {
"\x1b[1;8B"
}
"F1" => "\x1bOP",
"F2" => "\x1bOQ",
"F3" => "\x1bOR",
"F4" => "\x1bOS",
"F5" => "\x1b[15~",
"F6" => "\x1b[17~",
"F7" => "\x1b[18~",
"F8" => "\x1b[19~",
"F9" => "\x1b[20~",
"F10" => "\x1b[21~",
"F11" => "\x1b[23~",
"F12" => "\x1b[24~",
"C-F1" => "\x1b[1;5P",
"C-F2" => "\x1b[1;5Q",
"C-F3" => "\x1b[1;5R",
"C-F4" => "\x1b[1;5S",
"C-F5" => "\x1b[15;5~",
"C-F6" => "\x1b[17;5~",
"C-F7" => "\x1b[18;5~",
"C-F8" => "\x1b[19;5~",
"C-F9" => "\x1b[20;5~",
"C-F10" => "\x1b[21;5~",
"C-F11" => "\x1b[23;5~",
"C-F12" => "\x1b[24;5~",
"S-F1" => "\x1b[1;2P",
"S-F2" => "\x1b[1;2Q",
"S-F3" => "\x1b[1;2R",
"S-F4" => "\x1b[1;2S",
"S-F5" => "\x1b[15;2~",
"S-F6" => "\x1b[17;2~",
"S-F7" => "\x1b[18;2~",
"S-F8" => "\x1b[19;2~",
"S-F9" => "\x1b[20;2~",
"S-F10" => "\x1b[21;2~",
"S-F11" => "\x1b[23;2~",
"S-F12" => "\x1b[24;2~",
"A-F1" => "\x1b[1;3P",
"A-F2" => "\x1b[1;3Q",
"A-F3" => "\x1b[1;3R",
"A-F4" => "\x1b[1;3S",
"A-F5" => "\x1b[15;3~",
"A-F6" => "\x1b[17;3~",
"A-F7" => "\x1b[18;3~",
"A-F8" => "\x1b[19;3~",
"A-F9" => "\x1b[20;3~",
"A-F10" => "\x1b[21;3~",
"A-F11" => "\x1b[23;3~",
"A-F12" => "\x1b[24;3~",
"Home" => return cursor_key("\x1b[H", "\x1bOH"),
"C-Home" => "\x1b[1;5H",
"S-Home" => "\x1b[1;2H",
"A-Home" => "\x1b[1;3H",
"End" => return cursor_key("\x1b[F", "\x1bOF"),
"C-End" => "\x1b[1;5F",
"S-End" => "\x1b[1;2F",
"A-End" => "\x1b[1;3F",
"PageUp" => "\x1b[5~",
"C-PageUp" => "\x1b[5;5~",
"S-PageUp" => "\x1b[5;2~",
"A-PageUp" => "\x1b[5;3~",
"PageDown" => "\x1b[6~",
"C-PageDown" => "\x1b[6;5~",
"S-PageDown" => "\x1b[6;2~",
"A-PageDown" => "\x1b[6;3~",
k => {
let chars: Vec<char> = k.chars().collect();
match chars.as_slice() {
['C', '-', k @ 'a'..='z'] => {
return standard_key((*k as u8 - 0x60) as char);
}
['C', '-', k @ 'A'..='Z'] => {
return standard_key((*k as u8 - 0x40) as char);
}
['^', k @ 'a'..='z'] => {
return standard_key((*k as u8 - 0x60) as char);
}
['^', k @ 'A'..='Z'] => {
return standard_key((*k as u8 - 0x40) as char);
}
['A', '-', k] => {
return standard_key(format!("\x1b{}", k));
}
_ => &key,
}
}
};
standard_key(seq)
}
#[cfg(test)]
mod test {
use super::{cursor_key, parse_line, standard_key, Command};
use crate::command::InputSeq;
#[test]
fn parse_input() {
let command = parse_line(r#"{ "type": "input", "payload": "hello" }"#).unwrap();
assert!(matches!(command, Command::Input(input) if input == vec![standard_key("hello")]));
}
#[test]
fn parse_input_missing_args() {
parse_line(r#"{ "type": "input" }"#).expect_err("should fail");
}
#[test]
fn parse_send_keys() {
let examples = [
["hello", "hello"],
["C-@", "\x00"],
["C-a", "\x01"],
["C-A", "\x01"],
["^a", "\x01"],
["^A", "\x01"],
["C-z", "\x1a"],
["C-Z", "\x1a"],
["C-[", "\x1b"],
["Space", " "],
["C-Space", "\x00"],
["Tab", "\x09"],
["Enter", "\x0d"],
["Escape", "\x1b"],
["^[", "\x1b"],
["C-Left", "\x1b[1;5D"],
["C-Right", "\x1b[1;5C"],
["S-Left", "\x1b[1;2D"],
["S-Right", "\x1b[1;2C"],
["C-Up", "\x1b[1;5A"],
["C-Down", "\x1b[1;5B"],
["S-Up", "\x1b[1;2A"],
["S-Down", "\x1b[1;2B"],
["A-Left", "\x1b[1;3D"],
["A-Right", "\x1b[1;3C"],
["A-Up", "\x1b[1;3A"],
["A-Down", "\x1b[1;3B"],
["C-S-Left", "\x1b[1;6D"],
["C-S-Right", "\x1b[1;6C"],
["C-S-Up", "\x1b[1;6A"],
["C-S-Down", "\x1b[1;6B"],
["C-A-Left", "\x1b[1;7D"],
["C-A-Right", "\x1b[1;7C"],
["C-A-Up", "\x1b[1;7A"],
["C-A-Down", "\x1b[1;7B"],
["S-A-Left", "\x1b[1;4D"],
["S-A-Right", "\x1b[1;4C"],
["S-A-Up", "\x1b[1;4A"],
["S-A-Down", "\x1b[1;4B"],
["C-A-S-Left", "\x1b[1;8D"],
["C-A-S-Right", "\x1b[1;8C"],
["C-A-S-Up", "\x1b[1;8A"],
["C-A-S-Down", "\x1b[1;8B"],
["A-a", "\x1ba"],
["A-A", "\x1bA"],
["A-z", "\x1bz"],
["A-Z", "\x1bZ"],
["A-1", "\x1b1"],
["A-!", "\x1b!"],
["F1", "\x1bOP"],
["F2", "\x1bOQ"],
["F3", "\x1bOR"],
["F4", "\x1bOS"],
["F5", "\x1b[15~"],
["F6", "\x1b[17~"],
["F7", "\x1b[18~"],
["F8", "\x1b[19~"],
["F9", "\x1b[20~"],
["F10", "\x1b[21~"],
["F11", "\x1b[23~"],
["F12", "\x1b[24~"],
["C-F1", "\x1b[1;5P"],
["C-F2", "\x1b[1;5Q"],
["C-F3", "\x1b[1;5R"],
["C-F4", "\x1b[1;5S"],
["C-F5", "\x1b[15;5~"],
["C-F6", "\x1b[17;5~"],
["C-F7", "\x1b[18;5~"],
["C-F8", "\x1b[19;5~"],
["C-F9", "\x1b[20;5~"],
["C-F10", "\x1b[21;5~"],
["C-F11", "\x1b[23;5~"],
["C-F12", "\x1b[24;5~"],
["S-F1", "\x1b[1;2P"],
["S-F2", "\x1b[1;2Q"],
["S-F3", "\x1b[1;2R"],
["S-F4", "\x1b[1;2S"],
["S-F5", "\x1b[15;2~"],
["S-F6", "\x1b[17;2~"],
["S-F7", "\x1b[18;2~"],
["S-F8", "\x1b[19;2~"],
["S-F9", "\x1b[20;2~"],
["S-F10", "\x1b[21;2~"],
["S-F11", "\x1b[23;2~"],
["S-F12", "\x1b[24;2~"],
["A-F1", "\x1b[1;3P"],
["A-F2", "\x1b[1;3Q"],
["A-F3", "\x1b[1;3R"],
["A-F4", "\x1b[1;3S"],
["A-F5", "\x1b[15;3~"],
["A-F6", "\x1b[17;3~"],
["A-F7", "\x1b[18;3~"],
["A-F8", "\x1b[19;3~"],
["A-F9", "\x1b[20;3~"],
["A-F10", "\x1b[21;3~"],
["A-F11", "\x1b[23;3~"],
["A-F12", "\x1b[24;3~"],
["C-Home", "\x1b[1;5H"],
["S-Home", "\x1b[1;2H"],
["A-Home", "\x1b[1;3H"],
["C-End", "\x1b[1;5F"],
["S-End", "\x1b[1;2F"],
["A-End", "\x1b[1;3F"],
["PageUp", "\x1b[5~"],
["C-PageUp", "\x1b[5;5~"],
["S-PageUp", "\x1b[5;2~"],
["A-PageUp", "\x1b[5;3~"],
["PageDown", "\x1b[6~"],
["C-PageDown", "\x1b[6;5~"],
["S-PageDown", "\x1b[6;2~"],
["A-PageDown", "\x1b[6;3~"],
];
for [key, chars] in examples {
let command = parse_line(&format!(
"{{ \"type\": \"sendKeys\", \"keys\": [\"{key}\"] }}"
))
.unwrap();
assert!(matches!(command, Command::Input(input) if input == vec![standard_key(chars)]));
}
let command = parse_line(
r#"{ "type": "sendKeys", "keys": ["hello", "Enter", "C-c", "A-^", "Left"] }"#,
)
.unwrap();
assert!(
matches!(command, Command::Input(input) if input == vec![standard_key("hello"), standard_key("\x0d"), standard_key("\x03"), standard_key("\x1b^"), cursor_key("\x1b[D", "\x1bOD")])
);
}
#[test]
fn parse_cursor_keys() {
let examples = [
["Left", "\x1b[D", "\x1bOD"],
["Right", "\x1b[C", "\x1bOC"],
["Up", "\x1b[A", "\x1bOA"],
["Down", "\x1b[B", "\x1bOB"],
["Home", "\x1b[H", "\x1bOH"],
["End", "\x1b[F", "\x1bOF"],
];
for [key, seq1, seq2] in examples {
let command = parse_line(&format!(
"{{ \"type\": \"sendKeys\", \"keys\": [\"{key}\"] }}"
))
.unwrap();
if let Command::Input(seqs) = command {
if let InputSeq::Cursor(seq3, seq4) = &seqs[0] {
if seq1 == seq3 && seq2 == seq4 {
continue;
}
panic!("expected {:?} {:?}, got {:?} {:?}", seq1, seq2, seq3, seq4);
}
}
panic!("expected {:?} {:?}", seq1, seq2);
}
}
#[test]
fn parse_send_keys_missing_args() {
parse_line(r#"{ "type": "sendKeys" }"#).expect_err("should fail");
}
#[test]
fn parse_resize() {
let command = parse_line(r#"{ "type": "resize", "cols": 80, "rows": 24 }"#).unwrap();
assert!(matches!(command, Command::Resize(80, 24)));
}
#[test]
fn parse_resize_missing_args() {
parse_line(r#"{ "type": "resize" }"#).expect_err("should fail");
}
#[test]
fn parse_take_snapshot() {
let command = parse_line(r#"{ "type": "takeSnapshot" }"#).unwrap();
assert!(matches!(command, Command::Snapshot));
}
#[test]
fn parse_invalid_json() {
parse_line("{").expect_err("should fail");
}
}

View File

@ -1,3 +1,4 @@
use crate::api::Subscription;
use anyhow::bail;
use clap::Parser;
use nix::pty;
@ -18,6 +19,10 @@ pub struct Cli {
/// Enable HTTP server
#[arg(short, long, value_name = "LISTEN_ADDR", default_missing_value = "127.0.0.1:0", num_args = 0..=1)]
pub listen: Option<SocketAddr>,
/// Subscribe to events
#[arg(long, value_name = "EVENTS")]
pub subscribe: Option<Subscription>,
}
impl Cli {

View File

@ -4,7 +4,6 @@ mod command;
mod locale;
mod nbio;
mod pty;
mod server;
mod session;
use anyhow::{Context, Result};
use command::Command;
@ -22,8 +21,8 @@ async fn main() -> Result<()> {
let (command_tx, command_rx) = mpsc::channel(1024);
let (clients_tx, clients_rx) = mpsc::channel(1);
start_http_server(cli.listen, clients_tx.clone()).await?;
let api = start_api(command_tx, clients_tx);
start_http_api(cli.listen, clients_tx.clone()).await?;
let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default());
let pty = start_pty(cli.command, &cli.size, input_rx, output_tx)?;
let session = build_session(&cli.size);
run_event_loop(output_rx, input_tx, command_rx, clients_rx, session, api).await?;
@ -34,11 +33,12 @@ fn build_session(size: &cli::Size) -> Session {
Session::new(size.cols(), size.rows())
}
fn start_api(
fn start_stdio_api(
command_tx: mpsc::Sender<Command>,
clients_tx: mpsc::Sender<session::Client>,
sub: api::Subscription,
) -> JoinHandle<Result<()>> {
tokio::spawn(api::start(command_tx, clients_tx))
tokio::spawn(api::stdio::start(command_tx, clients_tx, sub))
}
fn start_pty(
@ -55,13 +55,13 @@ fn start_pty(
)?))
}
async fn start_http_server(
async fn start_http_api(
listen_addr: Option<SocketAddr>,
clients_tx: mpsc::Sender<session::Client>,
) -> Result<()> {
if let Some(addr) = listen_addr {
let listener = TcpListener::bind(addr).context("cannot start HTTP listener")?;
tokio::spawn(server::start(listener, clients_tx).await?);
tokio::spawn(api::http::start(listener, clients_tx).await?);
}
Ok(())

View File

@ -1,5 +1,6 @@
use anyhow::Result;
use futures_util::{stream, Stream, StreamExt};
use serde_json::json;
use std::future;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, mpsc, oneshot};
@ -103,6 +104,47 @@ impl Session {
}
}
impl Event {
pub fn to_json(&self) -> serde_json::Value {
match self {
Event::Init(_time, cols, rows, seq, text) => json!({
"type": "init",
"data": json!({
"cols": cols,
"rows": rows,
"seq": seq,
"text": text,
})
}),
Event::Output(_time, seq) => json!({
"type": "output",
"data": json!({
"seq": seq
})
}),
Event::Resize(_time, cols, rows) => json!({
"type": "resize",
"data": json!({
"cols": cols,
"rows": rows,
})
}),
Event::Snapshot(cols, rows, seq, text) => json!({
"type": "snapshot",
"data": json!({
"cols": cols,
"rows": rows,
"seq": seq,
"text": text,
})
}),
}
}
}
fn build_vt(cols: usize, rows: usize) -> avt::Vt {
avt::Vt::builder().size(cols, rows).resizable(true).build()
}