use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process::ExitCode;
macro_rules! say {
($($t:tt)*) => { eprintln!("{}", crate::paint(&format!($($t)*), crate::color())) };
}
mod check;
mod classify;
mod inspect;
mod list;
mod output;
mod repack;
mod scan;
mod sources;
mod table;
mod target;
use classify::{classify_asset, Kind};
#[derive(Parser, Debug)]
#[command(name = "untauri", version, about, args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
#[arg(required = true)]
path: Option<PathBuf>,
#[arg(short, long, default_value = "unpacked")]
out: PathBuf,
#[arg(long)]
raw: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
manifest_only: bool,
#[arg(long)]
tree: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
sources: bool,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Inspect {
path: PathBuf,
#[arg(long)]
json: bool,
#[arg(long, value_name = "FILE")]
lockfile: Option<PathBuf>,
},
List {
path: PathBuf,
#[arg(long)]
json: bool,
},
Repack {
path: PathBuf,
dir: PathBuf,
#[arg(short, long, required_unless_present = "dry_run")]
out: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
},
}
fn wants_color(tty: bool) -> bool {
let env = |k| std::env::var_os(k).filter(|v| !v.is_empty());
env("NO_COLOR").is_none() && (tty || env("CLICOLOR_FORCE").is_some_and(|v| v != "0"))
}
fn color() -> bool {
use std::io::IsTerminal;
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| wants_color(std::io::stderr().is_terminal()))
}
pub fn color_out() -> bool {
use std::io::IsTerminal;
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| wants_color(std::io::stdout().is_terminal()))
}
pub fn style(s: &str, code: &str, on: bool) -> String {
if on && !s.is_empty() { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() }
}
fn paint(line: &str, color: bool) -> String {
const LABELS: [(&str, &str, bool); 8] = [
("error", "31", true), ("warning", "33", true), ("note", "36", false),
("complete", "32", true), ("references", "32", true), ("sources", "32", true), ("patched", "32", true), ("dry run", "32", true),
];
match LABELS.iter().find(|(l, ..)| color && line.starts_with(l)) {
Some((l, c, true)) => format!("\x1b[1;{c}m{l}\x1b[0;{c}m{}\x1b[0m", &line[l.len()..]),
Some((l, c, false)) => format!("\x1b[1;{c}m{l}\x1b[0m{}", &line[l.len()..]),
None => line.to_string(),
}
}
pub struct Recovered {
pub assets: Vec<scan::Asset>,
pub undecoded: Vec<String>,
pub from_table: bool,
pub compressed: bool,
}
pub fn recover(loaded: &target::Loaded, log: bool) -> Recovered {
if let Some(t) = table::read(&loaded.data) {
if log {
say!("read Tauri's asset table: {} entries ({})", t.assets.len() + t.undecoded.len(),
if t.compressed { "brotli" } else { "uncompressed" });
}
return Recovered { assets: t.assets, undecoded: t.undecoded, from_table: true, compressed: t.compressed };
}
if log { say!("no Tauri asset table found; falling back to scanning for brotli streams (names and completeness are best-effort)"); }
let (mut assets, mut undecoded) = (Vec::new(), Vec::new());
for r in &loaded.ranges {
let (found, failed) = scan::walk(&loaded.data, r.clone());
assets.extend(found);
undecoded.extend(failed);
}
Recovered { assets, undecoded, from_table: false, compressed: true }
}
fn load(path: &std::path::Path) -> Option<target::Loaded> {
match target::load(path) {
Ok(l) => Some(l),
Err(e) => { say!("error: not a readable binary: {} ({e})", path.display()); None }
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(Cmd::Inspect { path, json, lockfile }) = &cli.cmd {
let Some(loaded) = load(path) else { return ExitCode::from(1) };
return inspect::run(&loaded, *json, lockfile.as_deref());
}
if let Some(Cmd::List { path, json }) = &cli.cmd {
let Some(loaded) = load(path) else { return ExitCode::from(1) };
return list::run(&loaded, *json);
}
if let Some(Cmd::Repack { path, dir, out, dry_run }) = &cli.cmd {
return repack::run(path, dir, out.as_deref().filter(|_| !dry_run));
}
let Some(path) = cli.path.as_deref() else { return ExitCode::from(2) }; let Some(loaded) = load(path) else { return ExitCode::from(1) };
let Recovered { assets, undecoded, from_table, .. } = recover(&loaded, true);
let table_len = assets.len() + undecoded.len();
let stream_count = assets.len();
let mut items: Vec<(scan::Asset, Kind)> = assets.into_iter()
.map(|a| { let k = classify_asset(a.name.as_deref(), &a.bytes); (a, k) })
.filter(|(_, k)| cli.raw || *k != Kind::Bin)
.collect();
if !items.iter().any(|(_, k)| matches!(k, Kind::Html | Kind::Js | Kind::Css)) {
say!("found {stream_count} streams but no HTML/JS/CSS: no web frontend recovered. This may not be a Tauri app, or it is a debug build that loads assets from disk.");
return ExitCode::from(2);
}
items.extend(hash_aliases(&undecoded, &items));
if cli.verbose {
for (a, k) in &items { say!(" {:>10} {:<5} off={} clen={}", a.bytes.len(), k.ext(), a.offset, a.compressed_len); }
}
let entries = match output::write(&cli.out, &items, cli.manifest_only, cli.tree) {
Ok(entries) => entries,
Err(e) => { say!("error writing output: {e}"); return ExitCode::from(1); }
};
say!("recovered {} assets -> {}", entries.len(), cli.out.display());
if cli.sources && cli.manifest_only {
say!("note: --sources does nothing with --manifest-only");
} else if cli.sources {
let reserved = entries.iter().filter_map(|e| e.name.strip_prefix("sources/")).map(sources::key).collect();
let st = sources::write(&cli.out, sources::plan(items.iter().map(|(a, _)| a), &reserved));
if st.maps == 0 {
say!("sources: the app ships no source maps, so there is no original source to rebuild.");
} else {
say!("sources: rebuilt {} original files from {} source maps -> {} ({} from node_modules)",
st.written, st.maps, cli.out.join("sources").display(), st.node_modules);
if st.unusable > 0 { say!(" {} source maps could not be read (invalid JSON, or an index map with `sections`)", st.unusable); }
if st.no_content > 0 { say!(" {} files are listed in the maps without their text, so they could not be rebuilt", st.no_content); }
if st.skipped > 0 { say!(" {} files skipped (unusable path or write error)", st.skipped); }
}
}
if from_table {
if undecoded.is_empty() {
say!("complete: all {table_len} entries in the app's asset table were extracted.");
} else {
say!("warning: {} of {table_len} table entries did not decode:", undecoded.len());
for u in &undecoded { say!(" undecoded: {u}"); }
}
}
let missing = check::missing(&items, &entries);
if missing.is_empty() {
say!("references: every JS/CSS chunk and font the bundle names (mapDeps, ./chunk imports, CSS url()) was extracted.");
} else if from_table {
say!("note: {} name(s) referenced by the bundle are not in the app's asset table (loaded from elsewhere at runtime, or unused):", missing.len());
for m in &missing { say!(" not embedded: {m}"); }
} else {
say!("warning: {} asset(s) named by the recovered bundle were NOT extracted:", missing.len());
for m in &missing { say!(" missing: {m}"); }
}
let incomplete = !undecoded.is_empty() || (!from_table && !missing.is_empty());
if cli.strict && incomplete { ExitCode::from(3) } else { ExitCode::SUCCESS }
}
fn hash_aliases(undecoded: &[String], items: &[(scan::Asset, Kind)]) -> Vec<(scan::Asset, Kind)> {
let mut out = Vec::new();
for name in undecoded {
let Some(key) = hash_key(name) else { continue };
let Some((src, kind)) = items.iter().find(|(a, _)| a.name.as_deref().and_then(hash_key) == Some(key.clone())) else { continue };
say!("note: {name} did not decode; emitting a copy of {} (same content hash)", src.name.as_deref().unwrap_or("?"));
let (base, path) = match name.rsplit_once('/') {
Some((_, base)) if name.starts_with('/') => (base.to_string(), Some(name.clone())),
_ => (name.clone(), src.path.as_deref().map(|p| match p.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/{name}"),
None => name.clone(),
})),
};
out.push((
scan::Asset {
offset: src.offset,
compressed_len: src.compressed_len,
bytes: src.bytes.clone(),
name: Some(base),
path,
},
*kind,
));
}
out
}
fn hash_key(name: &str) -> Option<(String, String)> {
let (stem, ext) = name.rsplit_once('.')?;
let hash = stem.rsplit('-').next()?;
let vite_shaped = hash.len() == 8
&& hash.chars().any(|c| c.is_ascii_uppercase())
&& hash.chars().any(|c| c.is_ascii_lowercase());
vite_shaped.then(|| (hash.to_string(), ext.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn asset(name: &str) -> (scan::Asset, Kind) {
(scan::Asset { offset: 7, compressed_len: 9, bytes: b"export default 1;".to_vec(), name: Some(name.into()), path: None }, Kind::Js)
}
#[test]
fn aliases_an_undecodable_record_to_its_content_hash_twin() {
let items = vec![asset("classDiagram-U1v2W3x4-M3n4O5p6.js")];
let out = hash_aliases(&["classDiagram-v2-Q7r8S9t0-M3n4O5p6.js".to_string()], &items);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0.name.as_deref(), Some("classDiagram-v2-Q7r8S9t0-M3n4O5p6.js"));
assert_eq!(out[0].0.bytes, items[0].0.bytes);
}
#[test]
fn table_key_alias_keeps_its_own_key() {
let mut twin = asset("classDiagram-U1v2W3x4-M3n4O5p6.js");
twin.0.path = Some("/assets/classDiagram-U1v2W3x4-M3n4O5p6.js".into());
let out = hash_aliases(&["/assets/classDiagram-v2-Q7r8S9t0-M3n4O5p6.js".to_string()], &[twin]);
assert_eq!(out[0].0.path.as_deref(), Some("/assets/classDiagram-v2-Q7r8S9t0-M3n4O5p6.js"));
assert_eq!(out[0].0.name.as_deref(), Some("classDiagram-v2-Q7r8S9t0-M3n4O5p6.js"));
}
#[test]
fn does_not_alias_across_different_content_hashes() {
let items = vec![asset("classDiagram-U1v2W3x4-ZZZZZZZZ.js")];
assert!(hash_aliases(&["classDiagram-v2-Q7r8S9t0-M3n4O5p6.js".to_string()], &items).is_empty());
}
#[test]
fn paint_colors_by_label() {
assert_eq!(paint("error: nope", true), "\x1b[1;31merror\x1b[0;31m: nope\x1b[0m");
assert_eq!(paint("patched 1 asset(s)", true), "\x1b[1;32mpatched\x1b[0;32m 1 asset(s)\x1b[0m");
assert_eq!(paint("note: x", true), "\x1b[1;36mnote\x1b[0m: x");
assert_eq!(paint("error: nope", false), "error: nope");
assert_eq!(paint("recovered 61 assets", true), "recovered 61 assets");
}
#[test]
fn kebab_case_names_without_a_hash_do_not_alias() {
let items = vec![asset("dialog-window.js")];
assert!(
hash_aliases(&["panel-window.js".to_string()], &items).is_empty(),
"a bare kebab word (`window`) is not a Vite content hash"
);
let vite = vec![asset("classDiagram-U1v2W3x4-M3n4O5p6.js")];
assert_eq!(hash_aliases(&["classDiagram-v2-Q7r8S9t0-M3n4O5p6.js".to_string()], &vite).len(), 1);
}
}