#![cfg_attr(windows, windows_subsystem = "windows")]
mod app;
mod bezel;
mod console;
mod crt;
mod input;
#[cfg(target_os = "macos")]
mod macos_urls;
mod macros;
mod png;
mod render;
mod secrets;
mod selection;
mod settings;
mod sound;
mod ui;
mod viewer;
use std::path::PathBuf;
use std::process::ExitCode;
use badterm_core::dialer::{self, Directory, Entry};
use winit::event_loop::EventLoop;
const USAGE: &str = "\
Usage: badterm [OPTIONS] [ADDRESS | FILE]
Connect to ADDRESS (host, host:port, telnet://host:port, raw://host:port or
ssh://user@host:port). With no address, BadTerm starts unconnected; Alt-D
(Cmd-D on macOS) opens the dialing directory. A FILE (ANSI art) opens in
the viewer.
Options:
--import FILE Add the entries in FILE to the dialing directory and exit
(SyncTERM .lst, NetRunner .phn, magiterm.ini, EtherTerm .xml, .csv
such as the Telnet BBS Guide's, .json or .toml)
--export FILE Write the dialing directory to FILE and exit; the format
follows the extension (.lst, .csv, .json or .toml)
--paths Print where the directory, captures and downloads are
stored and exit
--render IN OUT Draw the ANSI file IN to the PNG image OUT and exit. With:
--screen only the final 80x25 screen (for a captured session),
not the whole file
--crt MODE phosphor mode: color, green or amber
--scanlines S their strength: off, light, medium or strong
--curvature C the glass: off, slight, medium or strong
--nine-dot 9-dot characters
--aspect 4:3, as on a CRT
--font NAME topaz, topaz-plus, microknight, microknight-plus,
p0t-noodle or mosoul (Amiga art)
--bezel NAME draw a monitor bezel around it (as in the settings,
e.g. 'Beige, 1980s', or one of your own)
-h, --help Show this help
-V, --version Show the version";
enum Command {
Run(Option<Entry>, Option<PathBuf>),
Import(PathBuf),
Export(PathBuf),
Paths,
Render(PathBuf, PathBuf, viewer::RenderOptions),
}
fn parse_args() -> Result<Command, String> {
let mut args = std::env::args().skip(1);
let mut cmd = Command::Run(None, None);
let mut render = viewer::RenderOptions::default();
let mut render_files = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
println!("{USAGE}");
std::process::exit(0);
}
"-V" | "--version" => {
println!("badterm {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"--import" => cmd = Command::Import(args.next().ok_or("--import needs a file")?.into()),
"--export" => cmd = Command::Export(args.next().ok_or("--export needs a file")?.into()),
"--paths" => cmd = Command::Paths,
"--render" => {
let input = args.next().ok_or("--render needs an input and an output file")?;
let output = args.next().ok_or("--render needs an output file")?;
render_files = Some((input.into(), output.into()));
}
"--screen" => render.screen_only = true,
"--nine-dot" => render.nine_dot = true,
"--aspect" => render.aspect = true,
"--bezel" => {
let name = args.next().ok_or("--bezel needs a bezel name")?;
let dir = app::Paths::new().bezels;
let bezel = bezel::Bezel::find(&name, &dir)
.map_err(|e| format!("{e:#} (bezels: {})", bezel::names(&dir).join(", ")))?;
render.bezel = Some(bezel);
}
"--font" => {
let name = args.next().ok_or("--font needs a font name")?;
let slug = |n: &str| n.to_ascii_lowercase().replace(' ', "-").replace('\'', "");
render.font = render::EXTRA_FONTS
.iter()
.find(|(_, n)| slug(n) == slug(&name))
.map(|&(id, _)| id)
.ok_or_else(|| format!("unknown font {name}"))?;
}
"--curvature" => {
let want = args.next().unwrap_or_default().to_ascii_lowercase();
render.curvature = crt::Curvature::ALL
.into_iter()
.find(|c| c.name().to_ascii_lowercase().starts_with(&want) && !want.is_empty())
.ok_or("--curvature needs off, slight, medium or strong")?;
}
"--scanlines" => {
let want = args.next().unwrap_or_default().to_ascii_lowercase();
render.scanlines = crt::Scanlines::ALL
.into_iter()
.find(|s| s.name().eq_ignore_ascii_case(&want))
.ok_or("--scanlines needs off, light, medium or strong")?;
}
"--crt" => {
render.crt = match args.next().as_deref() {
Some("off") => crt::CrtMode::Off,
Some("color") => crt::CrtMode::Color,
Some("green") => crt::CrtMode::Green,
Some("amber") => crt::CrtMode::Amber,
_ => return Err("--crt needs off, color, green or amber".into()),
}
}
a if a.starts_with('-') => return Err(format!("unknown option {a}")),
a if std::path::Path::new(a).is_file() => cmd = Command::Run(None, Some(PathBuf::from(a))),
a => {
let entry = Entry::from_address(a).ok_or_else(|| format!("not a valid address: {a}"))?;
cmd = Command::Run(Some(entry), None);
}
}
}
Ok(match render_files {
Some((input, output)) => Command::Render(input, output, render),
None => cmd,
})
}
fn main() -> ExitCode {
let has_console = console::attach();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let cmd = match parse_args() {
Ok(c) => c,
Err(e) => {
console::report_error(&format!("{e}\n\n{USAGE}"), has_console);
return ExitCode::from(2);
}
};
let paths = app::Paths::new();
let result = match cmd {
Command::Paths => {
println!("Dialing directory: {}", paths.directory.display());
println!("Captures: {}", paths.captures.display());
println!("Downloads: {}", paths.downloads.display());
println!("Color themes: {}", paths.themes.display());
println!("Monitor bezels: {}", paths.bezels.display());
println!("SSH known hosts: {}", paths.known_hosts.display());
Ok(())
}
Command::Import(file) => app::import_into_directory(&paths, &file).map(|(added, total)| {
println!("Imported {added} new entries ({} already present).", total - added);
}),
Command::Export(file) => Directory::load(&paths.directory)
.and_then(|d| dialer::export(&file, &d.entries).map(|()| d.entries.len()))
.map(|n| println!("Exported {n} entries to {}", file.display())),
Command::Render(input, output, opts) => {
viewer::render_file(&input, &output, opts).map(|(w, h)| println!("Wrote {} ({w}x{h})", output.display()))
}
Command::Run(startup, view) => run(paths, startup, view),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
console::report_error(&format!("{e:#}"), has_console);
ExitCode::FAILURE
}
}
}
fn run(paths: app::Paths, startup: Option<Entry>, view: Option<PathBuf>) -> anyhow::Result<()> {
let event_loop = EventLoop::<app::UserEvent>::with_user_event().build()?;
#[cfg(target_os = "macos")]
{
let proxy = event_loop.create_proxy();
macos_urls::install(move |url| {
let _ = proxy.send_event(app::UserEvent::OpenUrl(url));
});
}
let mut app = app::App::new(event_loop.create_proxy(), paths, startup, view);
event_loop.run_app(&mut app)?;
Ok(())
}