use std::cell::OnceCell;
use std::collections::HashMap;
use std::io::{self, IsTerminal, Read};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use tao::dpi::LogicalSize;
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
mod config;
mod detach;
#[cfg(feature = "e2e")]
mod e2e;
mod menu;
#[cfg(target_os = "macos")]
mod shortcuts;
mod template;
mod watch;
mod webview;
use template::TemplateRef;
use watch::UserEvent;
use webview::{BuildOptions, Permissions};
#[derive(Parser)]
#[command(name = "tinyview", version, about = "Ephemeral CLI WebView runtime")]
struct Cli {
source: Option<PathBuf>,
#[arg(long)]
html: Option<String>,
#[arg(short = 't', long)]
template: Option<String>,
#[arg(long, value_parser = parse_param)]
param: Vec<(String, String)>,
#[arg(long)]
width: Option<u32>,
#[arg(long)]
height: Option<u32>,
#[arg(long)]
frameless: bool,
#[arg(long)]
transparent: bool,
#[arg(long)]
foreground: bool,
#[arg(long)]
watch: bool,
#[arg(long)]
allow_fetch: bool,
#[arg(long)]
allow_clipboard: bool,
#[arg(long)]
allow_storage: bool,
}
fn parse_param(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected key=value, got `{s}`"))?;
Ok((k.to_string(), v.to_string()))
}
struct Input {
content: String,
path: Option<PathBuf>,
}
fn read_input(cli: &Cli) -> io::Result<Input> {
let stdin = io::stdin();
if !stdin.is_terminal() && stdin_has_data() {
let mut buf = String::new();
stdin.lock().read_to_string(&mut buf)?;
return Ok(Input {
content: buf,
path: None,
});
}
if let Some(path) = &cli.source {
let content = std::fs::read_to_string(path)?;
return Ok(Input {
content,
path: Some(path.clone()),
});
}
if let Some(html) = &cli.html {
return Ok(Input {
content: html.clone(),
path: None,
});
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no input: pipe stdin, pass a file, or use --html",
))
}
#[cfg(unix)]
fn stdin_has_data() -> bool {
use std::os::fd::AsRawFd;
let mut bytes: libc::c_int = 0;
let r = unsafe { libc::ioctl(io::stdin().as_raw_fd(), libc::FIONREAD, &mut bytes) };
r == 0 && bytes > 0
}
#[cfg(not(unix))]
fn stdin_has_data() -> bool {
true
}
fn is_raw_fast_path(cli: &Cli, input: &Input) -> bool {
cli.template.is_none() && cli.param.is_empty() && input.path.is_none()
}
fn resolve_user_template_path(tpl: TemplateRef) -> TemplateRef {
match tpl {
TemplateRef::User(rel) if rel.is_relative() => {
let root = config::config_root()
.map(|r| r.join("templates"))
.unwrap_or_else(|| PathBuf::from("."));
TemplateRef::User(root.join(rel))
}
other => other,
}
}
fn merge_params(
tpl: &TemplateRef,
cfg: Option<&config::Config>,
cli_params: &[(String, String)],
) -> HashMap<String, String> {
let mut out: HashMap<String, String> = HashMap::new();
let name: Option<&str> = match tpl {
TemplateRef::Raw => Some("raw"),
TemplateRef::Text => Some("text"),
TemplateRef::Minimal => Some("minimal"),
TemplateRef::Markdown => Some("markdown"),
TemplateRef::Mermaid => Some("mermaid"),
TemplateRef::Code => Some("code"),
TemplateRef::User(p) => p.file_stem().and_then(|s| s.to_str()),
};
if let (Some(c), Some(n)) = (cfg, name) {
if let Some(entry) = c.templates.get(n) {
for (k, v) in &entry.params {
out.insert(k.clone(), v.clone());
}
}
}
for (k, v) in cli_params {
out.insert(k.clone(), v.clone());
}
out
}
struct WindowOpts {
width: u32,
height: u32,
frameless: bool,
transparent: bool,
}
fn launch_webview(
window_opts: WindowOpts,
html: String,
perms: Permissions,
raw_mode: bool,
watch_ctx: Option<watch::WatchContext>,
) -> ExitCode {
#[allow(unused_mut)]
let mut event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
#[cfg(target_os = "macos")]
{
use tao::platform::macos::{ActivationPolicy, EventLoopExtMacOS};
event_loop.set_activation_policy(ActivationPolicy::Regular);
}
let _menu_guard = menu::install();
let window = match WindowBuilder::new()
.with_title("tinyview")
.with_inner_size(LogicalSize::new(
window_opts.width as f64,
window_opts.height as f64,
))
.with_decorations(!window_opts.frameless)
.with_transparent(window_opts.transparent)
.build(&event_loop)
{
Ok(w) => w,
Err(e) => {
eprintln!("tinyview: failed to create window: {e}");
return ExitCode::from(1);
}
};
let webview = match webview::build(
&window,
BuildOptions {
html: &html,
perms,
raw_mode,
transparent: window_opts.transparent,
#[cfg(feature = "e2e")]
ipc_tx: None,
},
) {
Ok(wv) => wv,
Err(e) => {
eprintln!("tinyview: failed to create webview: {e}");
return ExitCode::from(1);
}
};
let _watcher_guard = match watch_ctx {
Some(ctx) => match watch::spawn_watcher(ctx, event_loop.create_proxy()) {
Ok(g) => Some(g),
Err(e) => {
eprintln!("tinyview: warn: failed to start file watcher: {e}");
None
}
},
None => None,
};
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
*control_flow = ControlFlow::Exit;
}
Event::UserEvent(UserEvent::Reload(new_html)) => {
let prepared = webview::prepare_html(&new_html, &perms, raw_mode);
if let Err(e) = webview.load_html(&prepared) {
eprintln!("tinyview: warn: load_html failed: {e}");
}
}
_ => {}
}
});
}
fn run_as_child(cli: Cli) -> ExitCode {
let mut html = String::new();
if io::stdin().lock().read_to_string(&mut html).is_err() {
return ExitCode::from(1);
}
let window_opts = WindowOpts {
width: cli.width.unwrap_or(1000),
height: cli.height.unwrap_or(760),
frameless: cli.frameless,
transparent: cli.transparent,
};
let raw_mode = detach::detached_raw_mode();
let perms = Permissions {
allow_fetch: cli.allow_fetch,
allow_clipboard: cli.allow_clipboard,
allow_storage: cli.allow_storage,
};
launch_webview(window_opts, html, perms, raw_mode, None)
}
fn run(cli: Cli) -> ExitCode {
let input = match read_input(&cli) {
Ok(i) => i,
Err(e) => {
eprintln!("tinyview: {e}");
return ExitCode::from(1);
}
};
if cli.watch && input.path.is_none() {
eprintln!("tinyview: --watch requires a file path (stdin / --html not supported)");
return ExitCode::from(2);
}
let cfg_cache: OnceCell<Option<config::Config>> = OnceCell::new();
let cfg = if is_raw_fast_path(&cli, &input) {
None
} else {
config::load_if_needed(&cfg_cache)
};
let tpl = template::resolve(
cli.template.as_deref(),
input.path.as_deref(),
cfg.map(|c| &c.extension),
cfg.and_then(|c| c.default_template.as_deref()),
);
let tpl = resolve_user_template_path(tpl);
let merged_params = merge_params(&tpl, cfg, &cli.param);
let raw_mode = matches!(tpl, TemplateRef::Raw);
let html = if raw_mode {
input.content
} else {
let data = template::InjectData {
input: &input.content,
params: &merged_params,
title: "tinyview",
path: input.path.as_deref(),
};
match template::render(&tpl, &data) {
Ok(h) => h,
Err(e) => {
eprintln!("tinyview: {e}");
return ExitCode::from(1);
}
}
};
if std::env::var_os("TINYVIEW_DUMP_HTML").is_some() {
print!("{html}");
return ExitCode::SUCCESS;
}
let width = cli
.width
.or(cfg.and_then(|c| c.window_width))
.unwrap_or(1000);
let height = cli
.height
.or(cfg.and_then(|c| c.window_height))
.unwrap_or(760);
let perms = Permissions {
allow_fetch: cli.allow_fetch,
allow_clipboard: cli.allow_clipboard,
allow_storage: cli.allow_storage,
};
let foreground = cli.foreground || cli.watch;
if foreground {
let watch_ctx = if cli.watch {
let source = input.path.clone().expect("watch validated above");
Some(watch::WatchContext {
source,
template: tpl.clone(),
params: merged_params.clone(),
raw_mode,
})
} else {
None
};
let window_opts = WindowOpts {
width,
height,
frameless: cli.frameless,
transparent: cli.transparent,
};
return launch_webview(window_opts, html, perms, raw_mode, watch_ctx);
}
let opts = detach::SpawnOpts {
html: &html,
width,
height,
raw_mode,
allow_fetch: cli.allow_fetch,
allow_clipboard: cli.allow_clipboard,
allow_storage: cli.allow_storage,
frameless: cli.frameless,
transparent: cli.transparent,
};
match detach::spawn(&opts) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("tinyview: detach failed: {e}");
ExitCode::from(1)
}
}
}
fn main() -> ExitCode {
#[cfg(feature = "e2e")]
if std::env::var_os("TINYVIEW_E2E_SELFTEST").is_some() {
return e2e::run_selftest();
}
let cli = Cli::parse();
if detach::is_detached_child() {
return run_as_child(cli);
}
run(cli)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn cli_defaults_frameless_and_transparent_to_false() {
let cli = Cli::try_parse_from(["tinyview", "file.html"]).expect("parse");
assert!(!cli.frameless);
assert!(!cli.transparent);
}
#[test]
fn cli_parses_frameless_flag() {
let cli = Cli::try_parse_from(["tinyview", "file.html", "--frameless"]).expect("parse");
assert!(cli.frameless);
assert!(!cli.transparent);
}
#[test]
fn cli_parses_transparent_flag() {
let cli = Cli::try_parse_from(["tinyview", "file.html", "--transparent"]).expect("parse");
assert!(cli.transparent);
assert!(!cli.frameless);
}
#[test]
fn cli_parses_frameless_and_transparent_together() {
let cli = Cli::try_parse_from(["tinyview", "file.html", "--frameless", "--transparent"])
.expect("parse");
assert!(cli.frameless);
assert!(cli.transparent);
}
#[test]
fn cli_frameless_and_transparent_coexist_with_existing_flags() {
let cli = Cli::try_parse_from([
"tinyview",
"file.html",
"--frameless",
"--transparent",
"--width",
"640",
"--height",
"480",
"--allow-fetch",
"--foreground",
])
.expect("parse");
assert!(cli.frameless);
assert!(cli.transparent);
assert_eq!(cli.width, Some(640));
assert_eq!(cli.height, Some(480));
assert!(cli.allow_fetch);
assert!(cli.foreground);
}
#[test]
fn merge_params_code_cli_lang_overrides_config() {
let mut cfg = config::Config::default();
let mut entry = config::TemplateEntry::default();
entry
.params
.insert("lang".to_string(), "python".to_string());
entry
.params
.insert("theme".to_string(), "github".to_string());
cfg.templates.insert("code".to_string(), entry);
let cli_params = vec![("lang".to_string(), "rust".to_string())];
let merged = merge_params(&TemplateRef::Code, Some(&cfg), &cli_params);
assert_eq!(merged.get("lang").map(String::as_str), Some("rust"));
assert_eq!(merged.get("theme").map(String::as_str), Some("github"));
}
}