feat: forward ssh keystroke data to tui
Makes controls usable by taking SSH keystroke data (encoded in the xterm format) and converting them to crossterm `KeyCode`s / `KeyEvent`s and sending them to the `Tui`. Also adds support for suspension of the `Tui` over the SSH connection. Some minor refactoring was also done.
This commit is contained in:
parent
15a6cfe4e2
commit
9c92bf4f3e
7 changed files with 401 additions and 113 deletions
|
@ -4,6 +4,7 @@
|
||||||
"<q>": "Quit", // Quit the application
|
"<q>": "Quit", // Quit the application
|
||||||
"<Ctrl-d>": "Quit", // Another way to quit
|
"<Ctrl-d>": "Quit", // Another way to quit
|
||||||
"<Ctrl-c>": "Quit", // Yet another way to quit
|
"<Ctrl-c>": "Quit", // Yet another way to quit
|
||||||
|
"<Esc>": "Quit", // Final way to quit
|
||||||
"<Ctrl-z>": "Suspend" // Suspend the application
|
"<Ctrl-z>": "Suspend" // Suspend the application
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
136
src/app.rs
136
src/app.rs
|
@ -1,16 +1,21 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::{eyre, Result};
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::prelude::Rect;
|
use ratatui::prelude::Rect;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::{
|
||||||
|
sync::{mpsc, Mutex, RwLock},
|
||||||
|
task::block_in_place,
|
||||||
|
};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
action::Action,
|
action::Action,
|
||||||
components::{fps::FpsCounter, home::Home, Component},
|
components::{fps::FpsCounter, home::Home, Component},
|
||||||
config::Config,
|
config::Config,
|
||||||
|
keycode::KeyCodeExt,
|
||||||
tui::{Event, Terminal, Tui},
|
tui::{Event, Terminal, Tui},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -25,6 +30,7 @@ pub struct App {
|
||||||
last_tick_key_events: Vec<KeyEvent>,
|
last_tick_key_events: Vec<KeyEvent>,
|
||||||
action_tx: mpsc::UnboundedSender<Action>,
|
action_tx: mpsc::UnboundedSender<Action>,
|
||||||
action_rx: mpsc::UnboundedReceiver<Action>,
|
action_rx: mpsc::UnboundedReceiver<Action>,
|
||||||
|
ssh_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
@ -34,7 +40,11 @@ pub enum Mode {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> {
|
pub fn new(
|
||||||
|
tick_rate: f64,
|
||||||
|
frame_rate: f64,
|
||||||
|
ssh_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
|
) -> Result<Self> {
|
||||||
let (action_tx, action_rx) = mpsc::unbounded_channel();
|
let (action_tx, action_rx) = mpsc::unbounded_channel();
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
tick_rate,
|
tick_rate,
|
||||||
|
@ -50,74 +60,104 @@ impl App {
|
||||||
last_tick_key_events: Vec::new(),
|
last_tick_key_events: Vec::new(),
|
||||||
action_tx,
|
action_tx,
|
||||||
action_rx,
|
action_rx,
|
||||||
|
ssh_rx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(&mut self, term: Arc<Mutex<Terminal>>) -> Result<()> {
|
pub async fn run(
|
||||||
let mut tui = Tui::new(term)?
|
&mut self,
|
||||||
// .mouse(true) // uncomment this line to enable mouse support
|
term: Arc<Mutex<Terminal>>,
|
||||||
.tick_rate(self.tick_rate)
|
tui: Arc<RwLock<Option<Tui>>>,
|
||||||
.frame_rate(self.frame_rate);
|
) -> Result<()> {
|
||||||
|
let mut tui = tui.write().await;
|
||||||
|
let mut tui = tui.get_or_insert(
|
||||||
|
Tui::new(term)?
|
||||||
|
// .mouse(true) // uncomment this line to enable mouse support
|
||||||
|
.tick_rate(self.tick_rate)
|
||||||
|
.frame_rate(self.frame_rate),
|
||||||
|
);
|
||||||
|
|
||||||
tokio::task::block_in_place(|| tui.enter())?;
|
// Blocking initialization logic for tui and components
|
||||||
|
block_in_place(|| {
|
||||||
|
tui.enter()?;
|
||||||
|
|
||||||
for component in self.components.iter_mut() {
|
for component in self.components.iter_mut() {
|
||||||
component
|
component
|
||||||
.try_lock()?
|
.try_lock()?
|
||||||
.register_action_handler(self.action_tx.clone())?;
|
.register_action_handler(self.action_tx.clone())?;
|
||||||
}
|
}
|
||||||
for component in self.components.iter_mut() {
|
|
||||||
component
|
for component in self.components.iter_mut() {
|
||||||
.try_lock()?
|
component
|
||||||
.register_config_handler(self.config.clone())?;
|
.try_lock()?
|
||||||
}
|
.register_config_handler(self.config.clone())?;
|
||||||
for component in self.components.iter_mut() {
|
}
|
||||||
component
|
|
||||||
.try_lock()?
|
for component in self.components.iter_mut() {
|
||||||
.init(tui.terminal.try_lock()?.size()?)?;
|
component
|
||||||
}
|
.try_lock()?
|
||||||
|
.init(tui.terminal.try_lock()?.size()?)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, eyre::Error>(())
|
||||||
|
})?;
|
||||||
|
|
||||||
let action_tx = self.action_tx.clone();
|
let action_tx = self.action_tx.clone();
|
||||||
|
let mut resume_tx: Option<Arc<CancellationToken>> = None;
|
||||||
loop {
|
loop {
|
||||||
self.handle_events(&mut tui).await?;
|
self.handle_events(&mut tui).await?;
|
||||||
// self.handle_actions(&mut tui)?;
|
// self.handle_actions(&mut tui)?;
|
||||||
tokio::task::block_in_place(|| self.handle_actions(&mut tui))?;
|
block_in_place(|| self.handle_actions(&mut tui))?;
|
||||||
if self.should_suspend {
|
if self.should_suspend {
|
||||||
tui.suspend()?;
|
if let Some(ref tx) = resume_tx {
|
||||||
|
tx.cancel();
|
||||||
|
resume_tx = None;
|
||||||
|
} else {
|
||||||
|
resume_tx = Some(tui.suspend().await?);
|
||||||
|
continue
|
||||||
|
}
|
||||||
action_tx.send(Action::Resume)?;
|
action_tx.send(Action::Resume)?;
|
||||||
action_tx.send(Action::ClearScreen)?;
|
action_tx.send(Action::ClearScreen)?;
|
||||||
// tui.mouse(true);
|
// tui.mouse(true);
|
||||||
tui.enter()?;
|
block_in_place(|| tui.enter())?;
|
||||||
} else if self.should_quit {
|
} else if self.should_quit {
|
||||||
tui.stop()?;
|
block_in_place(|| tui.stop())?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tui.exit()?;
|
|
||||||
Ok(())
|
block_in_place(|| tui.exit())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> {
|
async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> {
|
||||||
let Some(event) = tui.next_event().await else {
|
tokio::select! {
|
||||||
return Ok(());
|
Some(event) = tui.next_event() => {
|
||||||
};
|
// Wait for next event and fire required actions for components
|
||||||
let action_tx = self.action_tx.clone();
|
let action_tx = self.action_tx.clone();
|
||||||
match event {
|
match event {
|
||||||
Event::Quit => action_tx.send(Action::Quit)?,
|
Event::Quit => action_tx.send(Action::Quit)?,
|
||||||
Event::Tick => action_tx.send(Action::Tick)?,
|
Event::Tick => action_tx.send(Action::Tick)?,
|
||||||
Event::Render => action_tx.send(Action::Render)?,
|
Event::Render => action_tx.send(Action::Render)?,
|
||||||
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
|
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
|
||||||
Event::Key(key) => self.handle_key_event(key)?,
|
Event::Key(key) => block_in_place(|| self.handle_key_event(key))?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
};
|
||||||
for component in self.components.iter_mut() {
|
|
||||||
if let Some(action) = component.try_lock()?.handle_events(Some(event.clone()))? {
|
for component in self.components.iter_mut() {
|
||||||
action_tx.send(action)?;
|
let mut component = component.try_lock()?;
|
||||||
|
if let Some(action) = block_in_place(|| component.handle_events(Some(event.clone())))? {
|
||||||
|
action_tx.send(action)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ssh_data) = self.ssh_rx.recv() => {
|
||||||
|
// Receive keystroke data from SSH connection
|
||||||
|
let key_event = KeyCode::from_xterm_seq(&ssh_data[..]).into_key_event();
|
||||||
|
block_in_place(|| self.handle_key_event(key_event))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
|
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
let action_tx = self.action_tx.clone();
|
let action_tx = self.action_tx.clone();
|
||||||
let Some(keymap) = self.config.keybindings.get(&self.mode) else {
|
let Some(keymap) = self.config.keybindings.get(&self.mode) else {
|
||||||
|
@ -130,7 +170,7 @@ impl App {
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// If the key was not handled as a single key action,
|
// If the key was not handled as a single key action,
|
||||||
// then consider it for multi-key combinations.
|
// then consider it for multi-key combinations
|
||||||
self.last_tick_key_events.push(key);
|
self.last_tick_key_events.push(key);
|
||||||
|
|
||||||
// Check for multi-key combinations
|
// Check for multi-key combinations
|
||||||
|
|
171
src/keycode.rs
Normal file
171
src/keycode.rs
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
pub const CTRL_C: char = 3 as char;
|
||||||
|
pub const CTRL_D: char = 4 as char;
|
||||||
|
pub const CTRL_Z: char = 26 as char;
|
||||||
|
|
||||||
|
pub trait KeyCodeExt {
|
||||||
|
/// Create a [KeyCode] from a keystroke byte.
|
||||||
|
fn from(code: u8) -> KeyCode;
|
||||||
|
/// Create a [KeyCode] from a xterm byte sequence of keystroke data.
|
||||||
|
fn from_xterm_seq(seq: &[u8]) -> KeyCode;
|
||||||
|
/// Consume the [KeyCode], converting it into a [KeyEvent].
|
||||||
|
fn into_key_event(self) -> KeyEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyCodeExt for KeyCode {
|
||||||
|
fn from(code: u8) -> KeyCode {
|
||||||
|
match code {
|
||||||
|
// Control sequences (non exhaustive)
|
||||||
|
3 => Self::Char(CTRL_C),
|
||||||
|
4 => Self::Char(CTRL_D),
|
||||||
|
26 => Self::Char(CTRL_Z),
|
||||||
|
|
||||||
|
// Common control characters
|
||||||
|
8 => KeyCode::Backspace,
|
||||||
|
9 => KeyCode::Tab,
|
||||||
|
13 => KeyCode::Enter,
|
||||||
|
27 => KeyCode::Esc,
|
||||||
|
|
||||||
|
// Arrow keys
|
||||||
|
37 => KeyCode::Left,
|
||||||
|
38 => KeyCode::Up,
|
||||||
|
39 => KeyCode::Right,
|
||||||
|
40 => KeyCode::Down,
|
||||||
|
|
||||||
|
// Navigation keys
|
||||||
|
33 => KeyCode::PageUp,
|
||||||
|
34 => KeyCode::PageDown,
|
||||||
|
36 => KeyCode::Home,
|
||||||
|
35 => KeyCode::End,
|
||||||
|
45 => KeyCode::Insert,
|
||||||
|
46 => KeyCode::Delete,
|
||||||
|
|
||||||
|
// Menu key
|
||||||
|
93 => KeyCode::Menu,
|
||||||
|
|
||||||
|
// Printable ASCII characters
|
||||||
|
b' '..=b'~' => KeyCode::Char(code as char),
|
||||||
|
|
||||||
|
// Special characters
|
||||||
|
127 => KeyCode::Backspace,
|
||||||
|
|
||||||
|
// Caps/Num/Scroll Lock
|
||||||
|
20 => KeyCode::CapsLock,
|
||||||
|
144 => KeyCode::NumLock,
|
||||||
|
145 => KeyCode::ScrollLock,
|
||||||
|
|
||||||
|
// Pause/Break
|
||||||
|
19 => KeyCode::Pause,
|
||||||
|
|
||||||
|
// Anything else
|
||||||
|
0 | _ => KeyCode::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
|
fn from_xterm_seq(seq: &[u8]) -> Self {
|
||||||
|
let codes = seq
|
||||||
|
.iter()
|
||||||
|
.map(|&b| <Self as KeyCodeExt>::from(b))
|
||||||
|
.collect::<Vec<Self>>();
|
||||||
|
|
||||||
|
match codes.as_slice() {
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('A')] => Self::Up,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('B')] => Self::Down,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('C')] => Self::Right,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('D')] => Self::Left,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('1'), Self::Char('~')] => Self::Home,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('4'), Self::Char('~')] => Self::End,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('3'), Self::Char('~')] => Self::Delete,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('5'), Self::Char('~')] => Self::PageUp,
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('6'), Self::Char('~')] => Self::PageDown,
|
||||||
|
[Self::Esc, Self::Char('O'), Self::Char('P')] => Self::F(1),
|
||||||
|
[Self::Esc, Self::Char('O'), Self::Char('Q')] => Self::F(2),
|
||||||
|
[Self::Esc, Self::Char('O'), Self::Char('R')] => Self::F(3),
|
||||||
|
[Self::Esc, Self::Char('O'), Self::Char('S')] => Self::F(4),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('1'), Self::Char('5'), Self::Char('~')] => Self::F(5),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('1'), Self::Char('7'), Self::Char('~')] => Self::F(6),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('1'), Self::Char('8'), Self::Char('~')] => Self::F(7),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('1'), Self::Char('9'), Self::Char('~')] => Self::F(8),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('2'), Self::Char('0'), Self::Char('~')] => Self::F(9),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('2'), Self::Char('1'), Self::Char('~')] => Self::F(10),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('2'), Self::Char('3'), Self::Char('~')] => Self::F(11),
|
||||||
|
[Self::Esc, Self::Char('['), Self::Char('2'), Self::Char('4'), Self::Char('~')] => Self::F(12),
|
||||||
|
[single] => *single,
|
||||||
|
_ => KeyCode::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_key_event(self) -> KeyEvent {
|
||||||
|
match self {
|
||||||
|
Self::Char(CTRL_C) => KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
Self::Char(CTRL_D) => KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||||
|
Self::Char(CTRL_Z) => KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
|
||||||
|
other => KeyEvent::new(other, KeyModifiers::empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keycode_from_control_chars() {
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(8), KeyCode::Backspace);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(9), KeyCode::Tab);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(13), KeyCode::Enter);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(27), KeyCode::Esc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keycode_from_printable_ascii() {
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(b'a'), KeyCode::Char('a'));
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(b'Z'), KeyCode::Char('Z'));
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(b'0'), KeyCode::Char('0'));
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(b'~'), KeyCode::Char('~'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keycode_from_special_keys() {
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(20), KeyCode::CapsLock);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(144), KeyCode::NumLock);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(145), KeyCode::ScrollLock);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(19), KeyCode::Pause);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(0), KeyCode::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keycode_from_invalid() {
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(255), KeyCode::Null);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from(200), KeyCode::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keycode_from_seq() {
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[65]), KeyCode::Char('A'));
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27]), KeyCode::Esc);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[0]), KeyCode::Null);
|
||||||
|
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 68]), KeyCode::Left);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 67]), KeyCode::Right);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 65]), KeyCode::Up);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 66]), KeyCode::Down);
|
||||||
|
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 53, 126]), KeyCode::PageUp);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 54, 126]), KeyCode::PageDown);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 51, 126]), KeyCode::Delete);
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 52, 126]), KeyCode::End);
|
||||||
|
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 49, 56, 126]), KeyCode::F(7));
|
||||||
|
assert_eq!(<KeyCode as KeyCodeExt>::from_xterm_seq(&[27, 91, 49, 57, 126]), KeyCode::F(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_into_key_event() {
|
||||||
|
let key_code = KeyCode::Char('a');
|
||||||
|
let key_event = <KeyCode as KeyCodeExt>::into_key_event(key_code);
|
||||||
|
assert_eq!(key_event, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ mod errors;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod tui;
|
mod tui;
|
||||||
|
mod keycode;
|
||||||
|
|
||||||
const SOCKET_ADDR: LazyLock<SocketAddr> = LazyLock::new(|| SocketAddr::from(([127, 0, 0, 1], 2222)));
|
const SOCKET_ADDR: LazyLock<SocketAddr> = LazyLock::new(|| SocketAddr::from(([127, 0, 0, 1], 2222)));
|
||||||
pub static SSH_CONFIG: OnceLock<Arc<Config>> = OnceLock::new();
|
pub static SSH_CONFIG: OnceLock<Arc<Config>> = OnceLock::new();
|
||||||
|
|
84
src/ssh.rs
84
src/ssh.rs
|
@ -1,15 +1,22 @@
|
||||||
use std::{io::Write, net::SocketAddr, sync::Arc};
|
use std::{io::Write, net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use color_eyre::eyre::eyre;
|
||||||
use ratatui::prelude::CrosstermBackend;
|
use ratatui::prelude::CrosstermBackend;
|
||||||
use russh::{
|
use russh::{
|
||||||
server::{Auth, Handle, Handler, Msg, Server, Session},
|
server::{Auth, Handle, Handler, Msg, Server, Session},
|
||||||
Channel, ChannelId, CryptoVec, Pty,
|
Channel, ChannelId, CryptoVec, Pty,
|
||||||
};
|
};
|
||||||
use tokio::{runtime::Handle as TokioHandle, sync::Mutex};
|
use tokio::{
|
||||||
|
runtime::Handle as TokioHandle,
|
||||||
|
sync::{mpsc, Mutex, RwLock},
|
||||||
|
};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{app::App, tui::Terminal};
|
use crate::{
|
||||||
|
app::App,
|
||||||
|
tui::{Terminal, Tui},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TermWriter {
|
pub struct TermWriter {
|
||||||
|
@ -27,6 +34,19 @@ impl TermWriter {
|
||||||
inner: CryptoVec::new(),
|
inner: CryptoVec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flush_inner(&mut self) -> std::io::Result<()> {
|
||||||
|
let handle = TokioHandle::current();
|
||||||
|
handle.block_on(async move {
|
||||||
|
self.session
|
||||||
|
.data(self.channel.id(), self.inner.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
std::io::Error::other(String::from_iter(err.iter().map(|item| *item as char)))
|
||||||
|
})
|
||||||
|
.and_then(|()| Ok(self.inner.clear()))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Write for TermWriter {
|
impl Write for TermWriter {
|
||||||
|
@ -38,32 +58,29 @@ impl Write for TermWriter {
|
||||||
|
|
||||||
#[instrument(skip(self), level = "trace")]
|
#[instrument(skip(self), level = "trace")]
|
||||||
fn flush(&mut self) -> std::io::Result<()> {
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
let handle = TokioHandle::current();
|
tokio::task::block_in_place(|| self.flush_inner())
|
||||||
handle.block_on(async move {
|
|
||||||
self.session
|
|
||||||
.data(self.channel.id(), self.inner.clone())
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
std::io::Error::other(String::from_iter(err.iter().map(|item| *item as char)))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
self.inner.clear();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SshSession(Option<Arc<Mutex<App>>>);
|
pub struct SshSession {
|
||||||
|
app: Option<Arc<Mutex<App>>>,
|
||||||
|
ssh_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||||
|
tui: Arc<RwLock<Option<Tui>>>,
|
||||||
|
}
|
||||||
|
|
||||||
unsafe impl Send for SshSession {}
|
unsafe impl Send for SshSession {}
|
||||||
|
|
||||||
impl SshSession {
|
impl SshSession {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self(
|
let (ssh_tx, ssh_rx) = mpsc::unbounded_channel();
|
||||||
App::new(10f64, 60f64)
|
|
||||||
|
Self {
|
||||||
|
app: App::new(10f64, 60f64, ssh_rx)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|app| Arc::new(Mutex::new(app))),
|
.map(|app| Arc::new(Mutex::new(app))),
|
||||||
)
|
tui: Arc::new(RwLock::new(None)),
|
||||||
|
ssh_tx,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,23 +99,26 @@ impl Handler for SshSession {
|
||||||
channel: Channel<Msg>,
|
channel: Channel<Msg>,
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> Result<bool, Self::Error> {
|
||||||
if let Some(app) = &self.0 {
|
if let Some(app) = &self.app {
|
||||||
|
let session_handle = session.handle();
|
||||||
|
let channel_id = channel.id();
|
||||||
|
|
||||||
let inner_app = Arc::clone(app);
|
let inner_app = Arc::clone(app);
|
||||||
let term = Terminal::new(CrosstermBackend::new(TermWriter::new(session, channel)))?;
|
let term = Terminal::new(CrosstermBackend::new(TermWriter::new(session, channel)))?;
|
||||||
let writer = Arc::new(Mutex::new(term));
|
let writer = Arc::new(Mutex::new(term));
|
||||||
|
let tui = Arc::clone(&self.tui);
|
||||||
|
|
||||||
tokio::task::spawn(async move {
|
tokio::task::spawn(async move {
|
||||||
inner_app.lock_owned().await.run(writer).await.unwrap();
|
inner_app.lock_owned().await.run(writer, tui).await.unwrap();
|
||||||
|
session_handle.close(channel_id).await.unwrap();
|
||||||
|
session_handle.exit_status_request(channel_id, 0).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(color_eyre::eyre::eyre!(
|
Err(eyre!("Failed to initialize App for session"))
|
||||||
"Failed to initialize App for session"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _session), level = "trace")]
|
#[instrument(skip(self, _session), level = "trace")]
|
||||||
async fn pty_request(
|
async fn pty_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
@ -111,10 +131,23 @@ impl Handler for SshSession {
|
||||||
modes: &[(Pty, u32)],
|
modes: &[(Pty, u32)],
|
||||||
_session: &mut Session,
|
_session: &mut Session,
|
||||||
) -> Result<(), Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
tracing::info!("received pty request from channel {channel_id}");
|
tracing::info!("Received pty request from channel {channel_id}");
|
||||||
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, _session), level = "trace")]
|
||||||
|
async fn data(
|
||||||
|
&mut self,
|
||||||
|
_channel: ChannelId,
|
||||||
|
data: &[u8],
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
tracing::debug!("Received keystroke data from SSH: {:?}, sending", data);
|
||||||
|
self.ssh_tx
|
||||||
|
.send(data.to_vec())
|
||||||
|
.map_err(|_| eyre!("Failed to send event keystroke data"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
@ -127,7 +160,6 @@ impl Server for SshServer {
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
|
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
|
||||||
let session = SshSession::new();
|
let session = SshSession::new();
|
||||||
// self.0.push((peer_addr.unwrap(), session));
|
|
||||||
session
|
session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#![allow(dead_code)] // Remove this once you start using the code
|
#![allow(dead_code)] // TODO: Remove this once you start using the code
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
@ -14,10 +14,11 @@ use crossterm::{
|
||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
use ratatui::backend::CrosstermBackend as Backend;
|
use ratatui::backend::CrosstermBackend as Backend;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use status::TuiStatus;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{
|
sync::{
|
||||||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||||
Mutex,
|
Mutex, RwLock,
|
||||||
},
|
},
|
||||||
task::JoinHandle,
|
task::JoinHandle,
|
||||||
time::interval,
|
time::interval,
|
||||||
|
@ -27,6 +28,8 @@ use tracing::error;
|
||||||
|
|
||||||
use crate::ssh::TermWriter;
|
use crate::ssh::TermWriter;
|
||||||
|
|
||||||
|
pub(crate) mod status;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
Init,
|
Init,
|
||||||
|
@ -50,6 +53,8 @@ pub struct Tui {
|
||||||
pub cancellation_token: CancellationToken,
|
pub cancellation_token: CancellationToken,
|
||||||
pub event_rx: UnboundedReceiver<Event>,
|
pub event_rx: UnboundedReceiver<Event>,
|
||||||
pub event_tx: UnboundedSender<Event>,
|
pub event_tx: UnboundedSender<Event>,
|
||||||
|
pub resume_tx: Option<UnboundedSender<()>>,
|
||||||
|
pub status: Arc<RwLock<TuiStatus>>,
|
||||||
pub frame_rate: f64,
|
pub frame_rate: f64,
|
||||||
pub tick_rate: f64,
|
pub tick_rate: f64,
|
||||||
pub mouse: bool,
|
pub mouse: bool,
|
||||||
|
@ -65,6 +70,8 @@ impl Tui {
|
||||||
cancellation_token: CancellationToken::new(),
|
cancellation_token: CancellationToken::new(),
|
||||||
event_rx,
|
event_rx,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
resume_tx: Option::None,
|
||||||
|
status: Arc::new(RwLock::new(TuiStatus::Active)),
|
||||||
frame_rate: 60.0,
|
frame_rate: 60.0,
|
||||||
tick_rate: 4.0,
|
tick_rate: 4.0,
|
||||||
mouse: false,
|
mouse: false,
|
||||||
|
@ -96,6 +103,7 @@ impl Tui {
|
||||||
self.cancel(); // Cancel any existing task
|
self.cancel(); // Cancel any existing task
|
||||||
self.cancellation_token = CancellationToken::new();
|
self.cancellation_token = CancellationToken::new();
|
||||||
let event_loop = Self::event_loop(
|
let event_loop = Self::event_loop(
|
||||||
|
self.status.clone(),
|
||||||
self.event_tx.clone(),
|
self.event_tx.clone(),
|
||||||
self.cancellation_token.clone(),
|
self.cancellation_token.clone(),
|
||||||
self.tick_rate,
|
self.tick_rate,
|
||||||
|
@ -107,6 +115,7 @@ impl Tui {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn event_loop(
|
async fn event_loop(
|
||||||
|
status: Arc<RwLock<TuiStatus>>,
|
||||||
event_tx: UnboundedSender<Event>,
|
event_tx: UnboundedSender<Event>,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
tick_rate: f64,
|
tick_rate: f64,
|
||||||
|
@ -120,27 +129,33 @@ impl Tui {
|
||||||
event_tx
|
event_tx
|
||||||
.send(Event::Init)
|
.send(Event::Init)
|
||||||
.expect("failed to send init event");
|
.expect("failed to send init event");
|
||||||
|
|
||||||
|
let suspension_status = Arc::clone(&status);
|
||||||
loop {
|
loop {
|
||||||
|
let _guard = suspension_status.read().await;
|
||||||
let event = tokio::select! {
|
let event = tokio::select! {
|
||||||
_ = cancellation_token.cancelled() => {
|
_ = cancellation_token.cancelled() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ = tick_interval.tick() => Event::Tick,
|
_ = tick_interval.tick() => Event::Tick,
|
||||||
_ = render_interval.tick() => Event::Render,
|
_ = render_interval.tick() => Event::Render,
|
||||||
crossterm_event = event_stream.next().fuse() => match crossterm_event {
|
crossterm_event = event_stream.next().fuse() => {
|
||||||
Some(Ok(event)) => match event {
|
match crossterm_event {
|
||||||
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
|
Some(Ok(event)) => match event {
|
||||||
CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
|
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
|
||||||
CrosstermEvent::Resize(x, y) => Event::Resize(x, y),
|
CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
|
||||||
CrosstermEvent::FocusLost => Event::FocusLost,
|
CrosstermEvent::Resize(x, y) => Event::Resize(x, y),
|
||||||
CrosstermEvent::FocusGained => Event::FocusGained,
|
CrosstermEvent::FocusLost => Event::FocusLost,
|
||||||
CrosstermEvent::Paste(s) => Event::Paste(s),
|
CrosstermEvent::FocusGained => Event::FocusGained,
|
||||||
_ => continue, // ignore other events
|
CrosstermEvent::Paste(s) => Event::Paste(s),
|
||||||
|
_ => continue, // ignore other events
|
||||||
|
}
|
||||||
|
Some(Err(_)) => Event::Error,
|
||||||
|
None => break, // the event stream has stopped and will not produce any more events
|
||||||
}
|
}
|
||||||
Some(Err(_)) => Event::Error,
|
|
||||||
None => break, // the event stream has stopped and will not produce any more events
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if event_tx.send(event).is_err() {
|
if event_tx.send(event).is_err() {
|
||||||
// the receiver has been dropped, so there's no point in continuing the loop
|
// the receiver has been dropped, so there's no point in continuing the loop
|
||||||
break;
|
break;
|
||||||
|
@ -180,6 +195,7 @@ impl Tui {
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(term);
|
drop(term);
|
||||||
|
|
||||||
self.start();
|
self.start();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -209,16 +225,32 @@ impl Tui {
|
||||||
self.cancellation_token.cancel();
|
self.cancellation_token.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn suspend(&mut self) -> Result<()> {
|
pub async fn suspend(&mut self) -> Result<Arc<CancellationToken>> {
|
||||||
self.exit()?;
|
// Exit the current Tui
|
||||||
#[cfg(not(windows))]
|
tokio::task::block_in_place(|| self.exit())?;
|
||||||
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resume(&mut self) -> Result<()> {
|
// Update the status and initialize a cancellation token
|
||||||
self.enter()?;
|
let token = Arc::new(CancellationToken::new());
|
||||||
Ok(())
|
let suspension = Arc::new(Mutex::new(()));
|
||||||
|
*self.status.write().await = TuiStatus::Suspended(Arc::clone(&suspension));
|
||||||
|
|
||||||
|
// Spawn a task holding on the lock until a notification interrupts it
|
||||||
|
let status = Arc::clone(&self.status);
|
||||||
|
let lock_release = Arc::clone(&token);
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = lock_release.cancelled() => {
|
||||||
|
// Lock was released, update the status
|
||||||
|
*status.write().await = TuiStatus::Active;
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(tokio::time::Duration::from_secs(u64::MAX)) => {
|
||||||
|
// Hold on to the lock until notified
|
||||||
|
let _ = suspension.lock().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn next_event(&mut self) -> Option<Event> {
|
pub async fn next_event(&mut self) -> Option<Event> {
|
||||||
|
@ -226,20 +258,6 @@ impl Tui {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// impl Deref for Tui {
|
|
||||||
// type Target = ratatui::Terminal<Backend<TermWriter>>;
|
|
||||||
|
|
||||||
// fn deref(&self) -> &Self::Target {
|
|
||||||
// self.terminal.as_ref()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// impl DerefMut for Tui {
|
|
||||||
// fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
// self.terminal.get_mut().unwrap()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
impl Drop for Tui {
|
impl Drop for Tui {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.exit().unwrap();
|
self.exit().unwrap();
|
25
src/tui/status.rs
Normal file
25
src/tui/status.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
use std::{future::{Future, IntoFuture}, pin::Pin, sync::Arc};
|
||||||
|
|
||||||
|
use futures::future;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TuiStatus {
|
||||||
|
Active,
|
||||||
|
Suspended(Arc<Mutex<()>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoFuture for TuiStatus {
|
||||||
|
type Output = ();
|
||||||
|
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output>>>;
|
||||||
|
|
||||||
|
fn into_future(self) -> Self::IntoFuture {
|
||||||
|
if let Self::Suspended(lock) = self {
|
||||||
|
return Box::pin(async move {
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::pin(future::ready(()))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue