use anyhow::{anyhow, Context, Result};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use whisker_dev_server::{AndroidParams, Config, DevServer, HotPatchMode, IosParams, Target};
use crate::manifest;
#[derive(clap::Args, Debug)]
pub struct Args {
#[arg(long)]
pub manifest_path: Option<PathBuf>,
#[arg(value_enum)]
pub target: CliTarget,
#[arg(long, default_value = "127.0.0.1:9876")]
pub bind: SocketAddr,
#[arg(long)]
pub no_hot_patch: bool,
#[arg(long)]
pub workspace_root: Option<PathBuf>,
#[arg(long)]
pub show_native_logs: bool,
#[arg(long)]
pub no_tui: bool,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliTarget {
Android,
Ios,
}
impl From<CliTarget> for Target {
fn from(t: CliTarget) -> Self {
match t {
CliTarget::Android => Target::Android,
CliTarget::Ios => Target::IosSimulator,
}
}
}
pub fn run(args: Args) -> Result<()> {
let tui_enabled = !args.no_tui && std::io::IsTerminal::is_terminal(&std::io::stderr());
if tui_enabled {
std::env::set_var("WHISKER_TUI", "1");
}
let m = manifest::resolve(args.manifest_path.as_deref())
.context("resolve user-crate manifest (Cargo.toml + whisker.rs)")?;
let workspace_root = match &args.workspace_root {
Some(p) => p.clone(),
None => find_workspace_root(&m.crate_dir).ok_or_else(|| {
anyhow!(
"no [workspace] Cargo.toml at or above {}",
m.crate_dir.display()
)
})?,
};
let target: Target = args.target.into();
let target_label = target_label(target);
let bundle = m
.config
.bundle_id
.clone()
.unwrap_or_else(|| m.package.clone());
let tui_pieces = if tui_enabled {
match crate::tui::Tui::start(target_label.to_string(), bundle.clone()) {
Ok((tui, handle)) => {
handle.set_phase(crate::tui::AppPhase::Setup);
let render_handle = std::thread::Builder::new()
.name("whisker-tui-render".into())
.spawn(move || run_tui_render_loop(tui))
.ok();
Some((handle, render_handle))
}
Err(e) => {
eprintln!("couldn't start TUI ({e:#}); falling back to plain output");
None
}
}
} else {
None
};
let tui_handle = tui_pieces.as_ref().map(|(h, _)| h.clone());
let result = run_inner(args, m, workspace_root, target, tui_handle.as_ref());
if let Some((handle, render_thread)) = tui_pieces {
handle.request_quit();
if let Some(t) = render_thread {
let _ = t.join();
}
}
result
}
fn run_tui_render_loop(mut tui: crate::tui::Tui) {
let _ = tui.render_until_quit();
let user_quit = tui.was_user_quit();
let _ = tui.shutdown();
if user_quit {
whisker_build::child_guard::kill_all();
std::process::exit(0);
}
}
fn run_inner(
args: Args,
m: manifest::ResolvedManifest,
workspace_root: PathBuf,
target: Target,
tui: Option<&crate::tui::TuiHandle>,
) -> Result<()> {
let sync = crate::platforms::sync_for_target(
target,
&m.config,
&m.crate_dir,
&workspace_root,
&m.package,
)
.context("sync native project (gen/{android,ios}/)")?;
if sync.regenerated {
eprintln!(
"[whisker run] native project regenerated at {}",
sync.gen_dir.display(),
);
}
let android = match target {
Target::Android => Some(android_params_from(&m, &sync.gen_dir)?),
_ => None,
};
let ios = match target {
Target::IosSimulator => Some(ios_params_from(&m, &sync.gen_dir)?),
_ => None,
};
let watch_paths = vec![m.crate_dir.join("src"), m.crate_dir.join("whisker.rs")];
let config = Config {
workspace_root,
crate_dir: m.crate_dir,
package: m.package,
target,
watch_paths: watch_paths.clone(),
bind_addr: args.bind,
dev_token: Some(generate_dev_token()),
hot_patch_mode: if args.no_hot_patch {
HotPatchMode::Tier2ColdRebuild
} else {
HotPatchMode::Tier1Subsecond
},
android,
ios,
};
let watching_paths: Vec<String> = watch_paths
.iter()
.map(|p| p.display().to_string())
.collect();
if let Some(t) = tui {
t.set_dev_server(config.bind_addr.to_string(), watching_paths);
t.set_phase(crate::tui::AppPhase::Initializing);
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
let show_native_logs = args.show_native_logs;
let tui_for_events = tui.cloned();
let server = DevServer::new(config)?.on_event(move |e| {
if let Some(h) = &tui_for_events {
h.apply_event(&e);
} else {
forward_event_to_ui(e, show_native_logs);
}
});
rt.block_on(server.run())
}
fn generate_dev_token() -> String {
let mut buf = [0u8; 16];
let strong = std::fs::File::open("/dev/urandom")
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf))
.is_ok();
if !strong {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seed = nanos ^ (std::process::id() as u128);
for (i, b) in buf.iter_mut().enumerate() {
*b = (seed >> ((i % 16) * 8)) as u8;
}
}
let mut s = String::with_capacity(32);
for b in buf {
s.push_str(&format!("{b:02x}"));
}
s
}
fn target_label(target: Target) -> &'static str {
match target {
Target::Android => "Android",
Target::IosSimulator => "iOS Simulator",
}
}
fn forward_event_to_ui(event: whisker_dev_server::Event, show_native_logs: bool) {
use whisker_dev_server::Event;
if let Event::DeviceLog {
stream,
line,
ts_micros: _,
} = event
{
if !show_native_logs && is_native_engine_noise(&line) {
return;
}
let tag = match stream.as_str() {
"stderr" => "device:err",
_ => "device",
};
whisker_build::ui::info(format!("[{tag}] {line}"));
}
}
fn is_native_engine_noise(line: &str) -> bool {
let t = line.trim_start();
const LYNX_NOISE_PREFIXES: &[&str] = &[
"s_glBindAttribLocation:",
"s_glGetUniformLocation:",
"s_glGetAttribLocation:",
];
for prefix in LYNX_NOISE_PREFIXES {
if t.starts_with(prefix) {
return true;
}
}
false
}
#[cfg(test)]
mod device_log_filter_tests {
use super::is_native_engine_noise;
#[test]
fn drops_lynx_skia_bind_attrib_traces() {
assert!(is_native_engine_noise(
"s_glBindAttribLocation: bind attrib 0 name position"
));
assert!(is_native_engine_noise(
"s_glBindAttribLocation: bind attrib 2 name inTextureCoords"
));
assert!(is_native_engine_noise(
"s_glGetUniformLocation: query uniform u_mvp"
));
}
#[test]
fn drops_indented_lynx_traces() {
assert!(is_native_engine_noise(" s_glBindAttribLocation: bind 1"));
assert!(is_native_engine_noise(
"\ts_glGetAttribLocation: query in_color"
));
}
#[test]
fn preserves_user_println_output() {
assert!(!is_native_engine_noise("podcast: app() starting"));
assert!(!is_native_engine_noise("info: loaded 12 items from cache"));
assert!(!is_native_engine_noise("openglRenderer: skia init OK"));
assert!(!is_native_engine_noise(
"warning: glsl shader compilation took 42ms"
));
}
#[test]
fn preserves_panics_and_errors() {
assert!(!is_native_engine_noise(
"thread 'main' panicked at 'index out of bounds'"
));
assert!(!is_native_engine_noise("error: failed to parse JSON"));
}
}
fn android_params_from(
m: &manifest::ResolvedManifest,
project_dir: &Path,
) -> Result<AndroidParams> {
let a = &m.config.android;
let application_id = a
.application_id
.clone()
.or_else(|| m.config.bundle_id.clone())
.ok_or_else(|| {
anyhow!(
"whisker.rs: app.android(|a| a.application_id(\"…\")) is required for the android target"
)
})?;
let launcher_activity = a
.launcher_activity
.clone()
.unwrap_or_else(|| ".MainActivity".into());
Ok(AndroidParams {
project_dir: project_dir.to_path_buf(),
application_id,
launcher_activity,
abi: "arm64-v8a".into(),
})
}
fn ios_params_from(m: &manifest::ResolvedManifest, project_dir: &Path) -> Result<IosParams> {
let i = &m.config.ios;
let bundle_id = i
.bundle_id
.clone()
.or_else(|| m.config.bundle_id.clone())
.ok_or_else(|| {
anyhow!(
"whisker.rs: app.ios(|i| i.bundle_id(\"…\")) or app.bundle_id(\"…\") is required for the ios target"
)
})?;
let scheme = i
.scheme
.clone()
.or_else(|| m.config.name.clone())
.ok_or_else(|| {
anyhow!(
"whisker.rs: app.ios(|i| i.scheme(\"…\")) or app.name(\"…\") is required for the ios target"
)
})?;
Ok(IosParams {
project_dir: project_dir.to_path_buf(),
scheme,
bundle_id,
device_override: std::env::var("WHISKER_IOS_SIMULATOR").ok(),
})
}
fn find_workspace_root(start: &Path) -> Option<PathBuf> {
let mut cur = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
loop {
let cargo = cur.join("Cargo.toml");
if cargo.is_file() {
if let Ok(txt) = std::fs::read_to_string(&cargo) {
if txt.contains("[workspace]") {
return Some(cur);
}
}
}
if !cur.pop() {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
#[test]
fn cli_target_maps_to_dev_server_target() {
assert_eq!(Target::from(CliTarget::Android), Target::Android);
assert_eq!(Target::from(CliTarget::Ios), Target::IosSimulator);
}
fn unique_tempdir() -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let p = std::env::temp_dir().join(format!("whisker-cli-run-test-{pid}-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn find_workspace_root_returns_dir_when_cargo_toml_at_start() {
let tmp = unique_tempdir();
std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
assert_eq!(
find_workspace_root(&tmp).as_deref(),
Some(canonical_tmp.as_path()),
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn find_workspace_root_walks_up_from_a_member_dir() {
let tmp = unique_tempdir();
std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = [\"app\"]\n").unwrap();
let nested = tmp.join("app");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
nested.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.0.0\"\n",
)
.unwrap();
let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
assert_eq!(
find_workspace_root(&nested).as_deref(),
Some(canonical_tmp.as_path()),
);
std::fs::remove_dir_all(&tmp).ok();
}
}