Skip to main content

badterm_core/
session.rs

1//! A live connection to a BBS: protocol decoding, terminal state, ZMODEM
2//! transfers and session capture. Networking lives in [`crate::transport`].
3
4use std::collections::VecDeque;
5use std::fs::File;
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11
12use crate::ansi::{TermEvent, Terminal};
13use crate::autologin::{AutoLogin, Step};
14use crate::dialer::Entry;
15use crate::screen::Screen;
16use crate::telnet::Telnet;
17use crate::transfer::{self, Download, Outcome, TrailerFilter, Transfer, Upload, XyProtocol, XyReceive, XySend};
18pub use crate::transport::{AuthPrompt, ConnectOptions, NetEvent, PromptKind, Writer, spawn_connection};
19
20/// How long the BBS must be quiet before the text at the cursor is taken
21/// for a prompt.
22const PROMPT_QUIET: Duration = Duration::from_millis(250);
23
24/// How soon after answering a BBS that hangs up may be called busy.
25const BUSY_WINDOW: Duration = Duration::from_secs(30);
26
27/// Whether the screen says the BBS has no free line.
28fn says_busy(screen: &Screen) -> bool {
29    const WORDS: [&str; 6] = ["busy", "no free node", "all nodes", "all lines", "try again later", "too many users"];
30    (0..screen.rows()).any(|y| {
31        let line = Screen::row_text(screen.row(y)).to_ascii_lowercase();
32        WORDS.iter().any(|w| line.contains(w))
33    })
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum State {
38    Connecting,
39    Connected,
40    Closed,
41}
42
43/// Where a session keeps the files it produces.
44#[derive(Clone, Debug)]
45pub struct SessionConfig {
46    pub scrollback: usize,
47    pub known_hosts: PathBuf,
48    pub download_dir: PathBuf,
49    /// Offer the SSH agent's keys when logging in.
50    pub ssh_agent: bool,
51    /// Where default SSH keys (`id_ed25519`, ...) are looked for.
52    pub ssh_key_dir: Option<PathBuf>,
53}
54
55/// Things the front end should react to.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum SessionEvent {
58    /// The connection is up (for SSH, the login succeeded).
59    Connected,
60    Term(TermEvent),
61    Notice(String),
62    /// The BBS is waiting to receive files (it sent ZRINIT); ask the user
63    /// what to upload, then call [`Session::start_upload`] or
64    /// [`Session::decline_upload`].
65    UploadRequested,
66    /// The login has a question for the user: see [`Session::auth_prompt`],
67    /// and reply with [`Session::answer_prompt`].
68    AuthPrompt,
69    TransferStarted,
70    TransferFinished(Outcome),
71}
72
73pub struct Session {
74    pub entry: Entry,
75    pub term: Terminal,
76    pub state: State,
77    /// Why the session closed, if it failed.
78    pub error: Option<String>,
79    config: SessionConfig,
80    telnet: Option<Telnet>,
81    writer: Option<Writer>,
82    capture: Option<(PathBuf, BufWriter<File>)>,
83    transfer: Option<Transfer>,
84    /// Bytes received since the BBS asked for an upload, held until the user
85    /// picks files (they include the ZRINIT the sender needs to see).
86    upload_wait: Option<Vec<u8>>,
87    auth_prompt: Option<AuthPrompt>,
88    login: AutoLogin,
89    /// When data last arrived, until a login prompt check has looked at it.
90    quiet_since: Option<Instant>,
91    /// Baud rate emulation: received data waiting to be shown, how many
92    /// bytes may go now, and when that was worked out.
93    paced: VecDeque<u8>,
94    pace_credit: f64,
95    pace_at: Instant,
96    /// The end of the last data chunk, so a ZMODEM header split across two
97    /// reads is still recognized.
98    scan_tail: Vec<u8>,
99    /// Hides ZMODEM's closing bytes from the terminal after a transfer.
100    trailer: Option<TrailerFilter>,
101    started: Instant,
102    connected_at: Option<Instant>,
103    events: Vec<SessionEvent>,
104}
105
106impl Session {
107    pub fn new(entry: Entry, cols: usize, rows: usize, config: SessionConfig) -> Session {
108        let login = AutoLogin::new(&entry);
109        let mut term = Terminal::new(cols, rows, config.scrollback);
110        term.encoding = entry.encoding;
111        term.screen.base_font = entry.font;
112        term.screen.base_ice = entry.ice.unwrap_or(false);
113        term.screen.ice_colors = term.screen.base_ice;
114        term.screen.look.fonts[0] = entry.font;
115        // A C64 or Atari system: its colors and font.
116        term.start_emulation();
117        let telnet = entry.protocol.is_telnet().then(|| Telnet::new(&entry.term_type, cols as u16, rows as u16));
118        Session {
119            entry,
120            term,
121            state: State::Connecting,
122            error: None,
123            config,
124            telnet,
125            writer: None,
126            capture: None,
127            transfer: None,
128            upload_wait: None,
129            auth_prompt: None,
130            login,
131            quiet_since: None,
132            paced: VecDeque::new(),
133            pace_credit: 0.0,
134            pace_at: Instant::now(),
135            scan_tail: Vec::new(),
136            trailer: None,
137            started: Instant::now(),
138            connected_at: None,
139            events: Vec::new(),
140        }
141    }
142
143    /// Start connecting in the background; events arrive through `sink` and
144    /// must be passed to [`Session::handle`].
145    pub fn connect<F>(&self, sink: F)
146    where
147        F: Fn(NetEvent) + Send + 'static,
148    {
149        let opts = ConnectOptions {
150            cols: self.term.screen.cols() as u16,
151            rows: self.term.screen.rows() as u16,
152            known_hosts: self.config.known_hosts.clone(),
153            ssh_agent: self.config.ssh_agent,
154            ssh_key_dir: self.config.ssh_key_dir.clone(),
155        };
156        spawn_connection(&self.entry, &opts, sink);
157    }
158
159    pub fn handle(&mut self, ev: NetEvent) {
160        match ev {
161            NetEvent::Connected(w) => {
162                self.writer = Some(w);
163                self.state = State::Connected;
164                self.connected_at = Some(Instant::now());
165                self.login.connected();
166                self.events.push(SessionEvent::Connected);
167            }
168            NetEvent::Data(bytes) => self.receive(&bytes),
169            NetEvent::Notice(msg) => self.events.push(SessionEvent::Notice(msg)),
170            NetEvent::Prompt(p) => {
171                self.auth_prompt = Some(p);
172                self.events.push(SessionEvent::AuthPrompt);
173            }
174            NetEvent::Closed(err) => {
175                self.auth_prompt = None;
176                self.state = State::Closed;
177                self.error = err;
178                self.writer = None;
179                self.end_transfer(Some(Outcome::Failed("the connection closed".into())));
180            }
181        }
182    }
183
184    fn receive(&mut self, bytes: &[u8]) {
185        let data = match &mut self.telnet {
186            Some(t) => {
187                let (mut data, mut reply) = (Vec::with_capacity(bytes.len()), Vec::new());
188                t.receive(bytes, &mut data, &mut reply);
189                if !reply.is_empty() {
190                    self.write_raw(&reply);
191                }
192                data
193            }
194            None => bytes.to_vec(),
195        };
196        // Transfers are not slowed down.
197        if self.entry.baud == 0 || self.transfer.is_some() && self.paced.is_empty() {
198            self.process(data);
199            self.quiet_since = Some(Instant::now());
200        } else {
201            if self.paced.is_empty() {
202                self.pace_at = Instant::now();
203            }
204            self.paced.extend(data);
205            self.pump();
206        }
207    }
208
209    /// Whether received data is waiting for baud rate emulation: call
210    /// [`Session::pump`] often (every frame) while it is.
211    pub fn pacing(&self) -> bool {
212        !self.paced.is_empty()
213    }
214
215    /// Show as much paced data as the emulated baud rate allows by now: ten
216    /// bits per byte, as with a modem's start and stop bits.
217    pub fn pump(&mut self) {
218        if self.paced.is_empty() {
219            return;
220        }
221        let now = Instant::now();
222        self.pace_credit += now.duration_since(self.pace_at).as_secs_f64() * self.entry.baud as f64 / 10.0;
223        self.pace_at = now;
224        let n = (self.pace_credit as usize).min(self.paced.len());
225        if n == 0 {
226            return;
227        }
228        self.pace_credit -= n as f64;
229        let chunk: Vec<u8> = self.paced.drain(..n).collect();
230        self.process(chunk);
231        // A transfer that started in that chunk gets the rest at once.
232        if self.transfer.is_some() {
233            let rest: Vec<u8> = self.paced.drain(..).collect();
234            self.process(rest);
235        }
236        if self.paced.is_empty() {
237            self.pace_credit = 0.0;
238        }
239        self.quiet_since = Some(now);
240    }
241
242    /// Answer a login prompt, once the BBS has been quiet for a moment (so
243    /// half a line is not taken for a prompt).
244    fn check_login_prompt(&mut self) {
245        let Some(t) = self.quiet_since else { return };
246        if t.elapsed() < PROMPT_QUIET || self.transfer.is_some() || self.upload_wait.is_some() {
247            return;
248        }
249        self.quiet_since = None;
250        let screen = &self.term.screen;
251        let (x, y) = screen.cursor();
252        let row = screen.row(y);
253        let prompt = Screen::row_text(&row[..x.min(row.len())]);
254        if let Some(step) = self.login.answer(&prompt) {
255            self.send_login_step(step);
256        }
257    }
258
259    /// Send the next part of the stored login (Alt-L): the user name, then
260    /// the password. False when there is nothing (more) to send.
261    pub fn send_login(&mut self) -> bool {
262        match self.login.next_step() {
263            Some(step) => {
264                self.send_login_step(step);
265                true
266            }
267            None => false,
268        }
269    }
270
271    /// Whether the entry has a user name or password to log in with.
272    pub fn has_login(&self) -> bool {
273        self.login.has_login()
274    }
275
276    fn send_login_step(&mut self, step: Step) {
277        let (text, what) = match step {
278            Step::User(u) => (u, "user name"),
279            Step::Password(p) => (p, "password"),
280        };
281        let bytes = self.term.encoding.encode_text(&format!("{text}\r"));
282        self.send(&bytes);
283        self.events.push(SessionEvent::Notice(format!("Sent your {what}")));
284    }
285
286    /// Route decoded data to an active transfer, or detect a new one, or
287    /// show it on the terminal.
288    fn process(&mut self, mut data: Vec<u8>) {
289        if let Some(f) = &mut self.trailer
290            && !f.strip(&mut data)
291        {
292            self.trailer = None;
293        }
294        if let Some(t) = &mut self.transfer {
295            let mut out = Vec::new();
296            t.feed(&data, &mut out);
297            self.send(&out);
298            self.end_transfer(None);
299            return;
300        }
301        if let Some(buf) = &mut self.upload_wait {
302            buf.extend_from_slice(&data);
303            return;
304        }
305
306        let mut scan = std::mem::take(&mut self.scan_tail);
307        let already_shown = scan.len();
308        scan.extend_from_slice(&data);
309        if let Some(i) = transfer::find(&scan, transfer::ZRQINIT) {
310            self.show(&scan[already_shown.min(i)..i]);
311            match Download::new(self.config.download_dir.clone()) {
312                Ok(d) => {
313                    self.transfer = Some(Transfer::Download(d));
314                    self.events.push(SessionEvent::TransferStarted);
315                    self.process(scan[i..].to_vec());
316                }
317                Err(e) => self.events.push(SessionEvent::Notice(format!("Cannot start download: {e:#}"))),
318            }
319            return;
320        }
321        if let Some(i) = transfer::find(&scan, transfer::ZRINIT) {
322            self.show(&scan[already_shown.min(i)..i]);
323            self.upload_wait = Some(scan[i..].to_vec());
324            self.events.push(SessionEvent::UploadRequested);
325            return;
326        }
327        let keep = scan.len().min(transfer::ZRQINIT.len() - 1);
328        self.scan_tail = scan[scan.len() - keep..].to_vec();
329        self.show(&data);
330    }
331
332    fn show(&mut self, data: &[u8]) {
333        if data.is_empty() {
334            return;
335        }
336        if let Some((_, f)) = &mut self.capture {
337            let _ = f.write_all(data);
338        }
339        self.term.feed(data);
340        let replies = self.term.take_replies();
341        if !replies.is_empty() {
342            self.send(&replies);
343        }
344        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
345    }
346
347    /// If the transfer finished (or `force` gives a reason to stop it),
348    /// report the outcome and return its trailing bytes to the terminal.
349    fn end_transfer(&mut self, force: Option<Outcome>) {
350        let done = self.transfer.as_ref().is_some_and(Transfer::is_finished);
351        if !done && force.is_none() {
352            return;
353        }
354        let Some(t) = self.transfer.take() else { return };
355        let (outcome, leftover) = t.finish();
356        let outcome = if done { outcome } else { force.unwrap_or(outcome) };
357        self.events.push(SessionEvent::TransferFinished(outcome));
358        self.scan_tail.clear();
359        self.trailer = Some(TrailerFilter::new());
360        self.process(leftover);
361    }
362
363    pub fn take_events(&mut self) -> Vec<SessionEvent> {
364        std::mem::take(&mut self.events)
365    }
366
367    pub fn transfer(&self) -> Option<&Transfer> {
368        self.transfer.as_ref()
369    }
370
371    /// The login question waiting for an answer, if any.
372    pub fn auth_prompt(&self) -> Option<&AuthPrompt> {
373        self.auth_prompt.as_ref()
374    }
375
376    /// Answer the waiting login question; `None` cancels the login.
377    pub fn answer_prompt(&mut self, answer: Option<String>) {
378        if let Some(p) = self.auth_prompt.take() {
379            p.answer(answer);
380        }
381    }
382
383    /// Whether the BBS is waiting for the user to choose files to upload.
384    pub fn upload_requested(&self) -> bool {
385        self.upload_wait.is_some()
386    }
387
388    /// Send files with ZMODEM. If a batch is already being sent, the files
389    /// are added to it.
390    pub fn start_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
391        if let Some(t) = &mut self.transfer {
392            for p in paths {
393                t.enqueue(p)?;
394            }
395            return Ok(());
396        }
397        let up = Upload::new(paths)?;
398        self.transfer = Some(Transfer::Upload(up));
399        self.events.push(SessionEvent::TransferStarted);
400        // Replay what the BBS sent while waiting (its ZRINIT), if anything.
401        let waiting = self.upload_wait.take().unwrap_or_default();
402        self.process(waiting);
403        Ok(())
404    }
405
406    /// Send files with XMODEM or YMODEM, once the BBS's receiver starts.
407    pub fn start_xy_upload(&mut self, proto: XyProtocol, paths: Vec<PathBuf>) -> Result<()> {
408        if self.transfer.is_some() {
409            anyhow::bail!("a transfer is already in progress");
410        }
411        let up = XySend::new(proto, paths)?;
412        self.upload_wait = None;
413        self.transfer = Some(Transfer::XyUpload(up));
414        self.events.push(SessionEvent::TransferStarted);
415        Ok(())
416    }
417
418    /// Receive with XMODEM or YMODEM: asks the BBS's sender to start.
419    /// `name` is what an XMODEM file is saved as (YMODEM sends names).
420    pub fn start_xy_download(&mut self, proto: XyProtocol, name: Option<String>) -> Result<()> {
421        if self.transfer.is_some() {
422            anyhow::bail!("a transfer is already in progress");
423        }
424        let mut t = Transfer::XyDownload(XyReceive::new(proto, self.config.download_dir.clone(), name));
425        let mut out = Vec::new();
426        t.tick(Instant::now(), &mut out);
427        self.transfer = Some(t);
428        self.send(&out);
429        self.events.push(SessionEvent::TransferStarted);
430        Ok(())
431    }
432
433    /// The user chose not to upload: make the BBS's receiver give up.
434    pub fn decline_upload(&mut self) {
435        if self.upload_wait.take().is_some() {
436            self.send(transfer::CANCEL);
437        }
438    }
439
440    pub fn cancel_transfer(&mut self) {
441        if let Some(t) = &mut self.transfer {
442            let mut out = Vec::new();
443            t.cancel(&mut out);
444            self.send(&out);
445            self.end_transfer(None);
446        }
447    }
448
449    /// Call periodically (a few times a second) to drive transfer timeouts.
450    pub fn tick(&mut self) {
451        self.pump();
452        if let Some(t) = &mut self.transfer {
453            let mut out = Vec::new();
454            t.tick(Instant::now(), &mut out);
455            self.send(&out);
456            self.end_transfer(None);
457        }
458        self.check_login_prompt();
459    }
460
461    /// Send application data (keystrokes, paste, transfer data) to the remote.
462    pub fn send(&mut self, data: &[u8]) {
463        if data.is_empty() {
464            return;
465        }
466        let encoded = match &self.telnet {
467            Some(t) => t.encode(data),
468            None => data.to_vec(),
469        };
470        self.write_raw(&encoded);
471    }
472
473    fn write_raw(&mut self, data: &[u8]) {
474        if let Some(w) = &mut self.writer
475            && let Err(e) = w.write_all(data)
476        {
477            self.error = Some(e.to_string());
478            self.hang_up();
479        }
480    }
481
482    pub fn hang_up(&mut self) {
483        self.answer_prompt(None);
484        if let Some(w) = self.writer.take() {
485            w.shutdown();
486        }
487        self.state = State::Closed;
488        self.stop_capture();
489    }
490
491    /// Whether a call that has ended is worth dialing again: the connection
492    /// could not be made (refused, timed out), or the BBS hung up soon after
493    /// answering, saying it is busy. Not for failed logins, changed host
494    /// keys and the like.
495    pub fn should_redial(&self) -> bool {
496        if self.state != State::Closed {
497            return false;
498        }
499        match self.connected_at {
500            None => self.error.as_deref().is_some_and(|e| e.contains("connecting to")),
501            Some(t) => t.elapsed() < BUSY_WINDOW && says_busy(&self.term.screen),
502        }
503    }
504
505    /// Time since connecting (or since dialing, while still connecting).
506    pub fn elapsed(&self) -> Duration {
507        self.connected_at.unwrap_or(self.started).elapsed()
508    }
509
510    pub fn download_dir(&self) -> &Path {
511        &self.config.download_dir
512    }
513
514    pub fn capture_path(&self) -> Option<&Path> {
515        self.capture.as_ref().map(|(p, _)| p.as_path())
516    }
517
518    /// Start recording received data (after telnet decoding) to a file.
519    pub fn start_capture(&mut self, path: PathBuf) -> Result<()> {
520        if let Some(dir) = path.parent() {
521            std::fs::create_dir_all(dir)?;
522        }
523        let f = File::options()
524            .create(true)
525            .append(true)
526            .open(&path)
527            .with_context(|| format!("opening {}", path.display()))?;
528        self.capture = Some((path, BufWriter::new(f)));
529        Ok(())
530    }
531
532    pub fn stop_capture(&mut self) {
533        if let Some((_, mut f)) = self.capture.take() {
534            let _ = f.flush();
535        }
536    }
537}
538
539impl Drop for Session {
540    fn drop(&mut self) {
541        self.hang_up();
542    }
543}