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::{
18    self, Download, Kermit, Outcome, TrailerFilter, Transfer, Upload, XyProtocol, XyReceive, XySend,
19};
20pub use crate::transport::{AuthPrompt, ConnectOptions, NetEvent, PromptKind, Writer, spawn_connection};
21
22/// How long the BBS must be quiet before the text at the cursor is taken
23/// for a prompt.
24const PROMPT_QUIET: Duration = Duration::from_millis(250);
25
26/// How soon after answering a BBS that hangs up may be called busy.
27const BUSY_WINDOW: Duration = Duration::from_secs(30);
28
29/// Whether the screen says the BBS has no free line.
30fn says_busy(screen: &Screen) -> bool {
31    const WORDS: [&str; 6] = ["busy", "no free node", "all nodes", "all lines", "try again later", "too many users"];
32    (0..screen.rows()).any(|y| {
33        let line = Screen::row_text(screen.row(y)).to_ascii_lowercase();
34        WORDS.iter().any(|w| line.contains(w))
35    })
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum State {
40    Connecting,
41    Connected,
42    Closed,
43}
44
45/// Where a session keeps the files it produces.
46#[derive(Clone, Debug)]
47pub struct SessionConfig {
48    pub scrollback: usize,
49    pub known_hosts: PathBuf,
50    pub download_dir: PathBuf,
51    /// Offer the SSH agent's keys when logging in.
52    pub ssh_agent: bool,
53    /// Where default SSH keys (`id_ed25519`, ...) are looked for.
54    pub ssh_key_dir: Option<PathBuf>,
55    /// The modem, for modem entries.
56    pub modem: crate::modem::ModemConfig,
57}
58
59/// Things the front end should react to.
60#[derive(Clone, Debug, PartialEq)]
61pub enum SessionEvent {
62    /// The connection is up (for SSH, the login succeeded).
63    Connected,
64    Term(TermEvent),
65    Notice(String),
66    /// The BBS is waiting to receive files (it sent ZRINIT); ask the user
67    /// what to upload, then call [`Session::start_upload`] or
68    /// [`Session::decline_upload`].
69    UploadRequested,
70    /// The login has a question for the user: see [`Session::auth_prompt`],
71    /// and reply with [`Session::answer_prompt`].
72    AuthPrompt,
73    TransferStarted,
74    TransferFinished(Outcome),
75}
76
77pub struct Session {
78    pub entry: Entry,
79    pub term: Terminal,
80    pub state: State,
81    /// Why the session closed, if it failed.
82    pub error: Option<String>,
83    config: SessionConfig,
84    telnet: Option<Telnet>,
85    writer: Option<Writer>,
86    capture: Option<Capture>,
87    transfer: Option<Transfer>,
88    /// The transfer is a RIP one whose RIP files are shown as they come.
89    rip_shows: bool,
90    /// The transfer is SkyPix's, of a brush or sample of this mode.
91    skypix_load: Option<u32>,
92    /// Bytes received since the BBS asked for an upload, held until the user
93    /// picks files (they include the ZRINIT the sender needs to see).
94    upload_wait: Option<Vec<u8>>,
95    auth_prompt: Option<AuthPrompt>,
96    login: AutoLogin,
97    /// When data last arrived, until a login prompt check has looked at it.
98    quiet_since: Option<Instant>,
99    /// Baud rate emulation: received data waiting to be shown, how many
100    /// bytes may go now, and when that was worked out.
101    paced: VecDeque<u8>,
102    pace_credit: f64,
103    pace_at: Instant,
104    /// The end of the last data chunk, so a ZMODEM header split across two
105    /// reads is still recognized.
106    scan_tail: Vec<u8>,
107    /// Hides ZMODEM's closing bytes from the terminal after a transfer.
108    trailer: Option<TrailerFilter>,
109    started: Instant,
110    connected_at: Option<Instant>,
111    events: Vec<SessionEvent>,
112    /// A copy of what is shown, for a script, while it is Some.
113    pub tap: Option<Vec<u8>>,
114}
115
116impl Session {
117    pub fn new(entry: Entry, cols: usize, rows: usize, config: SessionConfig) -> Session {
118        let login = AutoLogin::new(&entry);
119        let mut term = Terminal::new(cols, rows, config.scrollback);
120        term.encoding = entry.encoding;
121        term.screen.base_font = entry.font;
122        term.screen.base_ice = entry.ice.unwrap_or(false);
123        term.screen.ice_colors = term.screen.base_ice;
124        term.screen.look.fonts[0] = entry.font;
125        // A C64 or Atari system: its colors and font.
126        term.start_emulation();
127        let telnet = entry.protocol.is_telnet().then(|| Telnet::new(&entry.term_type, cols as u16, rows as u16));
128        Session {
129            entry,
130            term,
131            state: State::Connecting,
132            error: None,
133            config,
134            telnet,
135            writer: None,
136            capture: None,
137            transfer: None,
138            rip_shows: false,
139            skypix_load: None,
140            upload_wait: None,
141            auth_prompt: None,
142            login,
143            quiet_since: None,
144            paced: VecDeque::new(),
145            pace_credit: 0.0,
146            pace_at: Instant::now(),
147            scan_tail: Vec::new(),
148            trailer: None,
149            started: Instant::now(),
150            connected_at: None,
151            events: Vec::new(),
152            tap: None,
153        }
154    }
155
156    /// Start connecting in the background; events arrive through `sink` and
157    /// must be passed to [`Session::handle`].
158    pub fn connect<F>(&self, sink: F)
159    where
160        F: Fn(NetEvent) + Send + 'static,
161    {
162        let opts = ConnectOptions {
163            cols: self.term.screen.cols() as u16,
164            rows: self.term.screen.rows() as u16,
165            known_hosts: self.config.known_hosts.clone(),
166            ssh_agent: self.config.ssh_agent,
167            ssh_key_dir: self.config.ssh_key_dir.clone(),
168            modem: self.config.modem.clone(),
169        };
170        spawn_connection(&self.entry, &opts, sink);
171    }
172
173    pub fn handle(&mut self, ev: NetEvent) {
174        match ev {
175            NetEvent::Connected(w) => {
176                self.writer = Some(w);
177                self.state = State::Connected;
178                self.connected_at = Some(Instant::now());
179                self.login.connected();
180                self.events.push(SessionEvent::Connected);
181            }
182            NetEvent::Data(bytes) => self.receive(&bytes),
183            NetEvent::Notice(msg) => self.events.push(SessionEvent::Notice(msg)),
184            NetEvent::Prompt(p) => {
185                self.auth_prompt = Some(p);
186                self.events.push(SessionEvent::AuthPrompt);
187            }
188            NetEvent::Closed(err) => {
189                self.auth_prompt = None;
190                self.state = State::Closed;
191                self.error = err;
192                self.writer = None;
193                self.end_transfer(Some(Outcome::Failed("the connection closed".into())));
194            }
195        }
196    }
197
198    fn receive(&mut self, bytes: &[u8]) {
199        let data = match &mut self.telnet {
200            Some(t) => {
201                let (mut data, mut reply) = (Vec::with_capacity(bytes.len()), Vec::new());
202                t.receive(bytes, &mut data, &mut reply);
203                if !reply.is_empty() {
204                    self.write_raw(&reply);
205                }
206                data
207            }
208            None => bytes.to_vec(),
209        };
210        // Transfers are not slowed down.
211        if self.entry.baud == 0 || self.transfer.is_some() && self.paced.is_empty() {
212            self.process(data);
213            self.quiet_since = Some(Instant::now());
214        } else {
215            if self.paced.is_empty() {
216                self.pace_at = Instant::now();
217            }
218            self.paced.extend(data);
219            self.pump();
220        }
221    }
222
223    /// Whether received data is waiting for baud rate emulation: call
224    /// [`Session::pump`] often (every frame) while it is.
225    pub fn pacing(&self) -> bool {
226        !self.paced.is_empty()
227    }
228
229    /// Show as much paced data as the emulated baud rate allows by now: ten
230    /// bits per byte, as with a modem's start and stop bits.
231    pub fn pump(&mut self) {
232        if self.paced.is_empty() {
233            return;
234        }
235        let now = Instant::now();
236        self.pace_credit += now.duration_since(self.pace_at).as_secs_f64() * self.entry.baud as f64 / 10.0;
237        self.pace_at = now;
238        let n = (self.pace_credit as usize).min(self.paced.len());
239        if n == 0 {
240            return;
241        }
242        self.pace_credit -= n as f64;
243        let chunk: Vec<u8> = self.paced.drain(..n).collect();
244        self.process(chunk);
245        // A transfer that started in that chunk gets the rest at once.
246        if self.transfer.is_some() {
247            let rest: Vec<u8> = self.paced.drain(..).collect();
248            self.process(rest);
249        }
250        if self.paced.is_empty() {
251            self.pace_credit = 0.0;
252        }
253        self.quiet_since = Some(now);
254    }
255
256    /// Answer a login prompt, once the BBS has been quiet for a moment (so
257    /// half a line is not taken for a prompt).
258    fn check_login_prompt(&mut self) {
259        let Some(t) = self.quiet_since else { return };
260        if t.elapsed() < PROMPT_QUIET || self.transfer.is_some() || self.upload_wait.is_some() {
261            return;
262        }
263        self.quiet_since = None;
264        let screen = &self.term.screen;
265        let (x, y) = screen.cursor();
266        let row = screen.row(y);
267        // A C64's or Atari's screen holds screen codes, not ASCII.
268        let prompt = match self.term.text_codepage().filter(|cp| cp.screen_codes()) {
269            Some(cp) => row[..x.min(row.len())].iter().map(|c| cp.decode(c.ch)).collect(),
270            None => Screen::row_text(&row[..x.min(row.len())]),
271        };
272        if let Some(step) = self.login.answer(&prompt) {
273            self.send_login_step(step);
274        }
275    }
276
277    /// A click on the RIP graphics: send what it sends. False if it sends
278    /// nothing (now: a question for the user may come first).
279    pub fn rip_press(&mut self, x: usize, y: usize) -> bool {
280        let out = self.term.rip_press(x, y);
281        if let Some(text) = &out {
282            self.send(text);
283        }
284        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
285        out.is_some()
286    }
287
288    /// Answer RIPscrip's question for a text variable (None: cancelled),
289    /// sending what was waiting for it.
290    pub fn answer_rip(&mut self, answer: Option<String>) {
291        self.term.rip_answer(answer);
292        let replies = self.term.take_replies();
293        if !replies.is_empty() {
294            self.send(&replies);
295        }
296        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
297    }
298
299    /// Send the next part of the stored login (Alt-L): the user name, then
300    /// the password. False when there is nothing (more) to send.
301    pub fn send_login(&mut self) -> bool {
302        match self.login.next_step() {
303            Some(step) => {
304                self.send_login_step(step);
305                true
306            }
307            None => false,
308        }
309    }
310
311    /// Whether the entry has a user name or password to log in with.
312    pub fn has_login(&self) -> bool {
313        self.login.has_login()
314    }
315
316    fn send_login_step(&mut self, step: Step) {
317        let (text, what) = match step {
318            Step::User(u) => (u, "user name"),
319            Step::Password(p) => (p, "password"),
320        };
321        let bytes = self.term.encoding.encode_text(&format!("{text}\r"));
322        self.send(&bytes);
323        self.events.push(SessionEvent::Notice(format!("Sent your {what}")));
324    }
325
326    /// Route decoded data to an active transfer, or detect a new one, or
327    /// show it on the terminal.
328    fn process(&mut self, mut data: Vec<u8>) {
329        if let Some(f) = &mut self.trailer
330            && !f.strip(&mut data)
331        {
332            self.trailer = None;
333        }
334        if let Some(t) = &mut self.transfer {
335            let mut out = Vec::new();
336            t.feed(&data, &mut out);
337            self.send(&out);
338            self.end_transfer(None);
339            return;
340        }
341        if let Some(buf) = &mut self.upload_wait {
342            buf.extend_from_slice(&data);
343            return;
344        }
345
346        let mut scan = std::mem::take(&mut self.scan_tail);
347        let already_shown = scan.len();
348        scan.extend_from_slice(&data);
349        if let Some(i) = transfer::find(&scan, transfer::ZRQINIT) {
350            self.show(&scan[already_shown.min(i)..i]);
351            match Download::new(self.config.download_dir.clone()) {
352                Ok(d) => {
353                    self.transfer = Some(Transfer::Download(d));
354                    self.events.push(SessionEvent::TransferStarted);
355                    self.process(scan[i..].to_vec());
356                }
357                Err(e) => self.events.push(SessionEvent::Notice(format!("Cannot start download: {e:#}"))),
358            }
359            return;
360        }
361        if let Some(i) = transfer::find(&scan, transfer::ZRINIT) {
362            self.show(&scan[already_shown.min(i)..i]);
363            self.upload_wait = Some(scan[i..].to_vec());
364            self.events.push(SessionEvent::UploadRequested);
365            return;
366        }
367        // A Kermit sending: its Send-Init packet.
368        if let Some(i) = crate::kermit::find_send_init(&scan) {
369            self.show(&scan[already_shown.min(i)..i]);
370            self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
371            self.events.push(SessionEvent::TransferStarted);
372            self.process(scan[i..].to_vec());
373            return;
374        }
375        let keep = scan.len().min(transfer::ZRQINIT.len() - 1);
376        self.scan_tail = scan[scan.len() - keep..].to_vec();
377        self.show(&data);
378    }
379
380    fn show(&mut self, data: &[u8]) {
381        if data.is_empty() {
382            return;
383        }
384        if let Some(c) = &mut self.capture {
385            c.write(data, self.term.encoding);
386        }
387        if let Some(t) = &mut self.tap {
388            t.extend_from_slice(data);
389        }
390        self.term.feed(data);
391        let replies = self.term.take_replies();
392        if !replies.is_empty() {
393            self.send(&replies);
394        }
395        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
396        if let Some((block, rest)) = self.term.take_rip_block() {
397            self.start_rip_block(&block);
398            self.process(rest);
399        }
400        if let Some((t, rest)) = self.term.take_skypix_transfer() {
401            // To the BBS's folder in the cache (BRU:, on the Amiga).
402            let dir = match &self.term.syncterm.cache_dir {
403                Some(c) => c.join("SkyPix"),
404                None => self.config.download_dir.clone(),
405            };
406            let name = transfer::sanitize_file_name(t.name.as_bytes());
407            let started = std::fs::create_dir_all(&dir)
408                .map_err(anyhow::Error::from)
409                .and_then(|()| self.start_xy_download_to(XyProtocol::XmodemCrc, dir, Some(name)));
410            match started {
411                Ok(()) => self.skypix_load = Some(t.mode),
412                Err(e) => {
413                    self.send(transfer::CANCEL);
414                    self.events.push(SessionEvent::Notice(format!("SkyPix transfer: {e:#}")));
415                }
416            }
417            self.process(rest);
418        }
419    }
420
421    /// A file transfer RIPscrip started: files go to (or come from) the
422    /// BBS's RIP folder.
423    fn start_rip_block(&mut self, b: &crate::rip::BlockTransfer) {
424        let dir = match &self.term.syncterm.cache_dir {
425            Some(c) => c.join("RIP"),
426            None => self.config.download_dir.clone(),
427        };
428        let xy = match b.protocol {
429            0 => Some(XyProtocol::Xmodem),
430            1 => Some(XyProtocol::XmodemCrc),
431            2 | 3 => Some(XyProtocol::Xmodem1k),
432            5 => Some(XyProtocol::Ymodem),
433            6 => Some(XyProtocol::YmodemG),
434            _ => None,
435        };
436        let started: Result<()> = (|| {
437            if b.upload {
438                // Only a file in the RIP folder, by its name.
439                let path = b.name.as_ref().map(|n| dir.join(n)).filter(|p| p.is_file());
440                let Some(path) = path else { anyhow::bail!("the BBS asked for a file that isn't here") };
441                match (b.protocol, xy) {
442                    (_, Some(p)) => self.start_xy_upload(p, vec![path]),
443                    (4, _) => self.start_kermit_upload(vec![path]),
444                    _ => {
445                        self.transfer = Some(Transfer::Upload(Upload::new(vec![path])?));
446                        self.events.push(SessionEvent::TransferStarted);
447                        Ok(())
448                    }
449                }
450            } else {
451                std::fs::create_dir_all(&dir)?;
452                match (b.protocol, xy) {
453                    (_, Some(p)) => self.start_xy_download_to(p, dir.clone(), b.name.clone()),
454                    (4, _) => {
455                        self.transfer = Some(Transfer::Kermit(Kermit::receive(dir.clone())));
456                        self.events.push(SessionEvent::TransferStarted);
457                        Ok(())
458                    }
459                    _ => {
460                        self.transfer = Some(Transfer::Download(Download::new(dir.clone())?));
461                        self.events.push(SessionEvent::TransferStarted);
462                        Ok(())
463                    }
464                }
465            }
466        })();
467        match started {
468            Ok(()) => self.rip_shows = !b.upload && b.shows(),
469            Err(e) => {
470                // As RIPterm refuses: ten CANs.
471                self.send(&[0x18; 10]);
472                self.events.push(SessionEvent::Notice(format!("RIP file transfer: {e:#}")));
473            }
474        }
475    }
476
477    /// If the transfer finished (or `force` gives a reason to stop it),
478    /// report the outcome and return its trailing bytes to the terminal.
479    fn end_transfer(&mut self, force: Option<Outcome>) {
480        let done = self.transfer.as_ref().is_some_and(Transfer::is_finished);
481        if !done && force.is_none() {
482            return;
483        }
484        let Some(t) = self.transfer.take() else { return };
485        let (outcome, leftover) = t.finish();
486        let outcome = if done { outcome } else { force.unwrap_or(outcome) };
487        // RIP files a RIP transfer brought, to be shown.
488        let show: Vec<PathBuf> = match &outcome {
489            Outcome::Completed(files) if std::mem::take(&mut self.rip_shows) => {
490                files.iter().filter(|f| f.extension().is_some_and(|e| e.eq_ignore_ascii_case("rip"))).cloned().collect()
491            }
492            _ => Vec::new(),
493        };
494        self.rip_shows = false;
495        // A SkyPix brush or sample, for it to use.
496        if let (Some(mode), Outcome::Completed(files)) = (self.skypix_load.take(), &outcome)
497            && let Some(data) = files.first().and_then(|f| std::fs::read(f).ok())
498            && let Some(sky) = self.term.skypix_mut()
499        {
500            sky.load(mode, &data);
501        }
502        self.events.push(SessionEvent::TransferFinished(outcome));
503        self.scan_tail.clear();
504        self.trailer = Some(TrailerFilter::new());
505        for f in show {
506            match std::fs::read(&f) {
507                Ok(data) => self.show(&data),
508                Err(e) => log::warn!("showing {}: {e}", f.display()),
509            }
510        }
511        self.process(leftover);
512    }
513
514    pub fn take_events(&mut self) -> Vec<SessionEvent> {
515        std::mem::take(&mut self.events)
516    }
517
518    pub fn transfer(&self) -> Option<&Transfer> {
519        self.transfer.as_ref()
520    }
521
522    /// The login question waiting for an answer, if any.
523    pub fn auth_prompt(&self) -> Option<&AuthPrompt> {
524        self.auth_prompt.as_ref()
525    }
526
527    /// Answer the waiting login question; `None` cancels the login.
528    pub fn answer_prompt(&mut self, answer: Option<String>) {
529        if let Some(p) = self.auth_prompt.take() {
530            p.answer(answer);
531        }
532    }
533
534    /// Whether the BBS is waiting for the user to choose files to upload.
535    pub fn upload_requested(&self) -> bool {
536        self.upload_wait.is_some()
537    }
538
539    /// Send files with ZMODEM. If a batch is already being sent, the files
540    /// are added to it.
541    pub fn start_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
542        if let Some(t) = &mut self.transfer {
543            for p in paths {
544                t.enqueue(p)?;
545            }
546            return Ok(());
547        }
548        let up = Upload::new(paths)?;
549        self.transfer = Some(Transfer::Upload(up));
550        self.events.push(SessionEvent::TransferStarted);
551        // Replay what the BBS sent while waiting (its ZRINIT), if anything.
552        let waiting = self.upload_wait.take().unwrap_or_default();
553        self.process(waiting);
554        Ok(())
555    }
556
557    /// Send files with XMODEM or YMODEM, once the BBS's receiver starts.
558    pub fn start_xy_upload(&mut self, proto: XyProtocol, paths: Vec<PathBuf>) -> Result<()> {
559        if self.transfer.is_some() {
560            anyhow::bail!("a transfer is already in progress");
561        }
562        let up = XySend::new(proto, paths)?;
563        self.upload_wait = None;
564        self.transfer = Some(Transfer::XyUpload(up));
565        self.events.push(SessionEvent::TransferStarted);
566        Ok(())
567    }
568
569    /// Receive with XMODEM or YMODEM: asks the BBS's sender to start.
570    /// `name` is what an XMODEM file is saved as (YMODEM sends names).
571    pub fn start_xy_download(&mut self, proto: XyProtocol, name: Option<String>) -> Result<()> {
572        self.start_xy_download_to(proto, self.config.download_dir.clone(), name)
573    }
574
575    fn start_xy_download_to(&mut self, proto: XyProtocol, dir: PathBuf, name: Option<String>) -> Result<()> {
576        if self.transfer.is_some() {
577            anyhow::bail!("a transfer is already in progress");
578        }
579        let mut t = Transfer::XyDownload(XyReceive::new(proto, dir, name));
580        let mut out = Vec::new();
581        t.tick(Instant::now(), &mut out);
582        self.transfer = Some(t);
583        self.send(&out);
584        self.events.push(SessionEvent::TransferStarted);
585        Ok(())
586    }
587
588    /// Send files with Kermit to the BBS's Kermit, once it is receiving.
589    pub fn start_kermit_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
590        if self.transfer.is_some() {
591            anyhow::bail!("a transfer is already in progress");
592        }
593        let mut t = Transfer::Kermit(Kermit::send(paths)?);
594        let mut out = Vec::new();
595        t.start(&mut out);
596        self.upload_wait = None;
597        self.transfer = Some(t);
598        self.send(&out);
599        self.events.push(SessionEvent::TransferStarted);
600        Ok(())
601    }
602
603    /// Receive with Kermit: for a BBS whose Kermit is already sending (one
604    /// that starts afterwards is picked up by itself).
605    pub fn start_kermit_download(&mut self) -> Result<()> {
606        if self.transfer.is_some() {
607            anyhow::bail!("a transfer is already in progress");
608        }
609        self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
610        self.events.push(SessionEvent::TransferStarted);
611        Ok(())
612    }
613
614    /// The user chose not to upload: make the BBS's receiver give up.
615    pub fn decline_upload(&mut self) {
616        if self.upload_wait.take().is_some() {
617            self.send(transfer::CANCEL);
618        }
619    }
620
621    pub fn cancel_transfer(&mut self) {
622        if let Some(t) = &mut self.transfer {
623            let mut out = Vec::new();
624            t.cancel(&mut out);
625            self.send(&out);
626            self.end_transfer(None);
627        }
628    }
629
630    /// Call periodically (a few times a second) to drive transfer timeouts.
631    pub fn tick(&mut self) {
632        self.pump();
633        // Sound reports, and reading again after waiting for a sound.
634        self.term.poll(Instant::now());
635        let replies = self.term.take_replies();
636        if !replies.is_empty() {
637            self.send(&replies);
638        }
639        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
640        if let Some(t) = &mut self.transfer {
641            let mut out = Vec::new();
642            t.tick(Instant::now(), &mut out);
643            self.send(&out);
644            self.end_transfer(None);
645        }
646        self.check_login_prompt();
647    }
648
649    /// When [`Session::tick`] next has something to do for the terminal.
650    pub fn next_tick(&self) -> Option<Instant> {
651        self.term.next_poll()
652    }
653
654    /// Send application data (keystrokes, paste, transfer data) to the remote.
655    pub fn send(&mut self, data: &[u8]) {
656        if data.is_empty() {
657            return;
658        }
659        let encoded = match &self.telnet {
660            Some(t) => t.encode(data),
661            None => data.to_vec(),
662        };
663        self.write_raw(&encoded);
664    }
665
666    fn write_raw(&mut self, data: &[u8]) {
667        if let Some(w) = &mut self.writer
668            && let Err(e) = w.write_all(data)
669        {
670            self.error = Some(e.to_string());
671            self.hang_up();
672        }
673    }
674
675    pub fn hang_up(&mut self) {
676        self.answer_prompt(None);
677        if let Some(w) = self.writer.take() {
678            w.shutdown();
679        }
680        self.state = State::Closed;
681        self.stop_capture();
682    }
683
684    /// Whether a call that has ended is worth dialing again: the connection
685    /// could not be made (refused, timed out), or the BBS hung up soon after
686    /// answering, saying it is busy. Not for failed logins, changed host
687    /// keys and the like.
688    pub fn should_redial(&self) -> bool {
689        if self.state != State::Closed {
690            return false;
691        }
692        match self.connected_at {
693            None => self.error.as_deref().is_some_and(|e| e.contains("connecting to")),
694            Some(t) => t.elapsed() < BUSY_WINDOW && says_busy(&self.term.screen),
695        }
696    }
697
698    /// Whether the call got through (it may have ended since).
699    pub fn was_connected(&self) -> bool {
700        self.connected_at.is_some()
701    }
702
703    /// Time since connecting (or since dialing, while still connecting).
704    pub fn elapsed(&self) -> Duration {
705        self.connected_at.unwrap_or(self.started).elapsed()
706    }
707
708    pub fn download_dir(&self) -> &Path {
709        &self.config.download_dir
710    }
711
712    pub fn capture_path(&self) -> Option<&Path> {
713        self.capture.as_ref().map(|c| c.path.as_path())
714    }
715
716    /// Start recording received data (after telnet decoding) to a file: all
717    /// of it, or for a `.txt` file only the text (see [`CaptureKind`]).
718    pub fn start_capture(&mut self, path: PathBuf) -> Result<()> {
719        if let Some(dir) = path.parent() {
720            std::fs::create_dir_all(dir)?;
721        }
722        let f = File::options()
723            .create(true)
724            .append(true)
725            .open(&path)
726            .with_context(|| format!("opening {}", path.display()))?;
727        let kind = CaptureKind::for_path(&path);
728        self.capture = Some(Capture { path, file: BufWriter::new(f), kind, text: TextState::Ground });
729        Ok(())
730    }
731
732    pub fn stop_capture(&mut self) {
733        if let Some(mut c) = self.capture.take() {
734            let _ = c.file.flush();
735        }
736    }
737}
738
739/// What a capture keeps.
740#[derive(Clone, Copy, Debug, PartialEq, Eq)]
741pub enum CaptureKind {
742    /// Everything the BBS sent, ANSI codes and all (`.ans`), to view later.
743    Raw,
744    /// Only the text, as UTF-8 (`.txt`): a log to read or search.
745    Text,
746}
747
748impl CaptureKind {
749    pub fn for_path(path: &Path) -> CaptureKind {
750        match path.extension().and_then(|e| e.to_str()) {
751            Some(e) if e.eq_ignore_ascii_case("txt") || e.eq_ignore_ascii_case("log") => CaptureKind::Text,
752            _ => CaptureKind::Raw,
753        }
754    }
755}
756
757#[derive(Clone, Copy, Debug, PartialEq, Eq)]
758enum TextState {
759    Ground,
760    Escape,
761    Csi,
762    /// An OSC or other string, up to BEL or ST.
763    String,
764}
765
766struct Capture {
767    path: PathBuf,
768    file: BufWriter<File>,
769    kind: CaptureKind,
770    text: TextState,
771}
772
773impl Capture {
774    fn write(&mut self, data: &[u8], encoding: crate::ansi::Encoding) {
775        if self.kind == CaptureKind::Raw {
776            let _ = self.file.write_all(data);
777            return;
778        }
779        let utf8 = encoding == crate::ansi::Encoding::Utf8;
780        let mut out = Vec::with_capacity(data.len());
781        for &b in data {
782            self.text = match (self.text, b) {
783                (_, 0x18 | 0x1A) => TextState::Ground,
784                (TextState::Ground, 0x1B) => TextState::Escape,
785                (TextState::Ground, b'\n' | b'\t') => {
786                    out.push(b);
787                    TextState::Ground
788                }
789                (TextState::Ground, 0x0C) => {
790                    out.extend_from_slice(b"\n\n");
791                    TextState::Ground
792                }
793                (TextState::Ground, 0x00..=0x1F | 0x7F) => TextState::Ground,
794                (TextState::Ground, _) if utf8 || b < 0x80 => {
795                    out.push(b);
796                    TextState::Ground
797                }
798                (TextState::Ground, _) => {
799                    let mut buf = [0; 4];
800                    out.extend_from_slice(crate::cp437::to_unicode(b).encode_utf8(&mut buf).as_bytes());
801                    TextState::Ground
802                }
803                (TextState::Escape, b'[') => TextState::Csi,
804                (TextState::Escape, b']' | b'P' | b'X' | b'^' | b'_') => TextState::String,
805                (TextState::Escape, 0x20..=0x2F) => TextState::Escape,
806                (TextState::Escape, _) => TextState::Ground,
807                (TextState::Csi, 0x40..=0x7E) => TextState::Ground,
808                (TextState::Csi, _) => TextState::Csi,
809                (TextState::String, 0x07) => TextState::Ground,
810                (TextState::String, 0x1B) => TextState::Escape,
811                (TextState::String, _) => TextState::String,
812            };
813        }
814        let _ = self.file.write_all(&out);
815    }
816}
817
818impl Drop for Session {
819    fn drop(&mut self) {
820        self.hang_up();
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::ansi::Encoding;
828
829    #[test]
830    fn text_captures_keep_only_the_words() {
831        let path = std::env::temp_dir().join(format!("badterm-capture-{}.txt", std::process::id()));
832        let _ = std::fs::remove_file(&path);
833        assert_eq!(CaptureKind::for_path(&path), CaptureKind::Text);
834        assert_eq!(CaptureKind::for_path(Path::new("x.ans")), CaptureKind::Raw);
835        let f = File::create(&path).unwrap();
836        let mut c =
837            Capture { path: path.clone(), file: BufWriter::new(f), kind: CaptureKind::Text, text: TextState::Ground };
838        // Colors, a title, CR LF and a CP437 box character, split mid-sequence.
839        c.write(b"\x1b[1;3", Encoding::Cp437);
840        c.write(b"1mHello\x1b]2;title\x07\r\n\xc4\xc4 bye\x07", Encoding::Cp437);
841        c.file.flush().unwrap();
842        assert_eq!(std::fs::read_to_string(&path).unwrap(), "Hello\n\u{2500}\u{2500} bye");
843        let _ = std::fs::remove_file(&path);
844    }
845}