use clap::Parser;
use std::path::PathBuf;
use std::process::ExitCode;
mod check;
mod classify;
mod output;
mod scan;
mod table;
mod target;
use classify::{classify_asset, Kind};
#[derive(Parser, Debug)]
#[command(name = "untauri", version, about)]
struct Cli {
path: 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,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let loaded = match target::load(&cli.path) {
Ok(l) => l,
Err(e) => { eprintln!("error: not a readable binary: {} ({e})", cli.path.display()); return ExitCode::from(1); }
};
let (assets, undecoded, from_table) = match table::read(&loaded.data) {
Some(t) => {
eprintln!("read Tauri's asset table: {} entries ({})", t.assets.len() + t.undecoded.len(),
if t.compressed { "brotli" } else { "uncompressed" });
(t.assets, t.undecoded, true)
}
None => {
eprintln!("no Tauri asset table found; falling back to scanning for brotli streams (names and completeness are best-effort)");
let (mut a, mut u) = (Vec::new(), Vec::new());
for r in &loaded.ranges {
let (found, failed) = scan::walk(&loaded.data, r.clone());
a.extend(found);
u.extend(failed);
}
(a, u, false)
}
};
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)) {
eprintln!("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 { eprintln!(" {:>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) => { eprintln!("error writing output: {e}"); return ExitCode::from(1); }
};
eprintln!("recovered {} assets -> {}", entries.len(), cli.out.display());
if from_table {
if undecoded.is_empty() {
eprintln!("complete: all {table_len} entries in the app's asset table were extracted.");
} else {
eprintln!("warning: {} of {table_len} table entries did not decode:", undecoded.len());
for u in &undecoded { eprintln!(" undecoded: {u}"); }
}
}
let missing = check::missing(&items, &entries);
if missing.is_empty() {
eprintln!("references: every JS/CSS chunk and font the bundle names (mapDeps, ./chunk imports, CSS url()) was extracted.");
} else if from_table {
eprintln!("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 { eprintln!(" not embedded: {m}"); }
} else {
eprintln!("warning: {} asset(s) named by the recovered bundle were NOT extracted:", missing.len());
for m in &missing { eprintln!(" 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 };
eprintln!("note: {name} did not decode; emitting a copy of {} (same content hash)", src.name.as_deref().unwrap_or("?"));
out.push((
scan::Asset {
offset: src.offset,
compressed_len: src.compressed_len,
bytes: src.bytes.clone(),
name: Some(name.clone()),
path: src.path.as_deref().map(|p| match p.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/{name}"),
None => name.clone(),
}),
},
*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 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 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);
}
}