use std::collections::VecDeque;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crate::ansi::{TermEvent, Terminal};
use crate::autologin::{AutoLogin, Step};
use crate::dialer::Entry;
use crate::screen::Screen;
use crate::telnet::Telnet;
use crate::transfer::{
self, Download, Kermit, Outcome, TrailerFilter, Transfer, Upload, XyProtocol, XyReceive, XySend,
};
pub use crate::transport::{AuthPrompt, ConnectOptions, NetEvent, PromptKind, Writer, spawn_connection};
const PROMPT_QUIET: Duration = Duration::from_millis(250);
const BUSY_WINDOW: Duration = Duration::from_secs(30);
fn says_busy(screen: &Screen) -> bool {
const WORDS: [&str; 6] = ["busy", "no free node", "all nodes", "all lines", "try again later", "too many users"];
(0..screen.rows()).any(|y| {
let line = Screen::row_text(screen.row(y)).to_ascii_lowercase();
WORDS.iter().any(|w| line.contains(w))
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Connecting,
Connected,
Closed,
}
#[derive(Clone, Debug)]
pub struct SessionConfig {
pub scrollback: usize,
pub known_hosts: PathBuf,
pub download_dir: PathBuf,
pub ssh_agent: bool,
pub ssh_key_dir: Option<PathBuf>,
pub modem: crate::modem::ModemConfig,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SessionEvent {
Connected,
Term(TermEvent),
Notice(String),
UploadRequested,
AuthPrompt,
TransferStarted,
TransferFinished(Outcome),
}
pub struct Session {
pub entry: Entry,
pub term: Terminal,
pub state: State,
pub error: Option<String>,
config: SessionConfig,
telnet: Option<Telnet>,
writer: Option<Writer>,
capture: Option<Capture>,
transfer: Option<Transfer>,
upload_wait: Option<Vec<u8>>,
auth_prompt: Option<AuthPrompt>,
login: AutoLogin,
quiet_since: Option<Instant>,
paced: VecDeque<u8>,
pace_credit: f64,
pace_at: Instant,
scan_tail: Vec<u8>,
trailer: Option<TrailerFilter>,
started: Instant,
connected_at: Option<Instant>,
events: Vec<SessionEvent>,
}
impl Session {
pub fn new(entry: Entry, cols: usize, rows: usize, config: SessionConfig) -> Session {
let login = AutoLogin::new(&entry);
let mut term = Terminal::new(cols, rows, config.scrollback);
term.encoding = entry.encoding;
term.screen.base_font = entry.font;
term.screen.base_ice = entry.ice.unwrap_or(false);
term.screen.ice_colors = term.screen.base_ice;
term.screen.look.fonts[0] = entry.font;
term.start_emulation();
let telnet = entry.protocol.is_telnet().then(|| Telnet::new(&entry.term_type, cols as u16, rows as u16));
Session {
entry,
term,
state: State::Connecting,
error: None,
config,
telnet,
writer: None,
capture: None,
transfer: None,
upload_wait: None,
auth_prompt: None,
login,
quiet_since: None,
paced: VecDeque::new(),
pace_credit: 0.0,
pace_at: Instant::now(),
scan_tail: Vec::new(),
trailer: None,
started: Instant::now(),
connected_at: None,
events: Vec::new(),
}
}
pub fn connect<F>(&self, sink: F)
where
F: Fn(NetEvent) + Send + 'static,
{
let opts = ConnectOptions {
cols: self.term.screen.cols() as u16,
rows: self.term.screen.rows() as u16,
known_hosts: self.config.known_hosts.clone(),
ssh_agent: self.config.ssh_agent,
ssh_key_dir: self.config.ssh_key_dir.clone(),
modem: self.config.modem.clone(),
};
spawn_connection(&self.entry, &opts, sink);
}
pub fn handle(&mut self, ev: NetEvent) {
match ev {
NetEvent::Connected(w) => {
self.writer = Some(w);
self.state = State::Connected;
self.connected_at = Some(Instant::now());
self.login.connected();
self.events.push(SessionEvent::Connected);
}
NetEvent::Data(bytes) => self.receive(&bytes),
NetEvent::Notice(msg) => self.events.push(SessionEvent::Notice(msg)),
NetEvent::Prompt(p) => {
self.auth_prompt = Some(p);
self.events.push(SessionEvent::AuthPrompt);
}
NetEvent::Closed(err) => {
self.auth_prompt = None;
self.state = State::Closed;
self.error = err;
self.writer = None;
self.end_transfer(Some(Outcome::Failed("the connection closed".into())));
}
}
}
fn receive(&mut self, bytes: &[u8]) {
let data = match &mut self.telnet {
Some(t) => {
let (mut data, mut reply) = (Vec::with_capacity(bytes.len()), Vec::new());
t.receive(bytes, &mut data, &mut reply);
if !reply.is_empty() {
self.write_raw(&reply);
}
data
}
None => bytes.to_vec(),
};
if self.entry.baud == 0 || self.transfer.is_some() && self.paced.is_empty() {
self.process(data);
self.quiet_since = Some(Instant::now());
} else {
if self.paced.is_empty() {
self.pace_at = Instant::now();
}
self.paced.extend(data);
self.pump();
}
}
pub fn pacing(&self) -> bool {
!self.paced.is_empty()
}
pub fn pump(&mut self) {
if self.paced.is_empty() {
return;
}
let now = Instant::now();
self.pace_credit += now.duration_since(self.pace_at).as_secs_f64() * self.entry.baud as f64 / 10.0;
self.pace_at = now;
let n = (self.pace_credit as usize).min(self.paced.len());
if n == 0 {
return;
}
self.pace_credit -= n as f64;
let chunk: Vec<u8> = self.paced.drain(..n).collect();
self.process(chunk);
if self.transfer.is_some() {
let rest: Vec<u8> = self.paced.drain(..).collect();
self.process(rest);
}
if self.paced.is_empty() {
self.pace_credit = 0.0;
}
self.quiet_since = Some(now);
}
fn check_login_prompt(&mut self) {
let Some(t) = self.quiet_since else { return };
if t.elapsed() < PROMPT_QUIET || self.transfer.is_some() || self.upload_wait.is_some() {
return;
}
self.quiet_since = None;
let screen = &self.term.screen;
let (x, y) = screen.cursor();
let row = screen.row(y);
let prompt = Screen::row_text(&row[..x.min(row.len())]);
if let Some(step) = self.login.answer(&prompt) {
self.send_login_step(step);
}
}
pub fn send_login(&mut self) -> bool {
match self.login.next_step() {
Some(step) => {
self.send_login_step(step);
true
}
None => false,
}
}
pub fn has_login(&self) -> bool {
self.login.has_login()
}
fn send_login_step(&mut self, step: Step) {
let (text, what) = match step {
Step::User(u) => (u, "user name"),
Step::Password(p) => (p, "password"),
};
let bytes = self.term.encoding.encode_text(&format!("{text}\r"));
self.send(&bytes);
self.events.push(SessionEvent::Notice(format!("Sent your {what}")));
}
fn process(&mut self, mut data: Vec<u8>) {
if let Some(f) = &mut self.trailer
&& !f.strip(&mut data)
{
self.trailer = None;
}
if let Some(t) = &mut self.transfer {
let mut out = Vec::new();
t.feed(&data, &mut out);
self.send(&out);
self.end_transfer(None);
return;
}
if let Some(buf) = &mut self.upload_wait {
buf.extend_from_slice(&data);
return;
}
let mut scan = std::mem::take(&mut self.scan_tail);
let already_shown = scan.len();
scan.extend_from_slice(&data);
if let Some(i) = transfer::find(&scan, transfer::ZRQINIT) {
self.show(&scan[already_shown.min(i)..i]);
match Download::new(self.config.download_dir.clone()) {
Ok(d) => {
self.transfer = Some(Transfer::Download(d));
self.events.push(SessionEvent::TransferStarted);
self.process(scan[i..].to_vec());
}
Err(e) => self.events.push(SessionEvent::Notice(format!("Cannot start download: {e:#}"))),
}
return;
}
if let Some(i) = transfer::find(&scan, transfer::ZRINIT) {
self.show(&scan[already_shown.min(i)..i]);
self.upload_wait = Some(scan[i..].to_vec());
self.events.push(SessionEvent::UploadRequested);
return;
}
if let Some(i) = crate::kermit::find_send_init(&scan) {
self.show(&scan[already_shown.min(i)..i]);
self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
self.events.push(SessionEvent::TransferStarted);
self.process(scan[i..].to_vec());
return;
}
let keep = scan.len().min(transfer::ZRQINIT.len() - 1);
self.scan_tail = scan[scan.len() - keep..].to_vec();
self.show(&data);
}
fn show(&mut self, data: &[u8]) {
if data.is_empty() {
return;
}
if let Some(c) = &mut self.capture {
c.write(data, self.term.encoding);
}
self.term.feed(data);
let replies = self.term.take_replies();
if !replies.is_empty() {
self.send(&replies);
}
self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
}
fn end_transfer(&mut self, force: Option<Outcome>) {
let done = self.transfer.as_ref().is_some_and(Transfer::is_finished);
if !done && force.is_none() {
return;
}
let Some(t) = self.transfer.take() else { return };
let (outcome, leftover) = t.finish();
let outcome = if done { outcome } else { force.unwrap_or(outcome) };
self.events.push(SessionEvent::TransferFinished(outcome));
self.scan_tail.clear();
self.trailer = Some(TrailerFilter::new());
self.process(leftover);
}
pub fn take_events(&mut self) -> Vec<SessionEvent> {
std::mem::take(&mut self.events)
}
pub fn transfer(&self) -> Option<&Transfer> {
self.transfer.as_ref()
}
pub fn auth_prompt(&self) -> Option<&AuthPrompt> {
self.auth_prompt.as_ref()
}
pub fn answer_prompt(&mut self, answer: Option<String>) {
if let Some(p) = self.auth_prompt.take() {
p.answer(answer);
}
}
pub fn upload_requested(&self) -> bool {
self.upload_wait.is_some()
}
pub fn start_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
if let Some(t) = &mut self.transfer {
for p in paths {
t.enqueue(p)?;
}
return Ok(());
}
let up = Upload::new(paths)?;
self.transfer = Some(Transfer::Upload(up));
self.events.push(SessionEvent::TransferStarted);
let waiting = self.upload_wait.take().unwrap_or_default();
self.process(waiting);
Ok(())
}
pub fn start_xy_upload(&mut self, proto: XyProtocol, paths: Vec<PathBuf>) -> Result<()> {
if self.transfer.is_some() {
anyhow::bail!("a transfer is already in progress");
}
let up = XySend::new(proto, paths)?;
self.upload_wait = None;
self.transfer = Some(Transfer::XyUpload(up));
self.events.push(SessionEvent::TransferStarted);
Ok(())
}
pub fn start_xy_download(&mut self, proto: XyProtocol, name: Option<String>) -> Result<()> {
if self.transfer.is_some() {
anyhow::bail!("a transfer is already in progress");
}
let mut t = Transfer::XyDownload(XyReceive::new(proto, self.config.download_dir.clone(), name));
let mut out = Vec::new();
t.tick(Instant::now(), &mut out);
self.transfer = Some(t);
self.send(&out);
self.events.push(SessionEvent::TransferStarted);
Ok(())
}
pub fn start_kermit_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
if self.transfer.is_some() {
anyhow::bail!("a transfer is already in progress");
}
let mut t = Transfer::Kermit(Kermit::send(paths)?);
let mut out = Vec::new();
t.start(&mut out);
self.upload_wait = None;
self.transfer = Some(t);
self.send(&out);
self.events.push(SessionEvent::TransferStarted);
Ok(())
}
pub fn start_kermit_download(&mut self) -> Result<()> {
if self.transfer.is_some() {
anyhow::bail!("a transfer is already in progress");
}
self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
self.events.push(SessionEvent::TransferStarted);
Ok(())
}
pub fn decline_upload(&mut self) {
if self.upload_wait.take().is_some() {
self.send(transfer::CANCEL);
}
}
pub fn cancel_transfer(&mut self) {
if let Some(t) = &mut self.transfer {
let mut out = Vec::new();
t.cancel(&mut out);
self.send(&out);
self.end_transfer(None);
}
}
pub fn tick(&mut self) {
self.pump();
self.term.poll(Instant::now());
let replies = self.term.take_replies();
if !replies.is_empty() {
self.send(&replies);
}
self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
if let Some(t) = &mut self.transfer {
let mut out = Vec::new();
t.tick(Instant::now(), &mut out);
self.send(&out);
self.end_transfer(None);
}
self.check_login_prompt();
}
pub fn next_tick(&self) -> Option<Instant> {
self.term.next_poll()
}
pub fn send(&mut self, data: &[u8]) {
if data.is_empty() {
return;
}
let encoded = match &self.telnet {
Some(t) => t.encode(data),
None => data.to_vec(),
};
self.write_raw(&encoded);
}
fn write_raw(&mut self, data: &[u8]) {
if let Some(w) = &mut self.writer
&& let Err(e) = w.write_all(data)
{
self.error = Some(e.to_string());
self.hang_up();
}
}
pub fn hang_up(&mut self) {
self.answer_prompt(None);
if let Some(w) = self.writer.take() {
w.shutdown();
}
self.state = State::Closed;
self.stop_capture();
}
pub fn should_redial(&self) -> bool {
if self.state != State::Closed {
return false;
}
match self.connected_at {
None => self.error.as_deref().is_some_and(|e| e.contains("connecting to")),
Some(t) => t.elapsed() < BUSY_WINDOW && says_busy(&self.term.screen),
}
}
pub fn elapsed(&self) -> Duration {
self.connected_at.unwrap_or(self.started).elapsed()
}
pub fn download_dir(&self) -> &Path {
&self.config.download_dir
}
pub fn capture_path(&self) -> Option<&Path> {
self.capture.as_ref().map(|c| c.path.as_path())
}
pub fn start_capture(&mut self, path: PathBuf) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let f = File::options()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
let kind = CaptureKind::for_path(&path);
self.capture = Some(Capture { path, file: BufWriter::new(f), kind, text: TextState::Ground });
Ok(())
}
pub fn stop_capture(&mut self) {
if let Some(mut c) = self.capture.take() {
let _ = c.file.flush();
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptureKind {
Raw,
Text,
}
impl CaptureKind {
pub fn for_path(path: &Path) -> CaptureKind {
match path.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("txt") || e.eq_ignore_ascii_case("log") => CaptureKind::Text,
_ => CaptureKind::Raw,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TextState {
Ground,
Escape,
Csi,
String,
}
struct Capture {
path: PathBuf,
file: BufWriter<File>,
kind: CaptureKind,
text: TextState,
}
impl Capture {
fn write(&mut self, data: &[u8], encoding: crate::ansi::Encoding) {
if self.kind == CaptureKind::Raw {
let _ = self.file.write_all(data);
return;
}
let utf8 = encoding == crate::ansi::Encoding::Utf8;
let mut out = Vec::with_capacity(data.len());
for &b in data {
self.text = match (self.text, b) {
(_, 0x18 | 0x1A) => TextState::Ground,
(TextState::Ground, 0x1B) => TextState::Escape,
(TextState::Ground, b'\n' | b'\t') => {
out.push(b);
TextState::Ground
}
(TextState::Ground, 0x0C) => {
out.extend_from_slice(b"\n\n");
TextState::Ground
}
(TextState::Ground, 0x00..=0x1F | 0x7F) => TextState::Ground,
(TextState::Ground, _) if utf8 || b < 0x80 => {
out.push(b);
TextState::Ground
}
(TextState::Ground, _) => {
let mut buf = [0; 4];
out.extend_from_slice(crate::cp437::to_unicode(b).encode_utf8(&mut buf).as_bytes());
TextState::Ground
}
(TextState::Escape, b'[') => TextState::Csi,
(TextState::Escape, b']' | b'P' | b'X' | b'^' | b'_') => TextState::String,
(TextState::Escape, 0x20..=0x2F) => TextState::Escape,
(TextState::Escape, _) => TextState::Ground,
(TextState::Csi, 0x40..=0x7E) => TextState::Ground,
(TextState::Csi, _) => TextState::Csi,
(TextState::String, 0x07) => TextState::Ground,
(TextState::String, 0x1B) => TextState::Escape,
(TextState::String, _) => TextState::String,
};
}
let _ = self.file.write_all(&out);
}
}
impl Drop for Session {
fn drop(&mut self) {
self.hang_up();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ansi::Encoding;
#[test]
fn text_captures_keep_only_the_words() {
let path = std::env::temp_dir().join(format!("badterm-capture-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&path);
assert_eq!(CaptureKind::for_path(&path), CaptureKind::Text);
assert_eq!(CaptureKind::for_path(Path::new("x.ans")), CaptureKind::Raw);
let f = File::create(&path).unwrap();
let mut c =
Capture { path: path.clone(), file: BufWriter::new(f), kind: CaptureKind::Text, text: TextState::Ground };
c.write(b"\x1b[1;3", Encoding::Cp437);
c.write(b"1mHello\x1b]2;title\x07\r\n\xc4\xc4 bye\x07", Encoding::Cp437);
c.file.flush().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "Hello\n\u{2500}\u{2500} bye");
let _ = std::fs::remove_file(&path);
}
}