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        //
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}