whisker_cli/run.rs
1//! `whisker run` — start the dev server.
2//!
3//! Thin wrapper: resolves the user crate's `whisker.rs` config (via
4//! [`super::manifest::resolve`] + [`super::probe::run`]), translates
5//! the resulting [`whisker_config::Config`] into a flat
6//! [`whisker_dev_server::Config`], and hands off to
7//! `DevServer::run`. All the heavy lifting (file watch / cargo build
8//! / WebSocket push / subsecond patches) lives in
9//! `whisker-dev-server` so other host shells (an editor plugin, a
10//! notebook front-end, …) can reuse the same loop without a
11//! whisker-config dependency.
12
13use anyhow::{anyhow, Context, Result};
14use std::net::SocketAddr;
15use std::path::{Path, PathBuf};
16use whisker_dev_server::{AndroidParams, Config, DevServer, HotPatchMode, IosParams, Target};
17
18use crate::manifest;
19
20#[derive(clap::Args, Debug)]
21pub struct Args {
22 /// Path to the user crate's `Cargo.toml`. Defaults to walking up
23 /// from `cwd` until a `Cargo.toml` with a `[package]` section is
24 /// found (cargo-style).
25 #[arg(long)]
26 pub manifest_path: Option<PathBuf>,
27
28 /// Where to deploy the rebuilt artifact. Positional so the
29 /// common case (`whisker run android` / `whisker run ios`) reads
30 /// naturally without a `--target=` prefix.
31 #[arg(value_enum)]
32 pub target: CliTarget,
33
34 /// WebSocket bind address. The Whisker app on the device dials this
35 /// (via `WHISKER_DEV_ADDR`) to receive patches.
36 #[arg(long, default_value = "127.0.0.1:9876")]
37 pub bind: SocketAddr,
38
39 /// Opt out of Tier 1 subsecond hot-patching and fall back to Tier 2
40 /// cold rebuilds. `whisker run` defaults to Tier 1; this flag is
41 /// for situations where the hot-patch path is misbehaving and you
42 /// just want the slower-but-bulletproof path.
43 #[arg(long)]
44 pub no_hot_patch: bool,
45
46 /// Override the workspace root (= directory containing the
47 /// `Cargo.toml` with `[workspace]`). Defaults to walking up from
48 /// the resolved manifest's parent dir.
49 #[arg(long)]
50 pub workspace_root: Option<PathBuf>,
51
52 /// Show every line of the device's stdout/stderr stream, including
53 /// Lynx C++ engine chatter (`s_glBindAttribLocation: …` and
54 /// friends) that the curated default suppresses. Useful when
55 /// triaging engine-level issues; noisy for typical app
56 /// development. Pair with `WHISKER_VERBOSE=1` for the full picture.
57 #[arg(long)]
58 pub show_native_logs: bool,
59
60 /// Disable the inline ratatui status bar at the bottom of the
61 /// terminal. On by default when stderr is a TTY; auto-off when
62 /// piping to a file or running under CI. Use this when running
63 /// against a tmux pane that doesn't like inline viewports, or
64 /// when you specifically want grep'able scrollback-only output.
65 #[arg(long)]
66 pub no_tui: bool,
67}
68
69#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
70pub enum CliTarget {
71 Android,
72 Ios,
73}
74
75impl From<CliTarget> for Target {
76 fn from(t: CliTarget) -> Self {
77 match t {
78 CliTarget::Android => Target::Android,
79 CliTarget::Ios => Target::IosSimulator,
80 }
81 }
82}
83
84pub fn run(args: Args) -> Result<()> {
85 // Set the cross-crate TUI signal before any `whisker_build::ui::*`
86 // call fires — `whisker_build::ui::mode()` caches its lookup in a
87 // `OnceLock` on the first call, so flipping this env later doesn't
88 // unstick a `Curated` cache.
89 let tui_enabled = !args.no_tui && std::io::IsTerminal::is_terminal(&std::io::stderr());
90 if tui_enabled {
91 std::env::set_var("WHISKER_TUI", "1");
92 }
93
94 // Resolve the user-facing manifest before doing anything UI-y so
95 // that the TUI header can display the bundle id from the moment
96 // it first paints.
97 let m = manifest::resolve(args.manifest_path.as_deref())
98 .context("resolve user-crate manifest (Cargo.toml + whisker.rs)")?;
99 let workspace_root = match &args.workspace_root {
100 Some(p) => p.clone(),
101 None => find_workspace_root(&m.crate_dir).ok_or_else(|| {
102 anyhow!(
103 "no [workspace] Cargo.toml at or above {}",
104 m.crate_dir.display()
105 )
106 })?,
107 };
108 let target: Target = args.target.into();
109 let target_label = target_label(target);
110 let bundle = m
111 .config
112 .bundle_id
113 .clone()
114 .unwrap_or_else(|| m.package.clone());
115
116 // Start the TUI as the very first user-visible action so the
117 // long setup steps (sync, plugin build, initial build, install)
118 // render with a proper progress indicator instead of leaking
119 // ahead of an inline status bar.
120 let tui_pieces = if tui_enabled {
121 match crate::tui::Tui::start(target_label.to_string(), bundle.clone()) {
122 Ok((tui, handle)) => {
123 handle.set_phase(crate::tui::AppPhase::Setup);
124 let render_handle = std::thread::Builder::new()
125 .name("whisker-tui-render".into())
126 .spawn(move || run_tui_render_loop(tui))
127 .ok();
128 Some((handle, render_handle))
129 }
130 Err(e) => {
131 eprintln!("couldn't start TUI ({e:#}); falling back to plain output");
132 None
133 }
134 }
135 } else {
136 None
137 };
138 let tui_handle = tui_pieces.as_ref().map(|(h, _)| h.clone());
139
140 // Run the rest of the cli pipeline. Each phase pushes its progress
141 // through `tui_handle`. If the TUI isn't running, every step is
142 // a no-op + the existing `whisker_build::ui::*` lines fall back
143 // to scrollback.
144 let result = run_inner(args, m, workspace_root, target, tui_handle.as_ref());
145
146 // Stop the render thread + restore the terminal. Use should_quit
147 // as the signal so the render thread exits cleanly.
148 if let Some((handle, render_thread)) = tui_pieces {
149 handle.request_quit();
150 if let Some(t) = render_thread {
151 let _ = t.join();
152 }
153 }
154 result
155}
156
157fn run_tui_render_loop(mut tui: crate::tui::Tui) {
158 let _ = tui.render_until_quit();
159 let user_quit = tui.was_user_quit();
160 let _ = tui.shutdown();
161 if user_quit {
162 // The dev-server runs to completion (i.e. forever) inside
163 // `rt.block_on(server.run())` on the cli thread, so simply
164 // tearing the TUI down here would leave a headless `whisker
165 // run` process alive after `q`. Hard-exit with a normal
166 // status; tokio sockets / file watchers get reaped by the
167 // kernel. cli-initiated shutdowns (build failed, etc.) take
168 // the other branch and let `run()`'s normal return path
169 // surface the error.
170 //
171 // `exit` skips destructors, so an in-flight cargo / gradle /
172 // xcodebuild would be orphaned — SIGTERM the tracked build
173 // children first (the gradle daemon, in its own session, is
174 // spared).
175 whisker_build::child_guard::kill_all();
176 std::process::exit(0);
177 }
178}
179
180fn run_inner(
181 args: Args,
182 m: manifest::ResolvedManifest,
183 workspace_root: PathBuf,
184 target: Target,
185 tui: Option<&crate::tui::TuiHandle>,
186) -> Result<()> {
187 // Sync the native host project (gen/{android,ios}/) before doing
188 // anything else. The cargo-side `build_discovered_plugins` step
189 // happens inside `sync_for_target` and is the long pole here.
190 // `set_phase(Setup)` already fired from `run()` before we got
191 // here, so re-issuing it would duplicate the "▶ Setup" entry in
192 // scrollback.
193 //
194 // No iOS Lynx pre-fetch: `platforms/ios/Package.swift` now uses
195 // `binaryTarget(url:checksum:)`, so xcodebuild resolves the four
196 // xcframeworks via SPM during package resolution. Android pulls
197 // its aar from `whiskerrs.github.io/lynx/maven` transitively via
198 // the SDK pom. Neither path needs the workspace `target/lynx-*`
199 // tree the cli used to stage here.
200
201 let sync = crate::platforms::sync_for_target(
202 target,
203 &m.config,
204 &m.crate_dir,
205 &workspace_root,
206 &m.package,
207 )
208 .context("sync native project (gen/{android,ios}/)")?;
209 if sync.regenerated {
210 eprintln!(
211 "[whisker run] native project regenerated at {}",
212 sync.gen_dir.display(),
213 );
214 }
215
216 let android = match target {
217 Target::Android => Some(android_params_from(&m, &sync.gen_dir)?),
218 _ => None,
219 };
220 let ios = match target {
221 Target::IosSimulator => Some(ios_params_from(&m, &sync.gen_dir)?),
222 _ => None,
223 };
224
225 let watch_paths = vec![m.crate_dir.join("src"), m.crate_dir.join("whisker.rs")];
226
227 let config = Config {
228 workspace_root,
229 crate_dir: m.crate_dir,
230 package: m.package,
231 target,
232 watch_paths: watch_paths.clone(),
233 bind_addr: args.bind,
234 // Random per-session token authenticating the device to the
235 // hot-reload WebSocket. The patch channel `dlopen`s whatever it
236 // receives, so without this an unauthenticated peer on a
237 // LAN-exposed bind could push arbitrary native code; the gate
238 // also defends an accidental `--bind 0.0.0.0`.
239 dev_token: Some(generate_dev_token()),
240 hot_patch_mode: if args.no_hot_patch {
241 HotPatchMode::Tier2ColdRebuild
242 } else {
243 HotPatchMode::Tier1Subsecond
244 },
245 android,
246 ios,
247 };
248
249 let watching_paths: Vec<String> = watch_paths
250 .iter()
251 .map(|p| p.display().to_string())
252 .collect();
253 if let Some(t) = tui {
254 t.set_dev_server(config.bind_addr.to_string(), watching_paths);
255 t.set_phase(crate::tui::AppPhase::Initializing);
256 }
257
258 let rt = tokio::runtime::Builder::new_multi_thread()
259 .enable_all()
260 .build()
261 .context("build tokio runtime")?;
262 let show_native_logs = args.show_native_logs;
263 let tui_for_events = tui.cloned();
264
265 let server = DevServer::new(config)?.on_event(move |e| {
266 if let Some(h) = &tui_for_events {
267 // TUI mode: the handle's `apply_event` already pushes
268 // `Event::DeviceLog` into scrollback via `insert_before`
269 // as a `[device]` / `[device:err]` row. Routing the
270 // same event through `forward_event_to_ui` would
271 // double-print every device log line (once raw, once
272 // wrapped in `whisker_build::ui::info`'s `· ` prefix
273 // and captured back through stderr). Skip the legacy
274 // path entirely when the TUI is on.
275 h.apply_event(&e);
276 } else {
277 forward_event_to_ui(e, show_native_logs);
278 }
279 });
280
281 rt.block_on(server.run())
282}
283
284/// Friendly label for the TUI header. `whisker_dev_server::Target`'s
285/// Debug impl renders `IosSimulator` which is a mouthful — pick a
286/// short noun for screen real estate.
287/// Generate a random hex token for the hot-reload session.
288///
289/// Reads 16 bytes from `/dev/urandom` (every host we run on is POSIX)
290/// and hex-encodes them into a 32-char token. If `/dev/urandom` is
291/// somehow unreadable we fall back to a time+pid-seeded value — weaker,
292/// but the token only needs to be unguessable within a dev session on
293/// the local machine, and the dev loop shouldn't hard-fail over it.
294fn generate_dev_token() -> String {
295 let mut buf = [0u8; 16];
296 let strong = std::fs::File::open("/dev/urandom")
297 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf))
298 .is_ok();
299 if !strong {
300 // Fallback seed: nanos since epoch XOR pid, splatted across the
301 // buffer. Not cryptographic, but non-constant per session.
302 let nanos = std::time::SystemTime::now()
303 .duration_since(std::time::UNIX_EPOCH)
304 .map(|d| d.as_nanos())
305 .unwrap_or(0);
306 let seed = nanos ^ (std::process::id() as u128);
307 for (i, b) in buf.iter_mut().enumerate() {
308 *b = (seed >> ((i % 16) * 8)) as u8;
309 }
310 }
311 let mut s = String::with_capacity(32);
312 for b in buf {
313 s.push_str(&format!("{b:02x}"));
314 }
315 s
316}
317
318fn target_label(target: Target) -> &'static str {
319 match target {
320 Target::Android => "Android",
321 Target::IosSimulator => "iOS Simulator",
322 }
323}
324
325/// Translate dev-server [`Event`]s into the existing line-based UI
326/// output. Phase 2 (ratatui TUI) will replace this with a routed
327/// dispatch into per-pane state; until then, the relevant signal we
328/// need to surface is the device's own stdout/stderr — everything else
329/// is already covered by `whisker_build::ui` calls inside the dev
330/// loop.
331///
332/// When `show_native_logs` is false (the default), device lines that
333/// match [`is_native_engine_noise`] are dropped silently. The escape
334/// hatch is `whisker run --show-native-logs`.
335fn forward_event_to_ui(event: whisker_dev_server::Event, show_native_logs: bool) {
336 use whisker_dev_server::Event;
337 if let Event::DeviceLog {
338 stream,
339 line,
340 ts_micros: _,
341 } = event
342 {
343 if !show_native_logs && is_native_engine_noise(&line) {
344 return;
345 }
346 // Short `[device]` / `[device:err]` prefix keeps the column
347 // alignment compact next to `whisker-build::ui::info`'s own
348 // output. The Phase-2 TUI can surface stream / timestamp /
349 // colour separately.
350 let tag = match stream.as_str() {
351 "stderr" => "device:err",
352 _ => "device",
353 };
354 whisker_build::ui::info(format!("[{tag}] {line}"));
355 }
356}
357
358/// Identify lines that come from the Lynx C++ engine's debug stderr
359/// rather than the user's own Rust code. Lynx's Skia/GL backend
360/// prints per-program attribute-binding traces (`s_glBindAttribLocation:
361/// bind attrib N name X`) on every frame draw and a handful of other
362/// engine-internal log lines that are not actionable from app code.
363///
364/// The filter intentionally errs toward letting unknown lines through
365/// — these patterns are bounded to specific known-noisy Lynx prefixes,
366/// so genuine error output and user `eprintln!`s are never silenced.
367fn is_native_engine_noise(line: &str) -> bool {
368 let t = line.trim_start();
369 // Lynx Skia / GL trace prefixes. The `s_gl<CamelCase>(` form is
370 // distinctive — Skia internals only — and shows up dozens of
371 // times per frame on first paint.
372 const LYNX_NOISE_PREFIXES: &[&str] = &[
373 "s_glBindAttribLocation:",
374 "s_glGetUniformLocation:",
375 "s_glGetAttribLocation:",
376 ];
377 for prefix in LYNX_NOISE_PREFIXES {
378 if t.starts_with(prefix) {
379 return true;
380 }
381 }
382 false
383}
384
385#[cfg(test)]
386mod device_log_filter_tests {
387 use super::is_native_engine_noise;
388
389 #[test]
390 fn drops_lynx_skia_bind_attrib_traces() {
391 assert!(is_native_engine_noise(
392 "s_glBindAttribLocation: bind attrib 0 name position"
393 ));
394 assert!(is_native_engine_noise(
395 "s_glBindAttribLocation: bind attrib 2 name inTextureCoords"
396 ));
397 assert!(is_native_engine_noise(
398 "s_glGetUniformLocation: query uniform u_mvp"
399 ));
400 }
401
402 #[test]
403 fn drops_indented_lynx_traces() {
404 // Belt-and-braces: native printf output sometimes lands with a
405 // leading space or tab from libc buffering.
406 assert!(is_native_engine_noise(" s_glBindAttribLocation: bind 1"));
407 assert!(is_native_engine_noise(
408 "\ts_glGetAttribLocation: query in_color"
409 ));
410 }
411
412 #[test]
413 fn preserves_user_println_output() {
414 assert!(!is_native_engine_noise("podcast: app() starting"));
415 assert!(!is_native_engine_noise("info: loaded 12 items from cache"));
416 // Even patterns that touch `gl` but aren't Lynx's known
417 // tracers should pass through — the filter list is precise
418 // by design.
419 assert!(!is_native_engine_noise("openglRenderer: skia init OK"));
420 assert!(!is_native_engine_noise(
421 "warning: glsl shader compilation took 42ms"
422 ));
423 }
424
425 #[test]
426 fn preserves_panics_and_errors() {
427 assert!(!is_native_engine_noise(
428 "thread 'main' panicked at 'index out of bounds'"
429 ));
430 assert!(!is_native_engine_noise("error: failed to parse JSON"));
431 }
432}
433
434/// Build [`AndroidParams`] from the resolved manifest. Returns an
435/// error if the user's `whisker.rs` left required fields (like the
436/// `applicationId`) unset.
437///
438/// `project_dir` is the *generated* Gradle project under
439/// `gen/android/` — `whisker-cng` writes the tree, this function just
440/// stitches in the `applicationId` + launcher activity for installer
441/// use.
442fn android_params_from(
443 m: &manifest::ResolvedManifest,
444 project_dir: &Path,
445) -> Result<AndroidParams> {
446 let a = &m.config.android;
447 let application_id = a
448 .application_id
449 .clone()
450 .or_else(|| m.config.bundle_id.clone())
451 .ok_or_else(|| {
452 anyhow!(
453 "whisker.rs: app.android(|a| a.application_id(\"…\")) is required for the android target"
454 )
455 })?;
456 let launcher_activity = a
457 .launcher_activity
458 .clone()
459 .unwrap_or_else(|| ".MainActivity".into());
460 Ok(AndroidParams {
461 project_dir: project_dir.to_path_buf(),
462 application_id,
463 launcher_activity,
464 // Single-ABI dev loops only — multi-ABI is a release concern.
465 abi: "arm64-v8a".into(),
466 })
467}
468
469/// Build [`IosParams`] from the resolved manifest. `project_dir` is
470/// the generated `gen/ios/` tree (after `whisker-cng` + xcodegen
471/// have run).
472fn ios_params_from(m: &manifest::ResolvedManifest, project_dir: &Path) -> Result<IosParams> {
473 let i = &m.config.ios;
474 let bundle_id = i
475 .bundle_id
476 .clone()
477 .or_else(|| m.config.bundle_id.clone())
478 .ok_or_else(|| {
479 anyhow!(
480 "whisker.rs: app.ios(|i| i.bundle_id(\"…\")) or app.bundle_id(\"…\") is required for the ios target"
481 )
482 })?;
483 let scheme = i
484 .scheme
485 .clone()
486 .or_else(|| m.config.name.clone())
487 .ok_or_else(|| {
488 anyhow!(
489 "whisker.rs: app.ios(|i| i.scheme(\"…\")) or app.name(\"…\") is required for the ios target"
490 )
491 })?;
492 Ok(IosParams {
493 project_dir: project_dir.to_path_buf(),
494 scheme,
495 bundle_id,
496 device_override: std::env::var("WHISKER_IOS_SIMULATOR").ok(),
497 })
498}
499
500/// Walk up from `start` looking for a `Cargo.toml` containing a
501/// `[workspace]` section. Returns the directory holding the matching
502/// Cargo.toml, or `None` if we walk off the top of the filesystem.
503fn find_workspace_root(start: &Path) -> Option<PathBuf> {
504 // Canonicalize so the upward walk doesn't bottom out at an empty
505 // PathBuf when `start` is relative and the workspace root happens
506 // to be the process's cwd. An empty `workspace_root` later feeds
507 // `Command::current_dir("")`, which posix-spawns ENOENT and
508 // surfaces as "spawn cargo: No such file or directory".
509 let mut cur = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
510 loop {
511 let cargo = cur.join("Cargo.toml");
512 if cargo.is_file() {
513 if let Ok(txt) = std::fs::read_to_string(&cargo) {
514 if txt.contains("[workspace]") {
515 return Some(cur);
516 }
517 }
518 }
519 if !cur.pop() {
520 return None;
521 }
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::sync::atomic::{AtomicU64, Ordering};
529
530 #[test]
531 fn cli_target_maps_to_dev_server_target() {
532 assert_eq!(Target::from(CliTarget::Android), Target::Android);
533 assert_eq!(Target::from(CliTarget::Ios), Target::IosSimulator);
534 }
535
536 fn unique_tempdir() -> PathBuf {
537 static SEQ: AtomicU64 = AtomicU64::new(0);
538 let n = SEQ.fetch_add(1, Ordering::Relaxed);
539 let pid = std::process::id();
540 let p = std::env::temp_dir().join(format!("whisker-cli-run-test-{pid}-{n}"));
541 std::fs::create_dir_all(&p).unwrap();
542 p
543 }
544
545 #[test]
546 fn find_workspace_root_returns_dir_when_cargo_toml_at_start() {
547 let tmp = unique_tempdir();
548 std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
549 // Compare against the canonical form — `find_workspace_root`
550 // canonicalises its input to avoid the empty-PathBuf ENOENT
551 // (see fn docs), and on macOS `std::env::temp_dir()` returns a
552 // path under `/var/folders/...` which is a symlink to
553 // `/private/var/folders/...`.
554 let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
555 assert_eq!(
556 find_workspace_root(&tmp).as_deref(),
557 Some(canonical_tmp.as_path()),
558 );
559 std::fs::remove_dir_all(&tmp).ok();
560 }
561
562 #[test]
563 fn find_workspace_root_walks_up_from_a_member_dir() {
564 let tmp = unique_tempdir();
565 std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = [\"app\"]\n").unwrap();
566 let nested = tmp.join("app");
567 std::fs::create_dir_all(&nested).unwrap();
568 std::fs::write(
569 nested.join("Cargo.toml"),
570 "[package]\nname = \"app\"\nversion = \"0.0.0\"\n",
571 )
572 .unwrap();
573 let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
574 assert_eq!(
575 find_workspace_root(&nested).as_deref(),
576 Some(canonical_tmp.as_path()),
577 );
578 std::fs::remove_dir_all(&tmp).ok();
579 }
580}