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 std::process::exit(0);
171 }
172}
173
174fn run_inner(
175 args: Args,
176 m: manifest::ResolvedManifest,
177 workspace_root: PathBuf,
178 target: Target,
179 tui: Option<&crate::tui::TuiHandle>,
180) -> Result<()> {
181 // Sync the native host project (gen/{android,ios}/) before doing
182 // anything else. The cargo-side `build_discovered_plugins` step
183 // happens inside `sync_for_target` and is the long pole here.
184 // `set_phase(Setup)` already fired from `run()` before we got
185 // here, so re-issuing it would duplicate the "▶ Setup" entry in
186 // scrollback.
187 //
188 // No iOS Lynx pre-fetch: `platforms/ios/Package.swift` now uses
189 // `binaryTarget(url:checksum:)`, so xcodebuild resolves the four
190 // xcframeworks via SPM during package resolution. Android pulls
191 // its aar from `whiskerrs.github.io/lynx/maven` transitively via
192 // the SDK pom. Neither path needs the workspace `target/lynx-*`
193 // tree the cli used to stage here.
194
195 let sync = crate::platforms::sync_for_target(
196 target,
197 &m.config,
198 &m.crate_dir,
199 &workspace_root,
200 &m.package,
201 )
202 .context("sync native project (gen/{android,ios}/)")?;
203 if sync.regenerated {
204 eprintln!(
205 "[whisker run] native project regenerated at {}",
206 sync.gen_dir.display(),
207 );
208 }
209
210 let android = match target {
211 Target::Android => Some(android_params_from(&m, &sync.gen_dir)?),
212 _ => None,
213 };
214 let ios = match target {
215 Target::IosSimulator => Some(ios_params_from(&m, &sync.gen_dir)?),
216 _ => None,
217 };
218
219 let watch_paths = vec![m.crate_dir.join("src"), m.crate_dir.join("whisker.rs")];
220
221 let config = Config {
222 workspace_root,
223 crate_dir: m.crate_dir,
224 package: m.package,
225 target,
226 watch_paths: watch_paths.clone(),
227 bind_addr: args.bind,
228 hot_patch_mode: if args.no_hot_patch {
229 HotPatchMode::Tier2ColdRebuild
230 } else {
231 HotPatchMode::Tier1Subsecond
232 },
233 android,
234 ios,
235 };
236
237 let watching_paths: Vec<String> = watch_paths
238 .iter()
239 .map(|p| p.display().to_string())
240 .collect();
241 if let Some(t) = tui {
242 t.set_dev_server(config.bind_addr.to_string(), watching_paths);
243 t.set_phase(crate::tui::AppPhase::Initializing);
244 }
245
246 let rt = tokio::runtime::Builder::new_multi_thread()
247 .enable_all()
248 .build()
249 .context("build tokio runtime")?;
250 let show_native_logs = args.show_native_logs;
251 let tui_for_events = tui.cloned();
252
253 let server = DevServer::new(config)?.on_event(move |e| {
254 if let Some(h) = &tui_for_events {
255 // TUI mode: the handle's `apply_event` already pushes
256 // `Event::DeviceLog` into scrollback via `insert_before`
257 // as a `[device]` / `[device:err]` row. Routing the
258 // same event through `forward_event_to_ui` would
259 // double-print every device log line (once raw, once
260 // wrapped in `whisker_build::ui::info`'s `· ` prefix
261 // and captured back through stderr). Skip the legacy
262 // path entirely when the TUI is on.
263 h.apply_event(&e);
264 } else {
265 forward_event_to_ui(e, show_native_logs);
266 }
267 });
268
269 rt.block_on(server.run())
270}
271
272/// Friendly label for the TUI header. `whisker_dev_server::Target`'s
273/// Debug impl renders `IosSimulator` which is a mouthful — pick a
274/// short noun for screen real estate.
275fn target_label(target: Target) -> &'static str {
276 match target {
277 Target::Android => "Android",
278 Target::IosSimulator => "iOS Simulator",
279 }
280}
281
282/// Translate dev-server [`Event`]s into the existing line-based UI
283/// output. Phase 2 (ratatui TUI) will replace this with a routed
284/// dispatch into per-pane state; until then, the relevant signal we
285/// need to surface is the device's own stdout/stderr — everything else
286/// is already covered by `whisker_build::ui` calls inside the dev
287/// loop.
288///
289/// When `show_native_logs` is false (the default), device lines that
290/// match [`is_native_engine_noise`] are dropped silently. The escape
291/// hatch is `whisker run --show-native-logs`.
292fn forward_event_to_ui(event: whisker_dev_server::Event, show_native_logs: bool) {
293 use whisker_dev_server::Event;
294 if let Event::DeviceLog {
295 stream,
296 line,
297 ts_micros: _,
298 } = event
299 {
300 if !show_native_logs && is_native_engine_noise(&line) {
301 return;
302 }
303 // Short `[device]` / `[device:err]` prefix keeps the column
304 // alignment compact next to `whisker-build::ui::info`'s own
305 // output. The Phase-2 TUI can surface stream / timestamp /
306 // colour separately.
307 let tag = match stream.as_str() {
308 "stderr" => "device:err",
309 _ => "device",
310 };
311 whisker_build::ui::info(format!("[{tag}] {line}"));
312 }
313}
314
315/// Identify lines that come from the Lynx C++ engine's debug stderr
316/// rather than the user's own Rust code. Lynx's Skia/GL backend
317/// prints per-program attribute-binding traces (`s_glBindAttribLocation:
318/// bind attrib N name X`) on every frame draw and a handful of other
319/// engine-internal log lines that are not actionable from app code.
320///
321/// The filter intentionally errs toward letting unknown lines through
322/// — these patterns are bounded to specific known-noisy Lynx prefixes,
323/// so genuine error output and user `eprintln!`s are never silenced.
324fn is_native_engine_noise(line: &str) -> bool {
325 let t = line.trim_start();
326 // Lynx Skia / GL trace prefixes. The `s_gl<CamelCase>(` form is
327 // distinctive — Skia internals only — and shows up dozens of
328 // times per frame on first paint.
329 const LYNX_NOISE_PREFIXES: &[&str] = &[
330 "s_glBindAttribLocation:",
331 "s_glGetUniformLocation:",
332 "s_glGetAttribLocation:",
333 ];
334 for prefix in LYNX_NOISE_PREFIXES {
335 if t.starts_with(prefix) {
336 return true;
337 }
338 }
339 false
340}
341
342#[cfg(test)]
343mod device_log_filter_tests {
344 use super::is_native_engine_noise;
345
346 #[test]
347 fn drops_lynx_skia_bind_attrib_traces() {
348 assert!(is_native_engine_noise(
349 "s_glBindAttribLocation: bind attrib 0 name position"
350 ));
351 assert!(is_native_engine_noise(
352 "s_glBindAttribLocation: bind attrib 2 name inTextureCoords"
353 ));
354 assert!(is_native_engine_noise(
355 "s_glGetUniformLocation: query uniform u_mvp"
356 ));
357 }
358
359 #[test]
360 fn drops_indented_lynx_traces() {
361 // Belt-and-braces: native printf output sometimes lands with a
362 // leading space or tab from libc buffering.
363 assert!(is_native_engine_noise(" s_glBindAttribLocation: bind 1"));
364 assert!(is_native_engine_noise(
365 "\ts_glGetAttribLocation: query in_color"
366 ));
367 }
368
369 #[test]
370 fn preserves_user_println_output() {
371 assert!(!is_native_engine_noise("podcast: app() starting"));
372 assert!(!is_native_engine_noise("info: loaded 12 items from cache"));
373 // Even patterns that touch `gl` but aren't Lynx's known
374 // tracers should pass through — the filter list is precise
375 // by design.
376 assert!(!is_native_engine_noise("openglRenderer: skia init OK"));
377 assert!(!is_native_engine_noise(
378 "warning: glsl shader compilation took 42ms"
379 ));
380 }
381
382 #[test]
383 fn preserves_panics_and_errors() {
384 assert!(!is_native_engine_noise(
385 "thread 'main' panicked at 'index out of bounds'"
386 ));
387 assert!(!is_native_engine_noise("error: failed to parse JSON"));
388 }
389}
390
391/// Build [`AndroidParams`] from the resolved manifest. Returns an
392/// error if the user's `whisker.rs` left required fields (like the
393/// `applicationId`) unset.
394///
395/// `project_dir` is the *generated* Gradle project under
396/// `gen/android/` — `whisker-cng` writes the tree, this function just
397/// stitches in the `applicationId` + launcher activity for installer
398/// use.
399fn android_params_from(
400 m: &manifest::ResolvedManifest,
401 project_dir: &Path,
402) -> Result<AndroidParams> {
403 let a = &m.config.android;
404 let application_id = a
405 .application_id
406 .clone()
407 .or_else(|| m.config.bundle_id.clone())
408 .ok_or_else(|| {
409 anyhow!(
410 "whisker.rs: app.android(|a| a.application_id(\"…\")) is required for the android target"
411 )
412 })?;
413 let launcher_activity = a
414 .launcher_activity
415 .clone()
416 .unwrap_or_else(|| ".MainActivity".into());
417 Ok(AndroidParams {
418 project_dir: project_dir.to_path_buf(),
419 application_id,
420 launcher_activity,
421 // Single-ABI dev loops only — multi-ABI is a release concern.
422 abi: "arm64-v8a".into(),
423 })
424}
425
426/// Build [`IosParams`] from the resolved manifest. `project_dir` is
427/// the generated `gen/ios/` tree (after `whisker-cng` + xcodegen
428/// have run).
429fn ios_params_from(m: &manifest::ResolvedManifest, project_dir: &Path) -> Result<IosParams> {
430 let i = &m.config.ios;
431 let bundle_id = i
432 .bundle_id
433 .clone()
434 .or_else(|| m.config.bundle_id.clone())
435 .ok_or_else(|| {
436 anyhow!(
437 "whisker.rs: app.ios(|i| i.bundle_id(\"…\")) or app.bundle_id(\"…\") is required for the ios target"
438 )
439 })?;
440 let scheme = i
441 .scheme
442 .clone()
443 .or_else(|| m.config.name.clone())
444 .ok_or_else(|| {
445 anyhow!(
446 "whisker.rs: app.ios(|i| i.scheme(\"…\")) or app.name(\"…\") is required for the ios target"
447 )
448 })?;
449 Ok(IosParams {
450 project_dir: project_dir.to_path_buf(),
451 scheme,
452 bundle_id,
453 device_override: std::env::var("WHISKER_IOS_SIMULATOR").ok(),
454 })
455}
456
457/// Walk up from `start` looking for a `Cargo.toml` containing a
458/// `[workspace]` section. Returns the directory holding the matching
459/// Cargo.toml, or `None` if we walk off the top of the filesystem.
460fn find_workspace_root(start: &Path) -> Option<PathBuf> {
461 // Canonicalize so the upward walk doesn't bottom out at an empty
462 // PathBuf when `start` is relative and the workspace root happens
463 // to be the process's cwd. An empty `workspace_root` later feeds
464 // `Command::current_dir("")`, which posix-spawns ENOENT and
465 // surfaces as "spawn cargo: No such file or directory".
466 let mut cur = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
467 loop {
468 let cargo = cur.join("Cargo.toml");
469 if cargo.is_file() {
470 if let Ok(txt) = std::fs::read_to_string(&cargo) {
471 if txt.contains("[workspace]") {
472 return Some(cur);
473 }
474 }
475 }
476 if !cur.pop() {
477 return None;
478 }
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use std::sync::atomic::{AtomicU64, Ordering};
486
487 #[test]
488 fn cli_target_maps_to_dev_server_target() {
489 assert_eq!(Target::from(CliTarget::Android), Target::Android);
490 assert_eq!(Target::from(CliTarget::Ios), Target::IosSimulator);
491 }
492
493 fn unique_tempdir() -> PathBuf {
494 static SEQ: AtomicU64 = AtomicU64::new(0);
495 let n = SEQ.fetch_add(1, Ordering::Relaxed);
496 let pid = std::process::id();
497 let p = std::env::temp_dir().join(format!("whisker-cli-run-test-{pid}-{n}"));
498 std::fs::create_dir_all(&p).unwrap();
499 p
500 }
501
502 #[test]
503 fn find_workspace_root_returns_dir_when_cargo_toml_at_start() {
504 let tmp = unique_tempdir();
505 std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
506 // Compare against the canonical form — `find_workspace_root`
507 // canonicalises its input to avoid the empty-PathBuf ENOENT
508 // (see fn docs), and on macOS `std::env::temp_dir()` returns a
509 // path under `/var/folders/...` which is a symlink to
510 // `/private/var/folders/...`.
511 let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
512 assert_eq!(
513 find_workspace_root(&tmp).as_deref(),
514 Some(canonical_tmp.as_path()),
515 );
516 std::fs::remove_dir_all(&tmp).ok();
517 }
518
519 #[test]
520 fn find_workspace_root_walks_up_from_a_member_dir() {
521 let tmp = unique_tempdir();
522 std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = [\"app\"]\n").unwrap();
523 let nested = tmp.join("app");
524 std::fs::create_dir_all(&nested).unwrap();
525 std::fs::write(
526 nested.join("Cargo.toml"),
527 "[package]\nname = \"app\"\nversion = \"0.0.0\"\n",
528 )
529 .unwrap();
530 let canonical_tmp = std::fs::canonicalize(&tmp).unwrap();
531 assert_eq!(
532 find_workspace_root(&nested).as_deref(),
533 Some(canonical_tmp.as_path()),
534 );
535 std::fs::remove_dir_all(&tmp).ok();
536 }
537}