Skip to main content

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}