mirror of
https://github.com/andyk/ht.git
synced 2024-10-04 01:08:04 +03:00
Compare commits
5 Commits
b5b44f6347
...
fc824b1877
Author | SHA1 | Date | |
---|---|---|---|
|
fc824b1877 | ||
|
4da196b6ec | ||
|
c52027e1bd | ||
|
a9de581618 | ||
|
5ccc6290a2 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -415,7 +415,7 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "ht"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"avt",
|
||||
|
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ht"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.74"
|
||||
|
||||
|
91
README.md
91
README.md
@ -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
|
||||
|
484
src/api.rs
484
src/api.rs
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
488
src/api/stdio.rs
Normal 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");
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
14
src/main.rs
14
src/main.rs
@ -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(())
|
||||
|
@ -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()
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user