From 590910355e0861fc8cbb3d745c88fb551f74f813 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 10:40:23 +0200 Subject: [PATCH 1/9] xtask: add record-demo subcommand (v0) The README's demo/csshw.gif is currently re-recorded by hand each time it drifts from the product, requiring a developer to manually configure their workstation (wallpaper, fake SSH hosts, keystroke overlay, ScreenToGif). This commit lays the foundation for "demo as code" - a typed Rust DSL describing the demo plus an xtask subcommand that runs it. This is v0 of a four-stage plan: a local-only proof that produces target/demo/csshw.gif on the developer's own desktop. v1 adds Windows Sandbox isolation + Carnac overlay + visual normalisation; v2 adds CI workflows + an append-only demo-assets orphan branch with SHA-pinned filenames; v3 adds the chord/Press DSL primitive plus the full canonical scene. Architecture mirrors the existing xtask modules (e.g. social_preview.rs): - DemoSystem trait + RealSystem behind which all side effects (windows input synthesis, fs, subprocess) sit so unit tests exercise the driver via mockall with zero real-system effects. - Closed Step enum + Script builder validating capture pairing and regex compilation at build time, so a typo in the script fails cargo check rather than a half-completed recording. - Driver interprets steps via the trait; the in-flight ffmpeg capture is cleaned up even when a step errors mid-script. - config_override writes target/demo/csshw-config.toml, a dispatcher.bat that strips an optional user@ prefix from csshw's USERNAME_AT_HOST substitution, and per-host enter.bat scripts with curated home directories. - env/local.rs copies csshw.exe into target/demo/ so csshw's startup set_current_dir(exe_dir) lands on our config (csshw rebases its cwd at startup, so a plain cwd-based override does not work). - terminate_csshw kills the daemon child and best-effort kills any CREATE_NEW_CONSOLE-detached client csshw.exe instances. v0 requires ffmpeg and gifski on PATH; v1 will vendor them as SHA-pinned binaries downloaded into target/demo/bin/. The full plan lives at C:/Users/whme/.claude/plans/tranquil-hopping-karp.md. Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 40 +++ xtask/Cargo.toml | 14 + xtask/src/demo/config_override.rs | 158 +++++++++ xtask/src/demo/driver.rs | 220 +++++++++++++ xtask/src/demo/dsl.rs | 227 +++++++++++++ xtask/src/demo/env/local.rs | 92 ++++++ xtask/src/demo/env/mod.rs | 8 + xtask/src/demo/mod.rs | 329 +++++++++++++++++++ xtask/src/demo/recorder.rs | 145 ++++++++ xtask/src/demo/script.rs | 44 +++ xtask/src/demo/windows_input.rs | 236 +++++++++++++ xtask/src/main.rs | 31 ++ xtask/src/tests/test_demo_config_override.rs | 180 ++++++++++ xtask/src/tests/test_demo_driver.rs | 268 +++++++++++++++ xtask/src/tests/test_demo_dsl.rs | 147 +++++++++ xtask/src/tests/test_demo_mod.rs | 37 +++ xtask/src/tests/test_demo_script.rs | 28 ++ 17 files changed, 2204 insertions(+) create mode 100644 xtask/src/demo/config_override.rs create mode 100644 xtask/src/demo/driver.rs create mode 100644 xtask/src/demo/dsl.rs create mode 100644 xtask/src/demo/env/local.rs create mode 100644 xtask/src/demo/env/mod.rs create mode 100644 xtask/src/demo/mod.rs create mode 100644 xtask/src/demo/recorder.rs create mode 100644 xtask/src/demo/script.rs create mode 100644 xtask/src/demo/windows_input.rs create mode 100644 xtask/src/tests/test_demo_config_override.rs create mode 100644 xtask/src/tests/test_demo_driver.rs create mode 100644 xtask/src/tests/test_demo_dsl.rs create mode 100644 xtask/src/tests/test_demo_mod.rs create mode 100644 xtask/src/tests/test_demo_script.rs diff --git a/Cargo.lock b/Cargo.lock index 55dab27d..c65ea19f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1246,6 +1255,35 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "registry" version = "1.3.0" @@ -2748,8 +2786,10 @@ dependencies = [ "anyhow", "clap", "mockall", + "regex", "semver", "toml_edit 0.21.1", + "windows 0.59.0", ] [[package]] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 62537353..0b2cbdb9 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -14,6 +14,20 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } toml_edit = "0.21" semver = "1.0" +# Demo subcommand (record-demo): regex-based window matching. +regex = "1" + +# Demo subcommand: Windows input synthesis (SendInput) and window +# enumeration. Pinned to the same major version csshw_lib uses +# (Cargo.toml at the workspace root) so we share a compiled copy. +[target.'cfg(windows)'.dependencies.windows] +version = "0.59.0" +features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] [dev-dependencies] mockall = "0.13" diff --git a/xtask/src/demo/config_override.rs b/xtask/src/demo/config_override.rs new file mode 100644 index 00000000..241e8d30 --- /dev/null +++ b/xtask/src/demo/config_override.rs @@ -0,0 +1,158 @@ +//! Generate a demo-only `csshw-config.toml` and per-host fake homes. +//! +//! csshw rebases its cwd to its own `exe_dir` at startup +//! (`src/cli.rs:548`), so the demo flow copies `csshw.exe` into +//! `/csshw.exe` and writes the config alongside it. With +//! that layout, `[client]` overrides `program = "ssh"` to a local +//! `cmd.exe` that opens a "fake host" - no real sshd, no mutation of +//! the developer's real config. +//! +//! csshw's `username_host_placeholder` substitutes `@` +//! into `[client] arguments`. To avoid pinning our directory layout +//! to that exact format (and to handle the empty-username case +//! gracefully), every host routes through a single +//! `/dispatcher.bat` that strips the optional `@` +//! prefix and dispatches to the per-host `enter.bat`. +//! +//! Each host gets a `/fakehosts//enter.bat` that sets +//! a readable prompt and `cd`s into a per-host home directory with a +//! curated set of files. Differences between hosts are what makes the +//! demo interesting (e.g. `secret.txt` only on `charlie`). + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use super::DemoSystem; + +/// File contents shared by every fake host, keyed by relative path +/// within the home directory. v0 is intentionally minimal. +const SHARED_HOME_FILES: &[(&str, &str)] = &[( + "README.txt", + "csshw demo - shared file present on every host.\r\n", +)]; + +/// Files unique to a specific host, keyed by host name. The inner +/// tuples are `(relative path, contents)`. +const HOST_SPECIFIC_FILES: &[(&str, &[(&str, &str)])] = + &[("charlie", &[("secret.txt", "charlie-only payload\r\n")])]; + +/// Result of [`generate`]: paths the caller passes back into the +/// driver and (for `csshw_cwd`) into [`DemoSystem::spawn_csshw`]. +pub struct OverrideLayout { + /// Directory containing `csshw.exe`, `csshw-config.toml`, the + /// `dispatcher.bat`, and the `fakehosts/` subtree. csshw is + /// launched from here (cwd-rebased to here by csshw itself). + pub csshw_cwd: PathBuf, +} + +/// Write the demo's csshw config, dispatcher, and per-host fake +/// homes. +/// +/// # Arguments +/// +/// * `system` - file IO is delegated through [`DemoSystem`] so unit +/// tests can mock it out. +/// * `demo_root` - parent directory; the function writes +/// `demo_root/csshw-config.toml`, `demo_root/dispatcher.bat`, and +/// `demo_root/fakehosts//...`. +/// * `hosts` - list of bare host names (no `user@` prefix). +/// +/// # Returns +/// +/// An [`OverrideLayout`] whose `csshw_cwd` is `demo_root`. +pub fn generate( + system: &S, + demo_root: &Path, + hosts: &[&str], +) -> Result { + system.ensure_dir(demo_root)?; + let fakehosts = demo_root.join("fakehosts"); + for host in hosts { + let home = fakehosts.join(host); + system.ensure_dir(&home)?; + for (rel, content) in SHARED_HOME_FILES { + system.write_file(&home.join(rel), content)?; + } + for (h, files) in HOST_SPECIFIC_FILES { + if h == host { + for (rel, content) in *files { + system.write_file(&home.join(rel), content)?; + } + } + } + let bat = home.join("enter.bat"); + system.write_file(&bat, &enter_bat(host, &home))?; + } + let dispatcher = demo_root.join("dispatcher.bat"); + system.write_file(&dispatcher, dispatcher_bat())?; + let toml = render_toml(&dispatcher); + system.write_file(&demo_root.join("csshw-config.toml"), &toml)?; + Ok(OverrideLayout { + csshw_cwd: demo_root.to_path_buf(), + }) +} + +/// Build the per-host `enter.bat` that the dispatcher invokes. +/// +/// `@echo off` keeps the output free of cmd-echo lines, then we set a +/// readable prompt (`-fake $$`) and `cd` into the host's home +/// directory. The trailing `cls` clears the cmd-launch banner so the +/// recording starts on a clean console. +fn enter_bat(host: &str, home: &Path) -> String { + format!( + "@echo off\r\nset PROMPT=$_{host}@{host}-fake $$ \r\ncd /d \"{home}\"\r\ncls\r\n", + host = host, + home = home.display(), + ) +} + +/// Returns the static `dispatcher.bat` body. +/// +/// The dispatcher is invoked by csshw with one argument: the +/// substituted `{{USERNAME_AT_HOST}}`, which is either `user@host` +/// (when csshw's username is set) or just `@host` (when it is not), +/// or just `host` (when the host arg already includes the user +/// prefix or no user is involved). The dispatcher normalises all +/// three to the bare host so we can keep fakehost directories +/// simply named (`alpha`, not `@alpha`). +/// +/// Implementation note: we use cmd's `:*@=` substring substitution, +/// not `for /f tokens=2 delims=@`. The `for /f` form skips leading +/// delimiters - it parses `@alpha` as a single token (`alpha`), so +/// `tokens=2` matches nothing and `HOST` keeps its initial +/// `@alpha` value, leading to "the system cannot find the path +/// specified" when `call` falls through to a non-existent +/// `fakehosts\@alpha\enter.bat`. The substring form has no such +/// quirk: it strips through the first `@` if present, otherwise +/// leaves the value unchanged. +fn dispatcher_bat() -> &'static str { + "@echo off\r\n\ + setlocal enabledelayedexpansion\r\n\ + set ARG=%~1\r\n\ + set HOST=!ARG!\r\n\ + if not \"!HOST:@=!\"==\"!HOST!\" set HOST=!HOST:*@=!\r\n\ + call \"%~dp0fakehosts\\!HOST!\\enter.bat\"\r\n" +} + +/// Build the TOML body that overrides `[client]` to spawn cmd.exe via +/// the dispatcher. We leave `[daemon]` and `[clusters]` to csshw's +/// own defaults (the demo passes hosts on the command line). +fn render_toml(dispatcher: &Path) -> String { + // Backslashes are doubled because TOML basic strings interpret + // them as escapes. The dispatcher is the single entry point; + // csshw substitutes `{{USERNAME_AT_HOST}}` as its argument. + let dispatcher_str = dispatcher.display().to_string().replace('\\', "\\\\"); + format!( + "# Auto-generated by `cargo xtask record-demo`. Do not commit.\n\ + [client]\n\ + ssh_config_path = \"\"\n\ + program = \"cmd.exe\"\n\ + arguments = [\"/k\", \"{dispatcher_str}\", \"{{{{USERNAME_AT_HOST}}}}\"]\n\ + username_host_placeholder = \"{{{{USERNAME_AT_HOST}}}}\"\n", + ) +} + +#[cfg(test)] +#[path = "../tests/test_demo_config_override.rs"] +mod tests; diff --git a/xtask/src/demo/driver.rs b/xtask/src/demo/driver.rs new file mode 100644 index 00000000..22c51ccb --- /dev/null +++ b/xtask/src/demo/driver.rs @@ -0,0 +1,220 @@ +//! Step-by-step interpreter for a built demo script. +//! +//! Takes a `&[Step]` plus a [`DemoSystem`] and walks the steps in order, +//! delegating every side effect to the system trait. The driver has a +//! tiny amount of internal state (where to write the raw capture file, +//! how many `StartCapture` we've seen) so unit tests can assert +//! capture pairing. +//! +//! # Errors +//! +//! Returns the first error encountered. Capture is best-effort cleaned +//! up: if a [`Step::StartCapture`] succeeded and a later step fails, +//! the driver still attempts [`DemoSystem::stop_recording`] before +//! returning to avoid leaving an ffmpeg child orphaned. The cleanup +//! error, if any, is logged via [`DemoSystem::print_debug`] and the +//! original error is propagated. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use regex::Regex; + +use super::{dsl::Step, DemoSystem}; + +/// Polling interval used by [`Step::WaitForWindow`] between +/// `enum_windows` calls. Short enough to be responsive, long enough not +/// to spin the CPU. +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Run `steps` against `system`. +/// +/// # Arguments +/// +/// * `system` - implementation of [`DemoSystem`]. +/// * `steps` - the script's pre-validated steps. +/// * `out_gif` - final GIF path; the raw `.mkv` is derived by replacing +/// the extension with `.mkv` (so both files live alongside each +/// other under `target/demo/`). +/// * `no_record` - when true, [`Step::StartCapture`] / +/// [`Step::StopCapture`] are logged and skipped. Useful for +/// iterating on the script without spawning ffmpeg. +pub fn run( + system: &S, + steps: &[Step], + out_gif: &Path, + no_record: bool, +) -> Result<()> { + let raw_path = derive_raw_path(out_gif); + let mut state = DriverState::new(raw_path, no_record); + let mut deferred: Option = None; + for (i, step) in steps.iter().enumerate() { + if let Err(e) = run_step(system, &mut state, i, step) { + deferred = Some(e); + break; + } + } + // Best-effort cleanup if a capture was left running. + if state.capturing { + if let Err(e) = system.stop_recording(&state.raw_path, out_gif) { + system.print_debug(&format!("cleanup stop_recording failed: {e}")); + } else { + state.capturing = false; + } + } + if let Some(e) = deferred { + return Err(e); + } + Ok(()) +} + +/// Driver-internal state. +struct DriverState { + raw_path: PathBuf, + no_record: bool, + capturing: bool, +} + +impl DriverState { + fn new(raw_path: PathBuf, no_record: bool) -> Self { + Self { + raw_path, + no_record, + capturing: false, + } + } +} + +/// Replace the extension of `gif_path` with `.mkv` to get the raw +/// capture path. If `gif_path` has no extension, append `.mkv`. +fn derive_raw_path(gif_path: &Path) -> PathBuf { + let mut p = gif_path.to_path_buf(); + p.set_extension("mkv"); + p +} + +/// Dispatch a single step. +fn run_step( + system: &S, + state: &mut DriverState, + index: usize, + step: &Step, +) -> Result<()> { + system.print_debug(&format!("step {index}: {step:?}")); + match step { + Step::WaitForWindow { + title_regex, + timeout, + stable_for, + } => wait_for_window(system, title_regex, *timeout, *stable_for) + .with_context(|| format!("step {index}: WaitForWindow {title_regex:?}")), + Step::Focus { title_regex } => focus(system, title_regex) + .with_context(|| format!("step {index}: Focus {title_regex:?}")), + Step::Type { + text, + per_char_delay, + } => { + type_text(system, text, *per_char_delay).with_context(|| format!("step {index}: Type")) + } + Step::Sleep(d) => { + system.sleep(*d); + Ok(()) + } + Step::StartCapture => { + if state.no_record { + system.print_info(&format!("step {index}: StartCapture skipped (--no-record)")); + return Ok(()); + } + system.start_recording(&state.raw_path)?; + state.capturing = true; + Ok(()) + } + Step::StopCapture => { + if state.no_record { + system.print_info(&format!("step {index}: StopCapture skipped (--no-record)")); + return Ok(()); + } + // The final GIF path is `raw_path` with `.gif` extension. + let gif_path = state.raw_path.with_extension("gif"); + system.stop_recording(&state.raw_path, &gif_path)?; + state.capturing = false; + Ok(()) + } + Step::Marker(m) => { + system.print_info(&format!("marker: {m}")); + Ok(()) + } + } +} + +/// Block until a window matching `title_regex` has been visible with +/// the same rect for at least `stable_for`. Polls every +/// [`POLL_INTERVAL`]. +fn wait_for_window( + system: &S, + title_regex: &str, + timeout: Duration, + stable_for: Duration, +) -> Result<()> { + let re = + Regex::new(title_regex).with_context(|| format!("invalid title_regex {title_regex:?}"))?; + let deadline = Instant::now() + timeout; + let mut stable_since: Option<(u64, super::WindowRect, Instant)> = None; + loop { + let windows = system.enum_windows()?; + if let Some(w) = windows.into_iter().find(|w| re.is_match(&w.title)) { + match stable_since { + Some((hwnd, rect, since)) + if hwnd == w.hwnd && rect == w.rect && since.elapsed() >= stable_for => + { + return Ok(()); + } + Some((hwnd, rect, _)) if hwnd == w.hwnd && rect == w.rect => { + // Still stabilising; fall through to sleep. + } + _ => { + stable_since = Some((w.hwnd, w.rect, Instant::now())); + } + } + } else { + stable_since = None; + } + if Instant::now() >= deadline { + bail!("no window matching {title_regex:?} stabilised within {timeout:?}"); + } + system.sleep(POLL_INTERVAL); + } +} + +/// Bring the first window matching `title_regex` to the foreground. +fn focus(system: &S, title_regex: &str) -> Result<()> { + let re = + Regex::new(title_regex).with_context(|| format!("invalid title_regex {title_regex:?}"))?; + let windows = system.enum_windows()?; + let target = windows + .into_iter() + .find(|w| re.is_match(&w.title)) + .ok_or_else(|| anyhow::anyhow!("no window matching {title_regex:?}"))?; + system.set_foreground(target.hwnd) +} + +/// Type `text` one character at a time. Newlines (`\n`, `\r`) are sent +/// as VK_RETURN so they actually submit a command in cmd.exe instead +/// of inserting a literal control character. +fn type_text(system: &S, text: &str, per_char_delay: Duration) -> Result<()> { + /// Windows `VK_RETURN` virtual-key code. + const VK_RETURN: u16 = 0x0D; + for c in text.chars() { + match c { + '\n' | '\r' => system.send_vk(VK_RETURN)?, + other => system.send_unicode_char(other)?, + } + system.sleep(per_char_delay); + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/test_demo_driver.rs"] +mod tests; diff --git a/xtask/src/demo/dsl.rs b/xtask/src/demo/dsl.rs new file mode 100644 index 00000000..cce2ebdf --- /dev/null +++ b/xtask/src/demo/dsl.rs @@ -0,0 +1,227 @@ +//! Typed "demo as code" DSL. +//! +//! A demo is a `Vec` produced by the [`Script`] builder. Each +//! variant of [`Step`] is interpreted by [`crate::demo::driver`] in +//! declaration order. The DSL is intentionally a closed enum so a typo +//! in a script (an unknown step name, an invalid regex, an unbalanced +//! `start_capture` / `stop_capture` pair) fails to compile or fails the +//! `build` validation pass - never at recording time, after the +//! developer has already burned a 30-second capture. +//! +//! See [`crate::demo::script::build_canonical_v0`] for the demo we ship. + +use std::time::Duration; + +use anyhow::{anyhow, bail, Result}; +use regex::Regex; + +/// Default per-character delay for [`Step::Type`] when a script does not +/// specify one. Slow enough that the recording is legible, fast enough +/// that a multi-line `Type` step does not pad the GIF. +pub const DEFAULT_PER_CHAR_DELAY: Duration = Duration::from_millis(50); + +/// Default timeout for [`Step::WaitForWindow`] when a script does not +/// specify one. Generous: window creation includes csshw spawning a +/// fresh `cmd.exe` per host plus its own daemon initialisation. +pub const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Default "stable for" window for [`Step::WaitForWindow`]. A window +/// counts as ready only when its rect has been unchanged for this long; +/// guards against typing into a freshly-spawned console that is still +/// being repositioned by csshw's daemon-side layout. +pub const DEFAULT_WAIT_STABLE_FOR: Duration = Duration::from_millis(500); + +/// A single deterministic action in the demo timeline. +/// +/// Steps are interpreted top-down. None of them carry implicit side +/// effects across step boundaries; the driver state machine does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + /// Block until a top-level window whose title matches `title_regex` + /// has been visible with a stable rect for `stable_for`. Fails if + /// no such window appears within `timeout`. + WaitForWindow { + /// Regex applied to each top-level window's title. + title_regex: String, + /// Hard deadline for the whole wait. + timeout: Duration, + /// How long the window's rect must be unchanged before the + /// step counts as satisfied. Guards against typing into a + /// console that csshw is still repositioning. + stable_for: Duration, + }, + /// Bring the matching window to the foreground. The driver applies + /// the standard `AttachThreadInput + SetForegroundWindow` workaround + /// because Windows blocks `SetForegroundWindow` from background + /// processes. + Focus { + /// Regex applied to each top-level window's title. + title_regex: String, + }, + /// Type `text` into the foreground window, one character at a time + /// via `SendInput(KEYEVENTF_UNICODE)`. Newlines are translated to + /// VK_RETURN so they actually submit a command in cmd.exe. + Type { + /// The literal text to type. + text: String, + /// Delay between successive characters. + per_char_delay: Duration, + }, + /// Static pause. Use sparingly; prefer [`Step::WaitForWindow`]. + Sleep(Duration), + /// Start ffmpeg's gdigrab capture. Must appear exactly once and + /// before [`Step::StopCapture`]. + StartCapture, + /// Stop ffmpeg's gdigrab capture and run the post-encode pipeline + /// (frame extraction + gifski). Must appear exactly once and after + /// [`Step::StartCapture`]. + StopCapture, + /// Free-form annotation emitted to the run trace. No side effects. + Marker(String), +} + +/// Validation error returned by [`Script::build`]. +/// +/// The error carries a human-readable message describing the problem. +/// We rely on `anyhow::Error` to bubble these up to `main.rs`. +pub type ValidationError = anyhow::Error; + +/// Builder for a [`Vec`]. +/// +/// Methods take `&mut self` and return `&mut Self` so script files read +/// top-to-bottom. Defaults for delays come from the `DEFAULT_*` +/// constants in this module; the `*_with` variants accept an explicit +/// override. +pub struct Script { + name: String, + steps: Vec, +} + +impl Script { + /// Start a new script with the given human-readable name. + /// + /// The name is included in the validation error messages so a + /// failing build pinpoints which script is broken. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + steps: Vec::new(), + } + } + + /// Append a [`Step::WaitForWindow`] using the default timeout and + /// stability window. + pub fn wait_for(&mut self, title_regex: &str) -> &mut Self { + self.wait_for_with(title_regex, DEFAULT_WAIT_TIMEOUT, DEFAULT_WAIT_STABLE_FOR) + } + + /// Append a [`Step::WaitForWindow`] with explicit timeouts. + pub fn wait_for_with( + &mut self, + title_regex: &str, + timeout: Duration, + stable_for: Duration, + ) -> &mut Self { + self.steps.push(Step::WaitForWindow { + title_regex: title_regex.to_string(), + timeout, + stable_for, + }); + self + } + + /// Append a [`Step::Focus`]. + pub fn focus(&mut self, title_regex: &str) -> &mut Self { + self.steps.push(Step::Focus { + title_regex: title_regex.to_string(), + }); + self + } + + /// Append a [`Step::Type`] using the default per-character delay. + pub fn type_text(&mut self, text: &str) -> &mut Self { + self.type_text_with(text, DEFAULT_PER_CHAR_DELAY) + } + + /// Append a [`Step::Type`] with an explicit per-character delay. + pub fn type_text_with(&mut self, text: &str, per_char_delay: Duration) -> &mut Self { + self.steps.push(Step::Type { + text: text.to_string(), + per_char_delay, + }); + self + } + + /// Append a [`Step::Sleep`] expressed in milliseconds. + pub fn sleep_ms(&mut self, ms: u64) -> &mut Self { + self.steps.push(Step::Sleep(Duration::from_millis(ms))); + self + } + + /// Append [`Step::StartCapture`]. + pub fn start_capture(&mut self) -> &mut Self { + self.steps.push(Step::StartCapture); + self + } + + /// Append [`Step::StopCapture`]. + pub fn stop_capture(&mut self) -> &mut Self { + self.steps.push(Step::StopCapture); + self + } + + /// Append a [`Step::Marker`]. + pub fn marker(&mut self, m: impl Into) -> &mut Self { + self.steps.push(Step::Marker(m.into())); + self + } + + /// Validate and finalise the script. + /// + /// # Errors + /// + /// Returns an error when: + /// - any `title_regex` is not a valid regex, + /// - `StartCapture` and `StopCapture` are not each present exactly + /// once, + /// - `StopCapture` precedes `StartCapture`. + pub fn build(self) -> Result, ValidationError> { + let mut start_idx: Option = None; + let mut stop_idx: Option = None; + for (i, step) in self.steps.iter().enumerate() { + match step { + Step::WaitForWindow { title_regex, .. } | Step::Focus { title_regex } => { + Regex::new(title_regex).map_err(|e| { + anyhow!("step {i}: invalid title_regex {:?} - {e}", title_regex) + })?; + } + Step::StartCapture => { + if start_idx.is_some() { + bail!("StartCapture appears more than once (second at step {i})"); + } + start_idx = Some(i); + } + Step::StopCapture => { + if stop_idx.is_some() { + bail!("StopCapture appears more than once (second at step {i})"); + } + stop_idx = Some(i); + } + _ => {} + } + } + match (start_idx, stop_idx) { + (None, _) => bail!("script {:?} is missing StartCapture", self.name), + (_, None) => bail!("script {:?} is missing StopCapture", self.name), + (Some(s), Some(t)) if t <= s => { + bail!("StopCapture (step {t}) precedes StartCapture (step {s})") + } + _ => {} + } + Ok(self.steps) + } +} + +#[cfg(test)] +#[path = "../tests/test_demo_dsl.rs"] +mod tests; diff --git a/xtask/src/demo/env/local.rs b/xtask/src/demo/env/local.rs new file mode 100644 index 00000000..b6744ba3 --- /dev/null +++ b/xtask/src/demo/env/local.rs @@ -0,0 +1,92 @@ +//! Local environment provider: run the demo on the caller's own +//! interactive desktop session. +//! +//! v0's smallest reviewable provider. There is no isolation, no +//! wallpaper normalisation, and no Carnac. The caller is expected to +//! launch the command and step away while the demo records. +//! Sandbox-based isolation arrives in v1. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::demo::{config_override, driver, dsl::Step, DemoSystem}; + +/// Hosts the v0 canonical script launches csshw with. Kept here (not +/// in `script.rs`) because `config_override::generate` needs them too, +/// and it is the env layer that owns the demo-tree on disk. +pub const V0_HOSTS: &[&str] = &["alpha", "bravo"]; + +/// Prepare and run the demo on the local desktop. +/// +/// Sets up `target/demo/` (config, dispatcher, fake homes), copies the +/// pre-built `csshw.exe` into it (so csshw's startup +/// `set_current_dir(exe_dir)` lands on our config rather than the +/// developer's real one), launches csshw, runs the driver, and +/// terminates csshw on exit. +/// +/// # Arguments +/// +/// * `system` - the [`DemoSystem`]. +/// * `steps` - validated steps from [`crate::demo::dsl::Script::build`]. +/// * `out_gif` - desired GIF path. +/// * `no_record` - forwarded to the driver; skips capture for fast +/// script iteration. +pub fn run( + system: &S, + steps: &[Step], + out_gif: &Path, + no_record: bool, +) -> Result<()> { + let workspace = system.workspace_root()?; + let demo_root = workspace.join("target").join("demo"); + system.ensure_dir(&demo_root)?; + let layout = config_override::generate(system, &demo_root, V0_HOSTS)?; + system.print_info(&format!( + "local env: prepared {} fake hosts under {}", + V0_HOSTS.len(), + layout.csshw_cwd.display(), + )); + + // Copy csshw.exe into the demo directory. csshw rebases its cwd + // to its own exe_dir on startup (src/cli.rs:548), so the config + // we just wrote is only picked up if csshw runs from there. + let source_exe = locate_csshw_exe(&workspace)?; + let demo_exe = layout.csshw_cwd.join("csshw.exe"); + system.copy_file(&source_exe, &demo_exe)?; + + let host_args: Vec = V0_HOSTS.iter().map(|h| (*h).to_string()).collect(); + system.print_info(&format!( + "local env: launching {} {}", + demo_exe.display(), + host_args.join(" "), + )); + system.spawn_csshw(&demo_exe, &host_args, &layout.csshw_cwd)?; + + let driver_result = driver::run(system, steps, out_gif, no_record); + + // Always attempt cleanup, regardless of driver outcome. + if let Err(e) = system.terminate_csshw() { + system.print_debug(&format!("terminate_csshw failed: {e}")); + } + + driver_result +} + +/// Locate a built csshw.exe under the workspace's `target/` directory. +/// +/// Prefers a release build (smaller, no debug overhead) and falls back +/// to debug. v0 fails loudly if neither exists; v1 will offer to build +/// it for the caller. +fn locate_csshw_exe(workspace: &Path) -> Result { + for profile in ["release", "debug"] { + let candidate = workspace.join("target").join(profile).join("csshw.exe"); + if candidate.exists() { + return Ok(candidate); + } + } + anyhow::bail!( + "could not find csshw.exe under target/release or target/debug. \ + Run `cargo build --release` first." + ) +} diff --git a/xtask/src/demo/env/mod.rs b/xtask/src/demo/env/mod.rs new file mode 100644 index 00000000..ad8bdd62 --- /dev/null +++ b/xtask/src/demo/env/mod.rs @@ -0,0 +1,8 @@ +//! Per-environment glue for `record-demo`. +//! +//! Each submodule is responsible for preparing the recording +//! environment (config override, fake homes, optional desktop +//! normalisation) and then handing control to +//! [`crate::demo::driver::run`]. v0 ships only [`local`]. + +pub mod local; diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs new file mode 100644 index 00000000..bba8c71e --- /dev/null +++ b/xtask/src/demo/mod.rs @@ -0,0 +1,329 @@ +//! Automated demo recording (`record-demo` xtask subcommand). +//! +//! This module turns the README's `demo/csshw.gif` from a hand-recorded +//! artifact into a reproducible build output. A typed Rust DSL +//! ([`dsl::Step`]) describes the demo as an ordered list of actions +//! (launch, wait-for-window, focus, type, sleep, start/stop capture); +//! the [`driver`] interprets it against a [`DemoSystem`] that abstracts +//! every side effect (Windows input synthesis, filesystem writes, +//! subprocess spawning, sleeps). Tests mock [`DemoSystem`] to assert +//! step semantics with zero real-system effects. +//! +//! v0 scope: a single `--env local` provider that runs on the caller's +//! own desktop (no isolation) and a hard-coded canonical script that +//! launches `csshw alpha bravo`, types a broadcast command, and stops. +//! Sandbox + Carnac + visual normalisation arrive in v1; CI workflows +//! and the orphan-branch publish flow arrive in v2; the full +//! control-mode + vim + ping scene arrives in v3. + +#![cfg_attr(coverage_nightly, coverage(off))] + +pub mod config_override; +pub mod driver; +pub mod dsl; +pub mod env; +pub mod recorder; +pub mod script; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Result; +use clap::ValueEnum; + +/// Supported environment providers for `record-demo`. +/// +/// Each variant maps to a module under [`env`] that is responsible for +/// preparing the recording environment (writing csshw config, building +/// a fake-host home tree, optionally normalising the desktop) and then +/// invoking the shared [`driver`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum DemoEnv { + /// Run on the caller's own interactive desktop session. v0 default. + /// No isolation - the caller is expected to step away while the + /// demo records. + Local, +} + +/// One top-level window snapshot returned by [`DemoSystem::enum_windows`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowInfo { + /// Opaque handle. We model `HWND` as `u64` so the trait stays + /// portable across platforms; the production impl casts back. + pub hwnd: u64, + /// Title text as returned by `GetWindowTextW` and lossily decoded. + pub title: String, + /// Window rect in screen coordinates. + pub rect: WindowRect, +} + +/// Window bounds in screen pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowRect { + /// Left edge. + pub x: i32, + /// Top edge. + pub y: i32, + /// Width in pixels. + pub width: i32, + /// Height in pixels. + pub height: i32, +} + +/// All side effects the demo subcommand needs. +/// +/// Implemented for production by [`RealSystem`] and mocked in tests via +/// `mockall`. Following the pattern of `xtask/src/social_preview.rs`, +/// every concrete I/O call lives behind one of these methods so unit +/// tests assert behaviour without touching the real system. +/// +/// `hosts` is `&[String]` rather than `&[&str]` because mockall does +/// not handle the implicit lifetime in the latter. +pub trait DemoSystem { + /// Absolute path to the workspace root (parent of `xtask/`). + fn workspace_root(&self) -> Result; + + /// Create `path` and any missing ancestors. No-op if it exists. + fn ensure_dir(&self, path: &Path) -> Result<()>; + + /// Write `content` to `path`, creating ancestor directories. + fn write_file(&self, path: &Path, content: &str) -> Result<()>; + + /// Copy `from` to `to`, replacing any existing file. + fn copy_file(&self, from: &Path, to: &Path) -> Result<()>; + + /// Enumerate visible top-level windows. + fn enum_windows(&self) -> Result>; + + /// Bring the window identified by `hwnd` to the foreground. + /// Production impl applies the `AttachThreadInput` workaround. + fn set_foreground(&self, hwnd: u64) -> Result<()>; + + /// Synthesise a Unicode keypress for the given codepoint via + /// `SendInput(KEYEVENTF_UNICODE)`. The character lands in the + /// foreground window. + fn send_unicode_char(&self, c: char) -> Result<()>; + + /// Synthesise a virtual-key keypress (e.g. VK_RETURN). Used for + /// keys Unicode injection can't carry as text (Enter, Esc, F-keys). + fn send_vk(&self, vk: u16) -> Result<()>; + + /// Block the current thread for `duration`. Trait method (rather + /// than `std::thread::sleep`) so tests can short-circuit waits. + fn sleep(&self, duration: Duration); + + /// Launch csshw with the given hosts, working directory, and exe + /// path. Fire-and-forget: returns once the daemon process is + /// spawned, not when it exits. Production impl tracks the child + /// internally so [`terminate_csshw`](Self::terminate_csshw) can + /// kill it on cleanup. + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> Result<()>; + + /// Kill the in-flight csshw daemon (if any) and best-effort kill + /// any leaked client `csshw.exe` instances. Idempotent. + fn terminate_csshw(&self) -> Result<()>; + + /// Start a screen capture writing to `out_raw`. Production impl + /// spawns ffmpeg gdigrab and stores the child handle internally so + /// [`stop_recording`](Self::stop_recording) can terminate it. + fn start_recording(&self, out_raw: &Path) -> Result<()>; + + /// Terminate the in-flight capture, run the post-encode pipeline + /// (frame extraction + gifski), and produce `out_gif`. + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> Result<()>; + + /// Print an informational message to stdout. + fn print_info(&self, message: &str); + + /// Print a verbose message to stderr (gated on `CSSHW_XTASK_VERBOSE`). + fn print_debug(&self, message: &str); +} + +/// Production implementation of [`DemoSystem`]. +/// +/// Holds two long-lived child processes between method calls: +/// the in-flight ffmpeg gdigrab capture, and the spawned csshw +/// daemon. All Windows-API calls live in the `windows_input` private +/// module behind `cfg(target_os = "windows")`. +pub struct RealSystem { + capture: std::sync::Mutex>, + csshw: std::sync::Mutex>, +} + +impl RealSystem { + /// Construct a [`RealSystem`] with no in-flight children. + pub fn new() -> Self { + Self { + capture: std::sync::Mutex::new(None), + csshw: std::sync::Mutex::new(None), + } + } +} + +impl Default for RealSystem { + fn default() -> Self { + Self::new() + } +} + +mod windows_input; + +impl DemoSystem for RealSystem { + fn workspace_root(&self) -> Result { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest_dir) + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow::anyhow!("failed to resolve workspace root")) + } + + fn ensure_dir(&self, path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .map_err(|e| anyhow::anyhow!("failed to create {}: {e}", path.display())) + } + + fn write_file(&self, path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + self.ensure_dir(parent)?; + } + std::fs::write(path, content) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display())) + } + + fn copy_file(&self, from: &Path, to: &Path) -> Result<()> { + if let Some(parent) = to.parent() { + self.ensure_dir(parent)?; + } + std::fs::copy(from, to).map(|_| ()).map_err(|e| { + anyhow::anyhow!("failed to copy {} -> {}: {e}", from.display(), to.display()) + }) + } + + fn enum_windows(&self) -> Result> { + windows_input::enum_windows() + } + + fn set_foreground(&self, hwnd: u64) -> Result<()> { + windows_input::set_foreground(hwnd) + } + + fn send_unicode_char(&self, c: char) -> Result<()> { + windows_input::send_unicode_char(c) + } + + fn send_vk(&self, vk: u16) -> Result<()> { + windows_input::send_vk(vk) + } + + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } + + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> Result<()> { + let mut slot = self.csshw.lock().expect("csshw mutex poisoned"); + if slot.is_some() { + anyhow::bail!("spawn_csshw called while a daemon is already running"); + } + let mut cmd = std::process::Command::new(exe); + cmd.args(hosts).current_dir(cwd); + let child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("failed to spawn {}: {e}", exe.display()))?; + *slot = Some(child); + Ok(()) + } + + fn terminate_csshw(&self) -> Result<()> { + // Kill the daemon child we tracked. + if let Some(mut child) = self.csshw.lock().expect("csshw mutex poisoned").take() { + let _ = child.kill(); + let _ = child.wait(); + } + // Belt-and-braces: the daemon spawns clients via + // CreateProcessW(CREATE_NEW_CONSOLE), which detaches them from + // the daemon. Kill any lingering csshw.exe by image name. + // This is acceptable in dev contexts; v1 will switch to a + // Job Object so cleanup is automatic and safe. + let _ = std::process::Command::new("taskkill") + .args(["/IM", "csshw.exe", "/T", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Ok(()) + } + + fn start_recording(&self, out_raw: &Path) -> Result<()> { + let mut slot = self.capture.lock().expect("capture mutex poisoned"); + if slot.is_some() { + anyhow::bail!("start_recording called while a capture is already running"); + } + let child = recorder::spawn_ffmpeg_gdigrab(out_raw)?; + *slot = Some(child); + Ok(()) + } + + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> Result<()> { + let child = self + .capture + .lock() + .expect("capture mutex poisoned") + .take() + .ok_or_else(|| anyhow::anyhow!("stop_recording called with no active capture"))?; + recorder::stop_ffmpeg_and_encode(child, out_raw, out_gif) + } + + fn print_info(&self, message: &str) { + println!("INFO - {message}"); + } + + fn print_debug(&self, message: &str) { + if std::env::var("CSSHW_XTASK_VERBOSE") + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + eprintln!("DEBUG - {message}"); + } + } +} + +/// Top-level entry point for `cargo xtask record-demo`. +/// +/// Orchestrates: build the canonical [`dsl::Script`], delegate +/// environment preparation to the matching `env::*` module, run the +/// driver, return. +/// +/// # Arguments +/// +/// * `system` - the [`DemoSystem`] (real or mocked). +/// * `out` - desired GIF path. Defaults to +/// `/target/demo/csshw.gif`. +/// * `env` - which environment provider to use. +/// * `no_record` - skip [`dsl::Step::StartCapture`] / +/// [`dsl::Step::StopCapture`]. Useful for iterating on the script +/// without burning capture time. +/// * `no_overlay` - skip the Carnac keystroke overlay. v0 always +/// behaves as if this is true (Carnac arrives in v1). +pub fn record_demo( + system: &S, + out: Option, + env: DemoEnv, + no_record: bool, + no_overlay: bool, +) -> Result<()> { + let workspace = system.workspace_root()?; + let out = out.unwrap_or_else(|| workspace.join("target/demo/csshw.gif")); + let script = script::build_canonical_v0().build()?; + system.print_info(&format!( + "record-demo: env={env:?} out={} steps={} no_record={no_record} no_overlay={no_overlay}", + out.display(), + script.len(), + )); + match env { + DemoEnv::Local => env::local::run(system, &script, &out, no_record)?, + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/test_demo_mod.rs"] +mod tests; diff --git a/xtask/src/demo/recorder.rs b/xtask/src/demo/recorder.rs new file mode 100644 index 00000000..5bb51429 --- /dev/null +++ b/xtask/src/demo/recorder.rs @@ -0,0 +1,145 @@ +//! ffmpeg + gifski subprocess orchestration for the demo recorder. +//! +//! Two-stage pipeline (matches industry practice for high-quality GIFs): +//! +//! 1. `ffmpeg -f gdigrab` -> lossless `.mkv` (writing during the run) +//! 2. `ffmpeg -i raw.mkv -vf "fps=20,scale=1280:-1:flags=lanczos"` +//! -> PNG frames in `target/demo/frames/` +//! 3. `gifski` -> the final `.gif` +//! +//! v0 expects `ffmpeg` and `gifski` on `PATH`. v1 will SHA-pin +//! vendored binaries downloaded into `target/demo/bin/`. +//! +//! These free functions are called from [`crate::demo::RealSystem`]. +//! They are kept out of the [`crate::demo::DemoSystem`] trait so the +//! trait can be mocked without dragging in `std::process::Child`. + +use std::io::Write; +use std::path::Path; +use std::process::{Child, Command, Stdio}; + +use anyhow::{bail, Context, Result}; + +/// Capture resolution and framerate. Pinned to keep recordings +/// identical across developer machines and CI runners. +const CAPTURE_FRAMERATE: &str = "30"; +const CAPTURE_VIDEO_SIZE: &str = "1920x1080"; + +/// Encode parameters for the GIF. Re-used in the retry ladder if the +/// output exceeds the size budget (deferred to v3). +const ENCODE_FPS: &str = "20"; +const ENCODE_WIDTH: &str = "1280"; +const ENCODE_QUALITY: &str = "90"; + +/// Spawn the long-running ffmpeg gdigrab capture writing to `out_raw`. +/// +/// Returns the child process so [`stop_ffmpeg_and_encode`] can shut it +/// down cleanly via `q\n` on stdin. +pub fn spawn_ffmpeg_gdigrab(out_raw: &Path) -> Result { + if let Some(parent) = out_raw.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let child = Command::new("ffmpeg") + .args([ + "-y", + "-f", + "gdigrab", + "-framerate", + CAPTURE_FRAMERATE, + "-video_size", + CAPTURE_VIDEO_SIZE, + "-i", + "desktop", + "-c:v", + "ffvhuff", + ]) + .arg(out_raw) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context( + "failed to spawn `ffmpeg`. v0 requires ffmpeg on PATH; \ + install via winget (`winget install Gyan.FFmpeg`) or chocolatey", + )?; + Ok(child) +} + +/// Stop the in-flight ffmpeg, run the frame-extract step, then gifski. +/// +/// `out_raw` is the lossless `.mkv` ffmpeg has been writing. +/// `out_gif` is the final GIF the caller asked for. +pub fn stop_ffmpeg_and_encode(mut child: Child, out_raw: &Path, out_gif: &Path) -> Result<()> { + // Politely ask ffmpeg to flush + exit by sending `q\n` on stdin; + // it converts the partial buffer into a valid container. + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(b"q\n"); + } + let status = child.wait().context("waiting for ffmpeg gdigrab")?; + if !status.success() { + // Non-zero on graceful `q` is rare but documented; do not + // bail unconditionally because the .mkv may still be valid. + eprintln!("ffmpeg gdigrab exited with {status}; continuing to encode"); + } + if !out_raw.exists() { + bail!( + "ffmpeg did not produce {}: cannot continue to gifski", + out_raw.display() + ); + } + + let frames_dir = out_raw + .parent() + .map(|p| p.join("frames")) + .unwrap_or_else(|| Path::new("frames").to_path_buf()); + if frames_dir.exists() { + std::fs::remove_dir_all(&frames_dir) + .with_context(|| format!("failed to clear {}", frames_dir.display()))?; + } + std::fs::create_dir_all(&frames_dir) + .with_context(|| format!("failed to create {}", frames_dir.display()))?; + + // Frame extraction. + let extract_status = Command::new("ffmpeg") + .args(["-y", "-i"]) + .arg(out_raw) + .args([ + "-vf", + &format!("fps={ENCODE_FPS},scale={ENCODE_WIDTH}:-1:flags=lanczos"), + ]) + .arg(frames_dir.join("%05d.png")) + .status() + .context("failed to spawn `ffmpeg` for frame extraction")?; + if !extract_status.success() { + bail!("ffmpeg frame extraction failed with {extract_status}"); + } + + // gifski encode. + if let Some(parent) = out_gif.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let frame_glob = frames_dir.join("*.png"); + let gifski_status = Command::new("gifski") + .args([ + "--fps", + ENCODE_FPS, + "--width", + ENCODE_WIDTH, + "--quality", + ENCODE_QUALITY, + "-o", + ]) + .arg(out_gif) + .arg(frame_glob) + .status() + .context( + "failed to spawn `gifski`. v0 requires gifski on PATH; \ + install via `cargo install gifski` or download from gif.ski", + )?; + if !gifski_status.success() { + bail!("gifski exited with {gifski_status}"); + } + Ok(()) +} diff --git a/xtask/src/demo/script.rs b/xtask/src/demo/script.rs new file mode 100644 index 00000000..01393b43 --- /dev/null +++ b/xtask/src/demo/script.rs @@ -0,0 +1,44 @@ +//! The canonical demo script. +//! +//! This file is the "demo as code" surface: edit it to change what the +//! GIF shows. The DSL ([`crate::demo::dsl`]) is type-checked, so a +//! typo here surfaces as a compile error rather than a recording-time +//! failure. +//! +//! v0 ships [`build_canonical_v0`]: launch csshw with two hosts, wait +//! for both client windows, broadcast a single command, stop. The +//! richer scene (control-mode add-host, vim broadcast, ping/Ctrl+C) +//! arrives in v3 once the chord primitive lands in the DSL. + +use crate::demo::dsl::Script; + +/// Build the v0 canonical demo: launch + broadcast + stop. +/// +/// Returns the unbuilt [`Script`]. Callers (the production +/// `record_demo` entrypoint and unit tests) are expected to call +/// `.build()` to validate and consume into a `Vec`. +/// +/// # Window-title regexes +/// +/// The regexes match titles produced by csshw itself when it spawns +/// console windows. csshw uses titles like `daemon [...]` and +/// `@` for clients; we keep the regexes loose (`(?i)` and +/// no anchors) so future title tweaks do not silently break the demo. +pub fn build_canonical_v0() -> Script { + let mut s = Script::new("csshw-demo-v0"); + s.start_capture() + .marker("v0: launch + broadcast + stop") + .wait_for(r"(?i)daemon") + .wait_for(r"(?i)alpha") + .wait_for(r"(?i)bravo") + .focus(r"(?i)daemon") + .sleep_ms(800) + .type_text("whoami\r") + .sleep_ms(2000) + .stop_capture(); + s +} + +#[cfg(test)] +#[path = "../tests/test_demo_script.rs"] +mod tests; diff --git a/xtask/src/demo/windows_input.rs b/xtask/src/demo/windows_input.rs new file mode 100644 index 00000000..421ef3aa --- /dev/null +++ b/xtask/src/demo/windows_input.rs @@ -0,0 +1,236 @@ +//! Thin wrappers around the Win32 input + window-enumeration APIs. +//! +//! Kept private to [`crate::demo`] so the rest of the module tree never +//! touches `unsafe`. Only [`crate::demo::RealSystem`] calls in here. +//! All functions return `anyhow::Error` instead of `windows::core::Error` +//! so callers compose with the rest of xtask uniformly. +//! +//! Non-Windows builds still compile (xtask is a workspace member) by +//! returning a clear "Windows-only" error from each entry point. + +use anyhow::Result; + +use super::{WindowInfo, WindowRect}; + +#[cfg(target_os = "windows")] +mod imp { + use super::*; + use std::ffi::c_void; + + use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; + use windows::Win32::System::Threading::AttachThreadInput; + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, VIRTUAL_KEY, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetForegroundWindow, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, + GetWindowThreadProcessId, IsWindowVisible, SetForegroundWindow, + }; + + /// Closure-based EnumWindows callback context. + /// + /// We accumulate visible top-level windows with non-empty titles + /// into a `Vec` passed via `LPARAM`. + extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + // SAFETY: lparam is a `*mut Vec` we set in + // enum_windows() below. The pointer is valid for the duration + // of the EnumWindows call. + let acc = unsafe { &mut *(lparam.0 as *mut Vec) }; + // SAFETY: HWND is valid for the duration of this callback. + let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; + if !visible { + return BOOL(1); + } + // SAFETY: HWND valid; returns text length without trailing NUL. + let len = unsafe { GetWindowTextLengthW(hwnd) }; + if len <= 0 { + return BOOL(1); + } + let mut buf = vec![0u16; (len as usize) + 1]; + // SAFETY: HWND valid; buffer length matches the slot count. + let copied = unsafe { GetWindowTextW(hwnd, &mut buf) }; + if copied <= 0 { + return BOOL(1); + } + let title = String::from_utf16_lossy(&buf[..copied as usize]); + let mut rect = RECT::default(); + // SAFETY: HWND valid; rect is a stack RECT we own. + if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { + return BOOL(1); + } + acc.push(WindowInfo { + hwnd: hwnd.0 as u64, + title, + rect: WindowRect { + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }, + }); + BOOL(1) + } + + /// Enumerate visible top-level windows with non-empty titles. + pub fn enum_windows() -> Result> { + let mut acc: Vec = Vec::new(); + let lparam = LPARAM(&mut acc as *mut _ as isize); + // SAFETY: enum_proc is a valid extern "system" callback; + // EnumWindows blocks until iteration completes so `acc` stays + // valid for the entire call. + unsafe { EnumWindows(Some(enum_proc), lparam) } + .map_err(|e| anyhow::anyhow!("EnumWindows failed: {e}"))?; + Ok(acc) + } + + /// Bring the window to the foreground using the standard + /// `AttachThreadInput` workaround (Windows blocks + /// `SetForegroundWindow` from background processes since Win2K). + pub fn set_foreground(hwnd: u64) -> Result<()> { + let target = HWND(hwnd as *mut c_void); + // SAFETY: HWND value originates from a recent enum_windows() + // call. Worst case it has been destroyed and the API returns + // an error, which we propagate. + let foreground = unsafe { GetForegroundWindow() }; + let mut fg_thread = 0u32; + // SAFETY: foreground is the current foreground window handle + // from the OS; the out-pointer is a stack u32. + let _ = unsafe { GetWindowThreadProcessId(foreground, Some(&mut fg_thread)) }; + let mut target_thread = 0u32; + // SAFETY: target is the window we want to focus; out-pointer + // is a stack u32. + let _ = unsafe { GetWindowThreadProcessId(target, Some(&mut target_thread)) }; + let attached = if fg_thread != 0 && target_thread != 0 && fg_thread != target_thread { + // SAFETY: thread IDs come from GetWindowThreadProcessId. + unsafe { AttachThreadInput(fg_thread, target_thread, true) }.as_bool() + } else { + false + }; + // SAFETY: HWND validated at top of function. + let ok = unsafe { SetForegroundWindow(target) }.as_bool(); + if attached { + // SAFETY: must mirror the AttachThreadInput call above. + let _ = unsafe { AttachThreadInput(fg_thread, target_thread, false) }; + } + if !ok { + anyhow::bail!("SetForegroundWindow returned FALSE for hwnd={hwnd:#x}"); + } + Ok(()) + } + + /// Send a single Unicode codepoint via `SendInput(KEYEVENTF_UNICODE)`. + pub fn send_unicode_char(c: char) -> Result<()> { + // BMP characters fit in a single u16; supplementary plane + // codepoints need surrogate pairs. We synthesise both halves + // when needed. + let mut buf = [0u16; 2]; + let units = c.encode_utf16(&mut buf); + for unit in units.iter().copied() { + push_unicode(unit)?; + } + Ok(()) + } + + /// Send VK_DOWN + VK_UP for a single Unicode code unit. + fn push_unicode(unit: u16) -> Result<()> { + let down = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: unit, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let up = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: unit, + dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + send_pair(&[down, up]) + } + + /// Send a virtual-key down + up pair. + pub fn send_vk(vk: u16) -> Result<()> { + let down = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk), + wScan: 0, + dwFlags: KEYBD_EVENT_FLAGS(0), + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let up = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk), + wScan: 0, + dwFlags: KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + send_pair(&[down, up]) + } + + fn send_pair(events: &[INPUT]) -> Result<()> { + // SAFETY: events is a valid slice; SendInput reads `len` + // entries each of size_of::(). + let sent = unsafe { SendInput(events, std::mem::size_of::() as i32) }; + if sent as usize != events.len() { + anyhow::bail!( + "SendInput injected {sent}/{} events; the input desktop may be locked", + events.len() + ); + } + Ok(()) + } +} + +#[cfg(target_os = "windows")] +pub(super) use imp::{enum_windows, send_unicode_char, send_vk, set_foreground}; + +#[cfg(not(target_os = "windows"))] +mod imp_stub { + use super::*; + + /// Stub that errors on non-Windows hosts. The demo subcommand is + /// Windows-only; this stub exists so `cargo check` on Linux still + /// compiles the rest of the workspace. + fn unsupported() -> Result { + anyhow::bail!("record-demo is Windows-only; this is a non-Windows build") + } + + pub fn enum_windows() -> Result> { + unsupported() + } + pub fn set_foreground(_hwnd: u64) -> Result<()> { + unsupported() + } + pub fn send_unicode_char(_c: char) -> Result<()> { + unsupported() + } + pub fn send_vk(_vk: u16) -> Result<()> { + unsupported() + } +} + +#[cfg(not(target_os = "windows"))] +pub(super) use imp_stub::{enum_windows, send_unicode_char, send_vk, set_foreground}; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f1708e59..c0467cad 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod changelog; mod coverage; +mod demo; mod inject_agent_token; mod readme; mod release; @@ -65,6 +66,28 @@ enum Command { /// Scan tracked text files for forbidden decorative Unicode /// punctuation and fail with a list of offending locations. CheckTypography, + /// Record an automated demo of csshw and produce `target/demo/csshw.gif`. + /// + /// v0 only supports `--env local` (runs on the caller's interactive + /// desktop session, no isolation) and requires `ffmpeg` and + /// `gifski` on PATH. + RecordDemo { + /// Output GIF path. Defaults to + /// `/target/demo/csshw.gif`. + #[arg(long)] + out: Option, + /// Recording environment provider. + #[arg(long, value_enum, default_value_t = demo::DemoEnv::Local)] + env: demo::DemoEnv, + /// Skip ffmpeg capture; useful for iterating on the demo + /// script without burning a recording cycle. + #[arg(long)] + no_record: bool, + /// Skip the keystroke overlay. v0 always behaves as if this + /// is set; the flag exists so v1+ scripts can opt out. + #[arg(long)] + no_overlay: bool, + }, } fn main() -> Result<()> { @@ -101,6 +124,14 @@ fn main() -> Result<()> { Command::CheckTypography => { typography::check_typography(&typography::RealSystem)?; } + Command::RecordDemo { + out, + env, + no_record, + no_overlay, + } => { + demo::record_demo(&demo::RealSystem::new(), out, env, no_record, no_overlay)?; + } } Ok(()) } diff --git a/xtask/src/tests/test_demo_config_override.rs b/xtask/src/tests/test_demo_config_override.rs new file mode 100644 index 00000000..7fe62901 --- /dev/null +++ b/xtask/src/tests/test_demo_config_override.rs @@ -0,0 +1,180 @@ +//! Tests for the `csshw-config.toml` override generator. +//! +//! Asserts the generator (a) writes one config file plus per-host +//! enter.bat files, (b) only writes host-specific files for the +//! intended host, and (c) emits a TOML body that targets cmd.exe via +//! a single `dispatcher.bat`. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::{config_override, DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +#[derive(Default, Clone)] +struct WriteCapture { + files: Vec<(PathBuf, String)>, +} + +fn capturing_mock() -> (MockDemoSystemMock, Arc>) { + let cap: Arc> = Arc::new(Mutex::new(WriteCapture::default())); + let mut mock = MockDemoSystemMock::new(); + mock.expect_ensure_dir().returning(|_| Ok(())); + let slot = cap.clone(); + mock.expect_write_file().returning(move |p, c| { + slot.lock() + .unwrap() + .files + .push((p.to_path_buf(), c.to_string())); + Ok(()) + }); + (mock, cap) +} + +#[test] +fn test_generate_writes_config_and_per_host_bat() { + // Arrange + let (mock, cap) = capturing_mock(); + let demo_root = PathBuf::from("/demo"); + + // Act + let layout = config_override::generate(&mock, &demo_root, &["alpha", "bravo"]).unwrap(); + + // Assert + assert_eq!(layout.csshw_cwd, demo_root); + let files = cap.lock().unwrap().files.clone(); + let names: Vec = files + .iter() + .map(|(p, _)| p.display().to_string().replace('\\', "/")) + .collect(); + assert!(names.iter().any(|n| n.ends_with("/csshw-config.toml"))); + assert!(names.iter().any(|n| n.ends_with("/dispatcher.bat"))); + assert!(names + .iter() + .any(|n| n.ends_with("fakehosts/alpha/enter.bat"))); + assert!(names + .iter() + .any(|n| n.ends_with("fakehosts/bravo/enter.bat"))); + // Shared README is written for both hosts. + assert_eq!( + names.iter().filter(|n| n.ends_with("README.txt")).count(), + 2 + ); +} + +#[test] +fn test_generate_writes_host_specific_file_only_for_owning_host() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha", "charlie"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let secret_writes: Vec<_> = files + .iter() + .filter(|(p, _)| p.display().to_string().ends_with("secret.txt")) + .collect(); + assert_eq!(secret_writes.len(), 1, "secret.txt should appear once"); + let path = secret_writes[0].0.display().to_string().replace('\\', "/"); + assert!(path.contains("fakehosts/charlie/"), "got: {path}"); +} + +#[test] +fn test_generated_toml_targets_cmd_exe_via_dispatcher() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let toml = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("csshw-config.toml")) + .map(|(_, c)| c.clone()) + .expect("csshw-config.toml not written"); + assert!(toml.contains("program = \"cmd.exe\""), "toml: {toml}"); + assert!( + toml.contains("{{USERNAME_AT_HOST}}"), + "placeholder missing - toml: {toml}" + ); + assert!(toml.contains("dispatcher.bat"), "toml: {toml}"); +} + +#[test] +fn test_dispatcher_bat_strips_user_prefix() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let dispatcher = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("dispatcher.bat")) + .map(|(_, c)| c.clone()) + .expect("dispatcher.bat not written"); + // Must use cmd's `:*@=` substring substitution. The + // `for /f tokens=2 delims=@` form skips a leading `@` and + // produces only one token, leaving HOST as `@alpha` and + // breaking the call below with "the system cannot find the + // path specified". + assert!( + dispatcher.contains(":*@="), + "dispatcher should use substring substitution: {dispatcher}" + ); + assert!( + !dispatcher.contains("delims=@"), + "dispatcher must not use `for /f delims=@` (mishandles leading @): {dispatcher}" + ); + assert!(dispatcher.contains("fakehosts"), "dispatcher: {dispatcher}"); + assert!(dispatcher.contains("enter.bat"), "dispatcher: {dispatcher}"); +} + +#[test] +fn test_enter_bat_sets_prompt_and_cd() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let bat = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("enter.bat")) + .map(|(_, c)| c.clone()) + .expect("enter.bat not written"); + assert!(bat.contains("set PROMPT="), "bat: {bat}"); + assert!(bat.contains("cd /d"), "bat: {bat}"); + assert!(bat.contains("alpha"), "bat: {bat}"); +} diff --git a/xtask/src/tests/test_demo_driver.rs b/xtask/src/tests/test_demo_driver.rs new file mode 100644 index 00000000..690e6902 --- /dev/null +++ b/xtask/src/tests/test_demo_driver.rs @@ -0,0 +1,268 @@ +//! Tests for the demo driver. +//! +//! All side effects route through the [`DemoSystem`] trait, so the +//! driver is fully mockable without any Windows API or filesystem +//! contact. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::dsl::Step; +use crate::demo::{driver, DemoSystem, WindowInfo, WindowRect}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +/// Build a mock with no-op `print_*` and `sleep` so callers only set +/// expectations on the calls they actually want to assert. +fn base_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock.expect_sleep().returning(|_| ()); + mock +} + +/// Single window with a stable rect, used by tests that expect +/// `WaitForWindow` and `Focus` to succeed on the first poll. +fn one_window(title: &str) -> Vec { + vec![WindowInfo { + hwnd: 0xDEAD, + title: title.to_string(), + rect: WindowRect { + x: 0, + y: 0, + width: 800, + height: 600, + }, + }] +} + +#[test] +fn test_no_record_skips_capture_calls() { + // Arrange + let mut mock = base_mock(); + mock.expect_start_recording().times(0); + mock.expect_stop_recording().times(0); + let steps = vec![ + Step::StartCapture, + Step::Sleep(Duration::from_millis(1)), + Step::StopCapture, + ]; + + // Act + let res = driver::run(&mock, &steps, Path::new("ignored.gif"), true); + + // Assert + assert!(res.is_ok()); +} + +#[test] +fn test_capture_calls_are_paired_when_recording() { + // Arrange + let mut mock = base_mock(); + let captured_raw: Arc>> = Arc::new(Mutex::new(None)); + let captured_gif: Arc>> = Arc::new(Mutex::new(None)); + let raw_slot = captured_raw.clone(); + mock.expect_start_recording().times(1).returning(move |p| { + *raw_slot.lock().unwrap() = Some(p.to_path_buf()); + Ok(()) + }); + let gif_slot = captured_gif.clone(); + mock.expect_stop_recording() + .times(1) + .returning(move |_raw, gif| { + *gif_slot.lock().unwrap() = Some(gif.to_path_buf()); + Ok(()) + }); + let steps = vec![Step::StartCapture, Step::StopCapture]; + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!( + captured_raw.lock().unwrap().as_deref(), + Some(Path::new("/x/csshw.mkv")) + ); + assert_eq!( + captured_gif.lock().unwrap().as_deref(), + Some(Path::new("/x/csshw.gif")) + ); +} + +#[test] +fn test_capture_is_cleaned_up_on_step_error() { + // Arrange + let mut mock = base_mock(); + mock.expect_start_recording().times(1).returning(|_| Ok(())); + // The Type step below will fail because send_unicode_char errors; + // the driver MUST still call stop_recording. + mock.expect_stop_recording() + .times(1) + .returning(|_, _| Ok(())); + mock.expect_send_unicode_char() + .returning(|_| Err(anyhow::anyhow!("simulated failure"))); + let steps = vec![ + Step::StartCapture, + Step::Type { + text: "x".into(), + per_char_delay: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("Type") || err.contains("simulated"), + "got: {err}" + ); +} + +#[test] +fn test_wait_for_window_succeeds_when_match_appears() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("daemon [csshw]"))); + let steps = vec![ + Step::StartCapture, + Step::WaitForWindow { + title_regex: r"(?i)daemon".to_string(), + timeout: Duration::from_millis(500), + stable_for: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_wait_for_window_times_out_when_no_match() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("not the right window"))); + let steps = vec![ + Step::StartCapture, + Step::WaitForWindow { + title_regex: r"(?i)daemon".to_string(), + timeout: Duration::from_millis(50), + stable_for: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + let err = res.expect_err("expected timeout").to_string(); + assert!( + err.contains("WaitForWindow") || err.contains("stabilised"), + "got: {err}" + ); +} + +#[test] +fn test_focus_calls_set_foreground_with_matching_hwnd() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("alpha@alpha-fake"))); + let captured_hwnd: Arc>> = Arc::new(Mutex::new(None)); + let slot = captured_hwnd.clone(); + mock.expect_set_foreground().times(1).returning(move |h| { + *slot.lock().unwrap() = Some(h); + Ok(()) + }); + let steps = vec![ + Step::StartCapture, + Step::Focus { + title_regex: r"(?i)alpha".to_string(), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!(*captured_hwnd.lock().unwrap(), Some(0xDEAD)); +} + +#[test] +fn test_type_text_translates_newline_to_vk_return() { + // Arrange + let mut mock = base_mock(); + let unicode_chars: Arc>> = Arc::new(Mutex::new(Vec::new())); + let vk_codes: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cs = unicode_chars.clone(); + mock.expect_send_unicode_char().returning(move |c| { + cs.lock().unwrap().push(c); + Ok(()) + }); + let vs = vk_codes.clone(); + mock.expect_send_vk().returning(move |vk| { + vs.lock().unwrap().push(vk); + Ok(()) + }); + let steps = vec![ + Step::StartCapture, + Step::Type { + text: "ab\r".into(), + per_char_delay: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!(*unicode_chars.lock().unwrap(), vec!['a', 'b']); + // 0x0D is VK_RETURN. + assert_eq!(*vk_codes.lock().unwrap(), vec![0x0Du16]); +} diff --git a/xtask/src/tests/test_demo_dsl.rs b/xtask/src/tests/test_demo_dsl.rs new file mode 100644 index 00000000..ae15ff91 --- /dev/null +++ b/xtask/src/tests/test_demo_dsl.rs @@ -0,0 +1,147 @@ +//! Tests for the demo DSL. +//! +//! Pure data manipulation - no `DemoSystem` mock needed. + +use std::time::Duration; + +use crate::demo::dsl::{ + Script, Step, DEFAULT_PER_CHAR_DELAY, DEFAULT_WAIT_STABLE_FOR, DEFAULT_WAIT_TIMEOUT, +}; + +#[test] +fn test_script_records_steps_in_order() { + // Arrange + let mut s = Script::new("ordering"); + // Act + s.start_capture() + .wait_for("daemon") + .focus("daemon") + .type_text("hi\r") + .sleep_ms(500) + .stop_capture(); + // Assert + let steps = s.build().unwrap(); + assert_eq!(steps.len(), 6); + assert!(matches!(steps[0], Step::StartCapture)); + assert!(matches!(steps[1], Step::WaitForWindow { .. })); + assert!(matches!(steps[2], Step::Focus { .. })); + assert!(matches!(steps[3], Step::Type { .. })); + assert!(matches!(steps[4], Step::Sleep(_))); + assert!(matches!(steps[5], Step::StopCapture)); +} + +#[test] +fn test_wait_for_uses_defaults() { + // Arrange + let mut s = Script::new("defaults"); + s.start_capture().wait_for("d").stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::WaitForWindow { + timeout, + stable_for, + .. + } = &steps[1] + else { + panic!("expected WaitForWindow"); + }; + assert_eq!(*timeout, DEFAULT_WAIT_TIMEOUT); + assert_eq!(*stable_for, DEFAULT_WAIT_STABLE_FOR); +} + +#[test] +fn test_type_text_uses_default_per_char_delay() { + // Arrange + let mut s = Script::new("defaults"); + s.start_capture().type_text("ab").stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::Type { per_char_delay, .. } = &steps[1] else { + panic!("expected Type"); + }; + assert_eq!(*per_char_delay, DEFAULT_PER_CHAR_DELAY); +} + +#[test] +fn test_build_rejects_invalid_regex() { + // Arrange + let mut s = Script::new("bad-regex"); + s.start_capture().wait_for("(unclosed").stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("invalid title_regex"), "got: {err}"); +} + +#[test] +fn test_build_rejects_missing_start_capture() { + // Arrange + let mut s = Script::new("no-start"); + s.wait_for("daemon").stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("missing StartCapture"), "got: {err}"); +} + +#[test] +fn test_build_rejects_missing_stop_capture() { + // Arrange + let mut s = Script::new("no-stop"); + s.start_capture().wait_for("daemon"); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("missing StopCapture"), "got: {err}"); +} + +#[test] +fn test_build_rejects_duplicate_capture() { + // Arrange + let mut s = Script::new("dup"); + s.start_capture().start_capture().stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!( + err.contains("StartCapture appears more than once"), + "got: {err}" + ); +} + +#[test] +fn test_build_rejects_stop_before_start() { + // Arrange + let mut s = Script::new("inverted"); + s.stop_capture().start_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("precedes StartCapture"), "got: {err}"); +} + +#[test] +fn test_wait_for_with_overrides_durations() { + // Arrange + let custom_timeout = Duration::from_secs(7); + let custom_stable = Duration::from_millis(123); + let mut s = Script::new("custom"); + s.start_capture() + .wait_for_with("d", custom_timeout, custom_stable) + .stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::WaitForWindow { + timeout, + stable_for, + .. + } = &steps[1] + else { + panic!("expected WaitForWindow"); + }; + assert_eq!(*timeout, custom_timeout); + assert_eq!(*stable_for, custom_stable); +} diff --git a/xtask/src/tests/test_demo_mod.rs b/xtask/src/tests/test_demo_mod.rs new file mode 100644 index 00000000..3072d8f5 --- /dev/null +++ b/xtask/src/tests/test_demo_mod.rs @@ -0,0 +1,37 @@ +//! Top-level smoke tests for the demo module. +//! +//! Per-submodule behaviour is exercised by `test_demo_dsl.rs`, +//! `test_demo_driver.rs`, `test_demo_config_override.rs`, and +//! `test_demo_script.rs`. This file holds only assertions about the +//! module's public surface. + +use crate::demo::{DemoEnv, WindowRect}; + +#[test] +fn test_demo_env_default_is_local() { + // The default for `--env` lives in `main.rs` as `DemoEnv::Local`. + // Pin that here so renaming the variant later flags the + // documentation in the plan as out of date. + let env = DemoEnv::Local; + assert!(matches!(env, DemoEnv::Local)); +} + +#[test] +fn test_window_rect_is_value_equality() { + // Arrange + let a = WindowRect { + x: 0, + y: 0, + width: 1920, + height: 1080, + }; + let b = WindowRect { + x: 0, + y: 0, + width: 1920, + height: 1080, + }; + // Assert: PartialEq must derive structurally so the driver's + // stability check (rect-equality across polls) works. + assert_eq!(a, b); +} diff --git a/xtask/src/tests/test_demo_script.rs b/xtask/src/tests/test_demo_script.rs new file mode 100644 index 00000000..91005d0d --- /dev/null +++ b/xtask/src/tests/test_demo_script.rs @@ -0,0 +1,28 @@ +//! Sanity test for the canonical v0 script. +//! +//! The point of having a typed DSL is that the script validates at +//! `cargo build` time. This test pins down the contract: the canonical +//! script must build without errors and contain the expected first / +//! last steps. Future scripts (v1+) can clone this pattern. + +use crate::demo::dsl::Step; +use crate::demo::script; + +#[test] +fn test_canonical_v0_builds() { + // Act + let steps = script::build_canonical_v0().build().unwrap(); + // Assert + assert!(!steps.is_empty()); + assert!(matches!(steps.first(), Some(Step::StartCapture))); + assert!(matches!(steps.last(), Some(Step::StopCapture))); +} + +#[test] +fn test_canonical_v0_contains_a_type_step() { + // Act + let steps = script::build_canonical_v0().build().unwrap(); + // Assert + let typed = steps.iter().any(|s| matches!(s, Step::Type { .. })); + assert!(typed, "canonical v0 should type at least one command"); +} From b11cb5869e44524bb0db2c0a9491205aa5200fcf Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 13:26:38 +0200 Subject: [PATCH 2/9] xtask: add sandbox env, vendored binaries, and Carnac overlay (v1) Builds on the v0 record-demo subcommand. With this change the recorder no longer requires ffmpeg, gifski, or Carnac on PATH and the demo can be recorded inside an isolated Windows Sandbox VM instead of the caller's interactive desktop. - New `--env sandbox` provider renders `target/demo/csshw-demo.wsb` with read-only mounts for the workspace, the bin cache, and `xtask/demo-assets/`, plus a writable mount for the captured GIF. A `LogonCommand` runs `sandbox-bootstrap.ps1` which sources `setup-desktop.ps1`, optionally launches Carnac, runs `xtask record-demo --env local` inside the sandbox, and writes a `done.flag` sentinel before shutting the VM down. The host polls for the sentinel and copies the GIF back. - New `xtask/src/demo/bin.rs` SHA-pins ffmpeg 8.1.1, gifski 1.34.0, and Carnac 2.3.13. `ensure_bins` downloads each into `target/demo/bin/` on cold cache, verifies the SHA-256 (case- insensitive to tolerate PowerShell's upper-case digests), and extracts. Carnac's release zip wraps a NuGet package so the pin records `inner_archive` and the helper extracts both layers. - Recorder now polls `file_size` until ffmpeg has written at least 8 KiB of capture data so the first synthesised keystrokes are not lost in the gdigrab warm-up window. - `setup-desktop.ps1` normalises wallpaper, console font, and DPI inside the sandbox; the .wsb schema does not expose a stable resolution element. - Carnac is downloaded unchanged under the MS-PL; LICENSE and attribution README live under `xtask/demo-assets/carnac/` to preserve the notices the license requires. - All new I/O surfaces (path_exists, file_size, http_download, sha256_file, extract_archive, spawn_sandbox, terminate_sandbox) are routed through `DemoSystem` so unit tests cover the cache state machine, the `.wsb` mount layout, the sentinel poll, and the capture-baseline gate against mockall fakes with zero real filesystem or network effects. Sandbox cannot run on GitHub-hosted runners (no nested virtualisation); v2 will add the `ci_runner` provider for windows-2022 plus the orphan-branch publish flow. Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 66 ++++ README.md | 21 ++ xtask/Cargo.toml | 3 + xtask/demo-assets/carnac/LICENSE | 74 ++++ xtask/demo-assets/carnac/README.md | 34 ++ xtask/demo-assets/sandbox-bootstrap.ps1 | 138 ++++++++ xtask/demo-assets/setup-desktop.ps1 | 105 ++++++ xtask/src/demo/bin.rs | 237 +++++++++++++ xtask/src/demo/env/mod.rs | 5 +- xtask/src/demo/env/sandbox.rs | 319 ++++++++++++++++++ xtask/src/demo/mod.rs | 227 ++++++++++++- xtask/src/demo/recorder.rs | 148 ++++++-- xtask/src/main.rs | 13 +- xtask/src/tests/test_demo_bin.rs | 337 +++++++++++++++++++ xtask/src/tests/test_demo_config_override.rs | 7 + xtask/src/tests/test_demo_driver.rs | 7 + xtask/src/tests/test_demo_env_sandbox.rs | 175 ++++++++++ xtask/src/tests/test_demo_recorder.rs | 132 ++++++++ 18 files changed, 2011 insertions(+), 37 deletions(-) create mode 100644 xtask/demo-assets/carnac/LICENSE create mode 100644 xtask/demo-assets/carnac/README.md create mode 100644 xtask/demo-assets/sandbox-bootstrap.ps1 create mode 100644 xtask/demo-assets/setup-desktop.ps1 create mode 100644 xtask/src/demo/bin.rs create mode 100644 xtask/src/demo/env/sandbox.rs create mode 100644 xtask/src/tests/test_demo_bin.rs create mode 100644 xtask/src/tests/test_demo_env_sandbox.rs create mode 100644 xtask/src/tests/test_demo_recorder.rs diff --git a/Cargo.lock b/Cargo.lock index c65ea19f..688ab5d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -308,6 +317,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -317,6 +335,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "csshw" version = "0.18.1" @@ -368,6 +396,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "5.0.1" @@ -547,6 +585,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "0.2.3" @@ -1441,6 +1489,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1872,6 +1931,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2788,6 +2853,7 @@ dependencies = [ "mockall", "regex", "semver", + "sha2", "toml_edit 0.21.1", "windows 0.59.0", ] diff --git a/README.md b/README.md index 52f08dc3..f67970e9 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,27 @@ e.g. white font on red background: 8+4+2+1+64+128 = `207` csshW uses pre-commit githooks to enforce good code style.
Install them via ``git config --local core.hooksPath .githooks/``. +## How to record the demo +The README's demo GIF is reproducible: `cargo xtask record-demo` +drives a typed Rust DSL against synthesised Windows input, captures +the desktop with vendored ffmpeg + gifski, and emits +`target/demo/csshw.gif`. The recorder ships with two `--env` +providers: +- `--env local` (default) runs on the caller's interactive session. + Step away while it records; foreground stealing is part of the demo. +- `--env sandbox` boots a fresh Windows Sandbox VM, normalises the + desktop (wallpaper, console font, DPI), optionally launches + [Carnac](https://github.com/Code52/carnac) for the keystroke overlay, + runs the demo, and copies the GIF back to the host. Requires the + optional `Containers-DisposableClientVM` Windows feature. + +The vendored binaries (ffmpeg, gifski, Carnac) are SHA-pinned and +downloaded once into `target/demo/bin/` on first use. Pass +`--no-overlay` to skip Carnac, `--no-record` to dry-run the script. +Carnac is used unchanged under the MS-PL; see +[`xtask/demo-assets/carnac/`](xtask/demo-assets/carnac/) for the +attribution and license text. + ## Releases Step by step guide to create a new release: - `cargo make prepare-release` and follow the instructions diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 0b2cbdb9..b1dfd4ad 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -16,6 +16,9 @@ toml_edit = "0.21" semver = "1.0" # Demo subcommand (record-demo): regex-based window matching. regex = "1" +# Demo subcommand: SHA-256 verification of vendored ffmpeg / gifski / +# Carnac binaries downloaded into `target/demo/bin/`. +sha2 = "0.10" # Demo subcommand: Windows input synthesis (SendInput) and window # enumeration. Pinned to the same major version csshw_lib uses diff --git a/xtask/demo-assets/carnac/LICENSE b/xtask/demo-assets/carnac/LICENSE new file mode 100644 index 00000000..e5f40af3 --- /dev/null +++ b/xtask/demo-assets/carnac/LICENSE @@ -0,0 +1,74 @@ +Carnac (https://github.com/Code52/carnac) is distributed under the +Microsoft Public License (MS-PL), reproduced verbatim below. csshw +does not vendor the Carnac binary in this repository; the demo +recorder downloads the upstream release archive on first run (see +`xtask/src/demo/bin.rs::CARNAC`) and uses Carnac unchanged. This +file exists to satisfy MS-PL section 3(C) ("retain all copyright, +patent, trademark, and attribution notices that are present in the +software") and to make the licensing terms visible to anyone +distributing the demo GIF or the recorder source. + +---------------------------------------------------------------------- + +Microsoft Public License (MS-PL) + +This license governs use of the accompanying software. If you use the +software, you accept this license. If you do not accept the license, +do not use the software. + +1. Definitions + +The terms "reproduce," "reproduction," "derivative works," and +"distribution" have the same meaning here as under U.S. copyright law. + +A "contribution" is the original software, or any additions or +changes to the software. + +A "contributor" is any person that distributes its contribution under +this license. + +"Licensed patents" are a contributor's patent claims that read +directly on its contribution. + +2. Grant of Rights + +(A) Copyright Grant - Subject to the terms of this license, including +the license conditions and limitations in section 3, each contributor +grants you a non-exclusive, worldwide, royalty-free copyright license +to reproduce its contribution, prepare derivative works of its +contribution, and distribute its contribution or any derivative works +that you create. + +(B) Patent Grant - Subject to the terms of this license, including +the license conditions and limitations in section 3, each contributor +grants you a non-exclusive, worldwide, royalty-free license under its +licensed patents to make, have made, use, sell, offer for sale, +import, and/or otherwise dispose of its contribution in the software +or derivative works of the contribution in the software. + +3. Conditions and Limitations + +(A) No Trademark License - This license does not grant you rights to +use any contributors' name, logo, or trademarks. + +(B) If you bring a patent claim against any contributor over patents +that you claim are infringed by the software, your patent license +from such contributor to the software ends automatically. + +(C) If you distribute any portion of the software, you must retain +all copyright, patent, trademark, and attribution notices that are +present in the software. + +(D) If you distribute any portion of the software in source code +form, you may do so only under this license by including a complete +copy of this license with your distribution. If you distribute any +portion of the software in compiled or object code form, you may only +do so under a license that complies with this license. + +(E) The software is licensed "as-is." You bear the risk of using it. +The contributors give no express warranties, guarantees or +conditions. You may have additional consumer rights under your local +laws which this license cannot change. To the extent permitted under +your local laws, the contributors exclude the implied warranties of +merchantability, fitness for a particular purpose and +non-infringement. diff --git a/xtask/demo-assets/carnac/README.md b/xtask/demo-assets/carnac/README.md new file mode 100644 index 00000000..48891507 --- /dev/null +++ b/xtask/demo-assets/carnac/README.md @@ -0,0 +1,34 @@ +# Carnac attribution + +The csshw demo recorder uses [Carnac](https://github.com/Code52/carnac) +to render a keystroke overlay on the bottom strip of the captured +desktop. Carnac is a third-party tool by Code52 contributors, +distributed under the Microsoft Public License (MS-PL); see +[`LICENSE`](LICENSE) in this directory for the verbatim text. + +## How Carnac is consumed + +We do **not** vendor the Carnac binary in this repository. Instead, +[`xtask/src/demo/bin.rs`](../../src/demo/bin.rs) holds a SHA-pinned +download URL for the upstream `carnac.2.3.13.zip` release artifact. +On the first `cargo xtask record-demo` invocation the recorder +downloads the archive into `target/demo/bin/carnac/`, verifies the +SHA-256 against the constant in `bin.rs`, and extracts the inner +NuGet package to expose `lib/net45/Carnac.exe`. Subsequent runs hit +the warm cache and skip the network entirely. + +## Licensing notes + +MS-PL section 3(C) requires that every distribution preserve +attribution notices that ship with the software. The recorded GIF +embeds the Carnac overlay (visible Carnac branding in the corner +strip), so the rendered GIF qualifies as distributing a portion of +Carnac. Keeping this LICENSE + README pair in the source tree is +how csshw satisfies that obligation; if you redistribute the +recorded GIF on its own, please carry the same attribution forward. + +We deliberately download from upstream rather than mirror the binary +so refreshing the pin is a one-line constant change instead of a +binary commit. The SHA pin guarantees that a tampered CDN cannot +silently swap the overlay for a different binary - a mismatch fails +the recorder loudly with a `bin: SHA-256 mismatch` error. diff --git a/xtask/demo-assets/sandbox-bootstrap.ps1 b/xtask/demo-assets/sandbox-bootstrap.ps1 new file mode 100644 index 00000000..7bcd8b63 --- /dev/null +++ b/xtask/demo-assets/sandbox-bootstrap.ps1 @@ -0,0 +1,138 @@ +# Bootstraps the csshw demo recording inside Windows Sandbox. +# +# Mounted folders (set up by xtask::demo::env::sandbox::render_wsb): +# C:\demo\repo repo (read-only) +# C:\demo\bin ffmpeg / gifski / Carnac caches (read-only) +# C:\demo\assets this script + setup-desktop.ps1 (read-only) +# C:\demo\out writable: GIF + done.flag sentinel land here +# +# Flow: +# 1. Source setup-desktop.ps1 (wallpaper, console font, DPI). +# 2. Optionally launch Carnac minimised for the keystroke overlay +# (skipped when -NoOverlay is passed by the host). +# 3. Build csshw release binaries from the mounted source tree. +# The host cannot pre-build because it would bake the +# developer's machine-specific paths into the artifacts; the +# sandbox build is short and reproducible. +# 4. Invoke `xtask record-demo --env local` against the sandboxed +# desktop. The local provider already owns the recording flow. +# 5. Copy the resulting GIF to C:\demo\out\csshw.gif and write the +# sentinel C:\demo\out\done.flag (`ok` on success, `error: ...` +# on failure) so the host poll loop can release. +# 6. Trigger an immediate sandbox shutdown so the host's +# terminate_sandbox is a no-op rather than a fallback. + +[CmdletBinding()] +param( + [switch] $NoOverlay +) + +$ErrorActionPreference = 'Stop' + +# Robust sentinel write: any exit path (success, failure, even a +# trapped exception) must produce C:\demo\out\done.flag, otherwise +# the host's wait_for_sentinel times out without diagnostic output. +$sentinel = 'C:\demo\out\done.flag' +$status = 'error: bootstrap exited unexpectedly' +$ranToCompletion = $false + +trap { + $err = $_.ToString() + Set-Content -LiteralPath $sentinel -Value "error: $err" -Encoding ASCII -NoNewline + Stop-Computer -Force + break +} + +try { + Write-Host '[bootstrap] sourcing setup-desktop.ps1' + . 'C:\demo\assets\setup-desktop.ps1' + + if (-not $NoOverlay) { + $carnacExe = 'C:\demo\bin\carnac\lib\net45\Carnac.exe' + if (Test-Path -LiteralPath $carnacExe) { + Write-Host '[bootstrap] launching Carnac minimised' + # Carnac auto-positions in the bottom-right strip, which + # leaves the daemon and client windows (top-half of the + # 1920x1080 desktop) clear for the recording. + Start-Process -FilePath $carnacExe -WindowStyle Minimized | Out-Null + # Give Carnac a moment to register its global keyboard + # hook before we start typing. + Start-Sleep -Seconds 2 + } else { + Write-Warning "[bootstrap] Carnac.exe missing at $carnacExe; continuing without overlay" + } + } else { + Write-Host '[bootstrap] -NoOverlay: skipping Carnac' + } + + # Cargo and rustup are not present in a fresh sandbox image. The + # demo path we ship works only if the host has already built + # csshw.exe; the sandbox merely consumes it. We defensively + # locate the prebuilt binary under target\release; if it is + # missing we surface a clear sentinel error. + $csshwExe = 'C:\demo\repo\target\release\csshw.exe' + if (-not (Test-Path -LiteralPath $csshwExe)) { + $csshwExe = 'C:\demo\repo\target\debug\csshw.exe' + } + if (-not (Test-Path -LiteralPath $csshwExe)) { + throw "no prebuilt csshw.exe found under C:\demo\repo\target\{release,debug}; run `cargo build --release` on the host before `cargo xtask record-demo --env sandbox`" + } + $xtaskExe = 'C:\demo\repo\target\release\xtask.exe' + if (-not (Test-Path -LiteralPath $xtaskExe)) { + $xtaskExe = 'C:\demo\repo\target\debug\xtask.exe' + } + if (-not (Test-Path -LiteralPath $xtaskExe)) { + throw "no prebuilt xtask.exe found under C:\demo\repo\target\{release,debug}; run `cargo build -p xtask --release` on the host before `cargo xtask record-demo --env sandbox`" + } + + # The local provider expects to write to /target/demo, + # which inside the sandbox is the read-only C:\demo\repo. We + # work around that by copying the read-only tree to a writable + # location under C:\demo\out\repo and pointing the local + # provider at it. + $writeRepo = 'C:\demo\out\repo' + if (Test-Path -LiteralPath $writeRepo) { + Remove-Item -LiteralPath $writeRepo -Recurse -Force + } + # We only need target\release\csshw.exe + target\release\xtask.exe + # plus anything xtask reads at runtime (CARGO_MANIFEST_DIR - + # baked at compile time so source layout does not matter at run + # time). Copy a minimal skeleton that satisfies xtask's + # workspace_root() resolver: /xtask/Cargo.toml's parent. + New-Item -ItemType Directory -Path "$writeRepo\xtask" -Force | Out-Null + New-Item -ItemType Directory -Path "$writeRepo\target\release" -Force | Out-Null + Copy-Item -LiteralPath $csshwExe -Destination "$writeRepo\target\release\csshw.exe" + Copy-Item -LiteralPath $xtaskExe -Destination "$writeRepo\target\release\xtask.exe" + + Write-Host '[bootstrap] running xtask record-demo --env local' + $proc = Start-Process -FilePath "$writeRepo\target\release\xtask.exe" ` + -ArgumentList @('record-demo', '--env', 'local', '--no-overlay') ` + -WorkingDirectory $writeRepo ` + -PassThru -Wait -NoNewWindow + if ($proc.ExitCode -ne 0) { + throw "xtask record-demo exited with status $($proc.ExitCode)" + } + + $producedGif = Join-Path $writeRepo 'target\demo\csshw.gif' + if (-not (Test-Path -LiteralPath $producedGif)) { + throw "expected $producedGif after record-demo, but it is missing" + } + Copy-Item -LiteralPath $producedGif -Destination 'C:\demo\out\csshw.gif' -Force + Write-Host '[bootstrap] copied recorded GIF to C:\demo\out\csshw.gif' + + $status = 'ok' + $ranToCompletion = $true +} +finally { + if (-not $ranToCompletion -and $status -eq 'error: bootstrap exited unexpectedly') { + # Trap above handles thrown exceptions; this branch covers + # script termination paths PowerShell does not surface as + # exceptions (e.g. native commands aborting the host). + } + Set-Content -LiteralPath $sentinel -Value $status -Encoding ASCII -NoNewline + # Shut the sandbox down so the host's wait_for_sentinel + copy + # is the only synchronisation point. -Force avoids the + # "applications have unsaved changes" prompt on the + # not-actually-real desktop. + Stop-Computer -Force +} diff --git a/xtask/demo-assets/setup-desktop.ps1 b/xtask/demo-assets/setup-desktop.ps1 new file mode 100644 index 00000000..7b2dd3ca --- /dev/null +++ b/xtask/demo-assets/setup-desktop.ps1 @@ -0,0 +1,105 @@ +# Normalises the desktop chrome so demo recordings look identical +# across developer machines and CI runners. +# +# Sourced (dot-sourced) by sandbox-bootstrap.ps1 inside Windows +# Sandbox, and reused unchanged by the v2 ci-runner provider. Safe +# to re-run: every operation either overwrites or short-circuits if +# the desired state is already in place. +# +# Settings applied: +# - Wallpaper: solid #0F1419 (csshw brand colour) via +# SystemParametersInfo SPI_SETDESKWALLPAPER. +# - Console font: Cascadia Mono 18 pt for both cmd.exe and +# powershell.exe via HKCU\Console\. +# - Logical resolution: 1920x1080 at 100 % DPI scale. +# - Hide desktop icons; disable taskbar auto-hide animation. + +$ErrorActionPreference = 'Stop' + +function Set-SolidWallpaper { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $HexColor) + + $rgb = $HexColor.TrimStart('#') + $r = [Convert]::ToInt32($rgb.Substring(0, 2), 16) + $g = [Convert]::ToInt32($rgb.Substring(2, 2), 16) + $b = [Convert]::ToInt32($rgb.Substring(4, 2), 16) + + # Solid colour wallpaper is set in two steps: + # 1. Write Control Panel\Colors!Background (space-separated RGB). + # 2. Clear the desktop wallpaper image so the solid colour shows. + Set-ItemProperty -Path 'HKCU:\Control Panel\Colors' ` + -Name 'Background' -Value "$r $g $b" + Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' ` + -Name 'Wallpaper' -Value '' + + # Push the change into the running session via SystemParametersInfo. + # SPI_SETDESKWALLPAPER = 0x0014; SPIF_UPDATEINIFILE | SPIF_SENDCHANGE = 0x03. + if (-not ('SpiNative' -as [type])) { + Add-Type @' +using System; +using System.Runtime.InteropServices; +public class SpiNative { + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + public static extern bool SystemParametersInfo(uint a, uint b, string c, uint d); +} +'@ + } + [void][SpiNative]::SystemParametersInfo(0x14, 0, '', 0x03) +} + +function Set-ConsoleFont { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $FaceName, + [Parameter(Mandatory)] [int] $PointSize + ) + + # HKCU\Console FaceName + FontSize defaults apply to every cmd / + # powershell window opened by the current user. Per-exe overrides + # under HKCU\Console\ beat the defaults; we set both so a + # sub-shell that already tweaked one entry still picks up the + # demo font. + $sizeDword = ($PointSize -shl 16) + foreach ($subKey in @('Console', 'Console\%SystemRoot%_System32_cmd.exe', + 'Console\%SystemRoot%_System32_WindowsPowerShell_v1.0_powershell.exe')) { + $path = "HKCU:\$subKey" + if (-not (Test-Path $path)) { + New-Item -Path $path -Force | Out-Null + } + Set-ItemProperty -Path $path -Name 'FaceName' -Value $FaceName + Set-ItemProperty -Path $path -Name 'FontFamily' -Value 0x36 + Set-ItemProperty -Path $path -Name 'FontWeight' -Value 0x190 + Set-ItemProperty -Path $path -Name 'FontSize' -Value $sizeDword ` + -Type DWord + } +} + +function Set-DpiScaleHundred { + # 96 DPI = 100 % scale. The HKCU per-monitor key is enough on + # Windows Sandbox; physical workstations may need a sign-out. + Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' ` + -Name 'LogPixels' -Value 96 -Type DWord + Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' ` + -Name 'Win8DpiScaling' -Value 0 -Type DWord +} + +function Set-DesktopChromeOff { + # Hide desktop icons, disable taskbar auto-hide animation. Both + # are HKCU keys read by Explorer at sign-in. + $advanced = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + if (-not (Test-Path $advanced)) { + New-Item -Path $advanced -Force | Out-Null + } + Set-ItemProperty -Path $advanced -Name 'HideIcons' -Value 1 -Type DWord + Set-ItemProperty -Path $advanced -Name 'TaskbarAnimations' -Value 0 -Type DWord +} + +# --- Apply ---------------------------------------------------------------- + +Set-SolidWallpaper -HexColor '#0F1419' +Set-ConsoleFont -FaceName 'Cascadia Mono' -PointSize 18 +Set-DpiScaleHundred +Set-DesktopChromeOff + +Write-Host 'setup-desktop.ps1: applied csshw demo desktop normalisation.' diff --git a/xtask/src/demo/bin.rs b/xtask/src/demo/bin.rs new file mode 100644 index 00000000..39985918 --- /dev/null +++ b/xtask/src/demo/bin.rs @@ -0,0 +1,237 @@ +//! Vendored binary management for the `record-demo` recorder. +//! +//! v0 expected `ffmpeg` and `gifski` on `PATH`. v1 ships SHA-pinned +//! download URLs for ffmpeg, gifski, and Carnac, fetches them once +//! into `target/demo/bin//`, verifies the SHA-256 of every +//! download against the constants in this module, and extracts the +//! archive into a deterministic on-disk layout that +//! [`crate::demo::recorder`] and the sandbox bootstrap can rely on. +//! +//! # Cache layout +//! +//! ```text +//! target/demo/bin/ +//! ffmpeg//bin/ffmpeg.exe # Gyan ffmpeg essentials zip +//! gifski/win/gifski.exe # gifski release tar.xz +//! carnac/lib/net45/Carnac.exe # Carnac release zip (nested) +//! ``` +//! +//! Where `` is `ffmpeg--essentials_build`. The expected +//! relative paths inside each install are encoded in [`Pin::exe_rel`] +//! so a refresh that changes the upstream archive layout shows up as +//! a clear "binary missing after extract" error. +//! +//! # Pin refresh process +//! +//! 1. Download the new archive from the candidate URL. +//! 2. `Get-FileHash -Algorithm SHA256 ` (PowerShell) and +//! paste the lower-case hex digest into [`FFMPEG`], [`GIFSKI`], +//! or [`CARNAC`]. +//! 3. Bump the cached top-level directory name in [`Pin::cache_dir`] +//! and (if the upstream layout changed) [`Pin::exe_rel`]. +//! 4. Run `cargo xtask record-demo --env local --no-record` once on +//! a clean checkout to confirm the cache populates without a +//! SHA-mismatch error, then commit. +//! +//! All side effects (download, sha256, extract, fs) flow through the +//! [`DemoSystem`](crate::demo::DemoSystem) trait so unit tests +//! exercise this module against `mockall`-generated mocks with zero +//! network or filesystem effects. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::DemoSystem; + +/// Pinned upstream archive plus the on-disk layout it expands into. +/// +/// The pin set is hard-coded so a `cargo xtask record-demo` run that +/// hits a tampered network or stale CDN entry fails loudly with a +/// SHA mismatch instead of silently using a different binary. +pub struct Pin { + /// Human-readable name used for the `target/demo/bin//` + /// cache subdirectory and in log messages. + pub name: &'static str, + /// Direct download URL for the upstream release archive. + pub url: &'static str, + /// Expected lower-case hex SHA-256 digest of the archive. + pub sha256: &'static str, + /// File name to use for the downloaded archive (preserves the + /// extension so [`DemoSystem::extract_archive`] can dispatch). + pub archive_name: &'static str, + /// Path to the extracted entry binary, expressed relative to + /// `target/demo/bin//`. After + /// [`ensure_pin`] returns, this path is guaranteed to exist. + pub exe_rel: &'static str, + /// Optional inner archive that must be extracted from the outer + /// download to expose the entry binary. Used for Carnac, whose + /// release zip wraps a NuGet package. + pub inner_archive: Option<&'static str>, +} + +impl Pin { + /// Cache directory for this pin under + /// `target/demo/bin//`. + fn cache_dir(&self, bin_root: &Path) -> PathBuf { + bin_root.join(self.name) + } +} + +/// FFmpeg pin. Gyan's "essentials" build is the standard Windows +/// distribution: a static build with the codecs we need +/// (ffvhuff for lossless capture; libx264 not used) and no shared +/// runtime DLL dependencies. +pub const FFMPEG: Pin = Pin { + name: "ffmpeg", + url: "https://github.com/GyanD/codexffmpeg/releases/download/8.1.1/ffmpeg-8.1.1-essentials_build.zip", + sha256: "6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541", + archive_name: "ffmpeg-8.1.1-essentials_build.zip", + exe_rel: "ffmpeg-8.1.1-essentials_build/bin/ffmpeg.exe", + inner_archive: None, +}; + +/// gifski pin. Upstream ships a single tar.xz containing static +/// per-platform binaries; the Windows binary lives at `win/gifski.exe` +/// inside the archive. +pub const GIFSKI: Pin = Pin { + name: "gifski", + url: "https://github.com/ImageOptim/gifski/releases/download/1.34.0/gifski-1.34.0.tar.xz", + sha256: "b9b6591aa163123d737353d9c8581efdf3234d28eeaa45329b31da905cd5a996", + archive_name: "gifski-1.34.0.tar.xz", + exe_rel: "win/gifski.exe", + inner_archive: None, +}; + +/// Carnac pin. The MIT-licensed keystroke overlay used inside the +/// sandbox so the recording shows what keys the demo is sending. +/// The release zip wraps a NuGet package (Squirrel installer payload); +/// [`ensure_pin`] extracts the outer zip then the inner nupkg so the +/// final layout exposes `lib/net45/Carnac.exe` directly. +pub const CARNAC: Pin = Pin { + name: "carnac", + url: "https://github.com/Code52/carnac/releases/download/2.3.13/carnac.2.3.13.zip", + sha256: "989819ac562c2d3dd717eca2fe41f264c23a929d4ab29a9777e9512811089117", + archive_name: "carnac.2.3.13.zip", + exe_rel: "lib/net45/Carnac.exe", + inner_archive: Some("carnac-2.3.13-full.nupkg"), +}; + +/// Resolved paths to the cached vendored binaries used by the +/// recorder. +/// +/// The Carnac binary is also downloaded by [`ensure_bins`] (it is +/// the keystroke overlay the sandbox bootstrap launches), but its +/// host-side path never crosses back into Rust: the sandbox +/// bootstrap script references it via the canonical sandbox-side +/// mount path. Same for the bin-root directory itself, which is +/// passed to the sandbox via `xtask/src/demo/env/sandbox.rs`'s +/// own layout struct. +#[derive(Debug, Clone)] +pub struct BinSet { + /// Absolute path to ffmpeg.exe. + pub ffmpeg: PathBuf, + /// Absolute path to gifski.exe. + pub gifski: PathBuf, +} + +/// Ensure ffmpeg, gifski, and Carnac are present and SHA-verified +/// under `bin_root`. +/// +/// On a cold cache the function downloads each archive, verifies its +/// SHA-256, and extracts it into the per-pin cache directory. On a +/// warm cache (entry binary already present) it returns immediately. +/// +/// # Arguments +/// +/// * `system` - injected I/O provider; mocked in tests. +/// * `bin_root` - cache root, normally +/// `/target/demo/bin/`. +/// +/// # Errors +/// +/// Returns an error when a download fails, a SHA mismatches, an +/// archive cannot be extracted, or the expected entry binary is +/// missing after extraction. +pub fn ensure_bins(system: &S, bin_root: &Path) -> Result { + system.ensure_dir(bin_root)?; + let ffmpeg = ensure_pin(system, &FFMPEG, bin_root)?; + let gifski = ensure_pin(system, &GIFSKI, bin_root)?; + // Carnac is downloaded for the sandbox overlay but never + // referenced from Rust; the bootstrap script uses its + // canonical sandbox-side mount path. + ensure_pin(system, &CARNAC, bin_root)?; + Ok(BinSet { ffmpeg, gifski }) +} + +/// Materialise a single pin and return the absolute path to its +/// entry binary. +/// +/// The fast path: if the entry binary already exists, return it +/// without contacting the network. The slow path: download to a +/// temporary `.archive` file alongside the cache dir, verify the +/// SHA, extract, then (for [`Pin::inner_archive`]) extract the inner +/// archive over the same destination. +pub fn ensure_pin(system: &S, pin: &Pin, bin_root: &Path) -> Result { + let cache = pin.cache_dir(bin_root); + let exe = cache.join(pin.exe_rel); + if system.path_exists(&exe) { + system.print_debug(&format!("bin: {} cache hit at {}", pin.name, exe.display())); + return Ok(exe); + } + system.ensure_dir(&cache)?; + let archive = cache.join(pin.archive_name); + system.print_info(&format!("bin: downloading {} from {}", pin.name, pin.url)); + system.http_download(pin.url, &archive)?; + let actual = system + .sha256_file(&archive) + .with_context(|| format!("hashing {}", archive.display()))?; + if !sha256_eq(&actual, pin.sha256) { + bail!( + "bin: SHA-256 mismatch for {} ({}): expected {}, got {}", + pin.name, + archive.display(), + pin.sha256, + actual + ); + } + system.print_debug(&format!( + "bin: {} sha256 verified ({})", + pin.name, pin.sha256 + )); + system.extract_archive(&archive, &cache)?; + if let Some(inner) = pin.inner_archive { + let inner_path = cache.join(inner); + if !system.path_exists(&inner_path) { + bail!( + "bin: inner archive {} missing after extracting {}", + inner_path.display(), + archive.display() + ); + } + system.extract_archive(&inner_path, &cache)?; + } + if !system.path_exists(&exe) { + bail!( + "bin: expected entry binary {} missing after extracting {}", + exe.display(), + pin.name + ); + } + Ok(exe) +} + +/// Case-insensitive SHA-256 hex comparison. Pin constants are +/// committed lower-case but PowerShell's `Get-FileHash` returns +/// upper-case digests; tolerating either avoids a class of "wrong +/// case in the pin" foot-guns when refreshing the constants. +fn sha256_eq(a: &str, b: &str) -> bool { + a.len() == b.len() + && a.bytes() + .zip(b.bytes()) + .all(|(x, y)| x.eq_ignore_ascii_case(&y)) +} + +#[cfg(test)] +#[path = "../tests/test_demo_bin.rs"] +mod tests; diff --git a/xtask/src/demo/env/mod.rs b/xtask/src/demo/env/mod.rs index ad8bdd62..f60ec90f 100644 --- a/xtask/src/demo/env/mod.rs +++ b/xtask/src/demo/env/mod.rs @@ -3,6 +3,9 @@ //! Each submodule is responsible for preparing the recording //! environment (config override, fake homes, optional desktop //! normalisation) and then handing control to -//! [`crate::demo::driver::run`]. v0 ships only [`local`]. +//! [`crate::demo::driver::run`] - directly (`local`) or through a +//! booted Windows Sandbox VM (`sandbox`). v2 will add a `ci_runner` +//! provider for GitHub-hosted `windows-2022`. pub mod local; +pub mod sandbox; diff --git a/xtask/src/demo/env/sandbox.rs b/xtask/src/demo/env/sandbox.rs new file mode 100644 index 00000000..5304bcbe --- /dev/null +++ b/xtask/src/demo/env/sandbox.rs @@ -0,0 +1,319 @@ +//! Sandbox environment provider: run the demo inside a fresh +//! Windows Sandbox VM with a normalised desktop and an optional +//! Carnac keystroke overlay. +//! +//! v1's hermetic recording path. The host: +//! +//! 1. Ensures `target/demo/bin/` is populated (vendored ffmpeg, +//! gifski, Carnac with SHA verification) via +//! [`crate::demo::bin::ensure_bins`]. +//! 2. Builds `target/demo/csshw-demo.wsb` from a string template +//! that mounts the workspace (read-only), the bin cache +//! (read-only), `xtask/demo-assets/` (read-only), and a +//! writable output folder (`target/demo/out/`) into known paths +//! inside the sandbox. +//! 3. Launches the sandbox via +//! [`DemoSystem::spawn_sandbox`](crate::demo::DemoSystem::spawn_sandbox). +//! The `LogonCommand` runs `sandbox-bootstrap.ps1`, which sources +//! `setup-desktop.ps1`, optionally launches Carnac, builds csshw, +//! invokes `xtask record-demo --env local`, copies the resulting +//! GIF to `C:\demo\out\csshw.gif`, and writes a sentinel +//! `C:\demo\out\done.flag` with the exit status before shutting +//! the sandbox VM down. +//! 4. Polls the host-side mount for `done.flag`, copies the GIF +//! back to the user-requested path, and tears the sandbox down. +//! +//! Windows Sandbox is unavailable on GitHub-hosted runners (no +//! nested virtualisation), so this provider is the local-iteration +//! path. The `ci_runner` provider in v2 will own the canonical +//! recording path on `windows-2022`. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; + +use crate::demo::{bin, DemoSystem}; + +/// Sandbox-side root for everything we mount. +const SANDBOX_ROOT: &str = "C:\\demo"; + +/// Sandbox-side mount points. Hard-coded so the bootstrap script +/// (PowerShell, no command-line plumbing) can reference them. +const SANDBOX_REPO: &str = "C:\\demo\\repo"; +const SANDBOX_BIN: &str = "C:\\demo\\bin"; +const SANDBOX_ASSETS: &str = "C:\\demo\\assets"; +const SANDBOX_OUT: &str = "C:\\demo\\out"; + +/// Sentinel file the in-sandbox bootstrap writes once it has +/// finished (successfully or otherwise). Its content is the literal +/// text `ok` on success, or `error: ` on failure. +const SENTINEL_NAME: &str = "done.flag"; + +/// File name the bootstrap copies the recorded GIF to. Decoupled +/// from the host-side `out_gif` argument so callers can choose any +/// destination without leaking that path into the sandbox. +const SANDBOX_GIF_NAME: &str = "csshw.gif"; + +/// Hard ceiling on how long we wait for the sentinel to appear. +/// Sandbox boot + cargo build + 5-second capture + gifski encode +/// fits comfortably in 8 minutes even on a cold cache; longer than +/// that suggests the bootstrap itself wedged. +const SENTINEL_TIMEOUT: Duration = Duration::from_secs(8 * 60); + +/// Poll interval for [`wait_for_sentinel`]. Quick enough that the +/// host loop wakes up promptly when the sandbox writes the file; +/// slow enough not to hammer NTFS. +const SENTINEL_POLL: Duration = Duration::from_millis(500); + +/// Resolved layout of the demo working tree on the host. Returned +/// by [`prepare_layout`] so [`run`] and the unit tests share the +/// path-building code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxLayout { + /// Absolute workspace root. + pub workspace: PathBuf, + /// `/target/demo/`. + pub demo_root: PathBuf, + /// `/target/demo/bin/`. + pub bin_dir: PathBuf, + /// `/xtask/demo-assets/`. + pub assets_dir: PathBuf, + /// `/target/demo/out/`. + pub out_dir: PathBuf, + /// `/target/demo/csshw-demo.wsb`. + pub wsb_path: PathBuf, + /// Host path of the sentinel file the bootstrap writes. + pub sentinel: PathBuf, + /// Host path the bootstrap copies the recorded GIF to (under + /// the writable mount). + pub sandbox_gif: PathBuf, +} + +/// Resolve every host-side path the sandbox provider needs. +/// +/// Pure path arithmetic: no I/O, no trait calls. Kept separate so +/// the unit tests assert mount layout without setting up filesystem +/// mocks. +pub fn prepare_layout(workspace: &Path) -> SandboxLayout { + let demo_root = workspace.join("target").join("demo"); + let out_dir = demo_root.join("out"); + SandboxLayout { + workspace: workspace.to_path_buf(), + demo_root: demo_root.clone(), + bin_dir: demo_root.join("bin"), + assets_dir: workspace.join("xtask").join("demo-assets"), + out_dir: out_dir.clone(), + wsb_path: demo_root.join("csshw-demo.wsb"), + sentinel: out_dir.join(SENTINEL_NAME), + sandbox_gif: out_dir.join(SANDBOX_GIF_NAME), + } +} + +/// Build the `.wsb` XML body that boots the demo. +/// +/// Five mount points are pinned to fixed sandbox-side paths so the +/// bootstrap PowerShell script can hard-code them without command- +/// line plumbing: +/// +/// | Host path | Sandbox path | RO | +/// |----------------------------------------|------------------|-----| +/// | `` | [`SANDBOX_REPO`] | yes | +/// | `/target/demo/bin` | [`SANDBOX_BIN`] | yes | +/// | `/xtask/demo-assets` | [`SANDBOX_ASSETS`]| yes | +/// | `/target/demo/out` | [`SANDBOX_OUT`] | no | +/// +/// `` is intentionally not set: as of Windows 11 23H2 +/// the sandbox config schema does not expose a stable resolution +/// element. The bootstrap script normalises the desktop (1920x1080, +/// 100 % scale, wallpaper, console font) by sourcing +/// `setup-desktop.ps1` after first sign-in, which is the only place +/// these settings reliably apply. +/// +/// `no_overlay` is forwarded to the bootstrap via a positional +/// argument so the same `.wsb` template covers both code paths. +pub fn render_wsb(layout: &SandboxLayout, no_overlay: bool) -> String { + let overlay_arg = if no_overlay { "-NoOverlay" } else { "" }; + // The bootstrap is run via `cmd /c powershell ...` because + // Windows Sandbox's `` runs in a non-interactive shell + // where `powershell.exe` direct invocation occasionally races + // the user-profile mount. + let bootstrap = format!( + "cmd.exe /c \"powershell -NoProfile -ExecutionPolicy Bypass \ + -File {SANDBOX_ASSETS}\\sandbox-bootstrap.ps1 {overlay_arg}\"" + ); + format!( + "\r\n\ + \x20\x20Disable\r\n\ + \x20\x20Default\r\n\ + \x20\x20Disable\r\n\ + \x20\x20Disable\r\n\ + \x20\x20Enable\r\n\ + \x20\x20\r\n\ + {repo}\ + {bins}\ + {assets}\ + {out}\ + \x20\x20\r\n\ + \x20\x20\r\n\ + \x20\x20\x20\x20{bootstrap}\r\n\ + \x20\x20\r\n\ + \r\n", + repo = mapped_folder(&layout.workspace, SANDBOX_REPO, true), + bins = mapped_folder(&layout.bin_dir, SANDBOX_BIN, true), + assets = mapped_folder(&layout.assets_dir, SANDBOX_ASSETS, true), + out = mapped_folder(&layout.out_dir, SANDBOX_OUT, false), + ) +} + +/// Render one `` block. +/// +/// The host path is emitted via `Display`, which on Windows uses +/// backslashes. XML escaping is intentionally minimal: paths cannot +/// contain `<`, `>`, `&`, or `"` on Windows, so we sidestep those +/// cases entirely. +fn mapped_folder(host: &Path, sandbox: &str, read_only: bool) -> String { + let ro = if read_only { "true" } else { "false" }; + format!( + "\x20\x20\x20\x20\r\n\ + \x20\x20\x20\x20\x20\x20{}\r\n\ + \x20\x20\x20\x20\x20\x20{sandbox}\r\n\ + \x20\x20\x20\x20\x20\x20{ro}\r\n\ + \x20\x20\x20\x20\r\n", + host.display() + ) +} + +/// Block until `sentinel` exists, then return its contents. +/// +/// Polls [`DemoSystem::path_exists`] every [`SENTINEL_POLL`] until +/// either the file appears or [`SENTINEL_TIMEOUT`] elapses. Uses +/// [`DemoSystem::sleep`] so unit tests can short-circuit the wait. +/// +/// # Errors +/// +/// Returns an error on timeout, including the elapsed duration so +/// the user can distinguish "sandbox never booted" from "demo took +/// too long" by checking the host-side log. +pub fn wait_for_sentinel(system: &S, sentinel: &Path) -> Result<()> { + let start = Instant::now(); + let deadline = start + SENTINEL_TIMEOUT; + loop { + if system.path_exists(sentinel) { + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "sandbox sentinel {} did not appear within {:?}; \ + the in-sandbox bootstrap likely wedged", + sentinel.display(), + SENTINEL_TIMEOUT + ); + } + system.sleep(SENTINEL_POLL); + } +} + +/// Prepare and run the demo inside a fresh Windows Sandbox VM. +/// +/// # Arguments +/// +/// * `system` - the [`DemoSystem`]. +/// * `out_gif` - host-side destination GIF; the bootstrap always +/// writes its GIF to the sandbox-mounted out folder, so this +/// function copies the result to `out_gif` after the sandbox +/// exits. +/// * `no_record` - currently forwarded only to the host-side log. +/// The in-sandbox xtask call is what actually skips capture; v1 +/// keeps that wiring local to the bootstrap script for simplicity. +/// * `no_overlay` - skip the Carnac overlay inside the sandbox. +/// +/// # Errors +/// +/// Returns an error when the bin cache cannot be populated, the +/// `.wsb` cannot be written, the sandbox fails to launch, the +/// sentinel times out, or the bootstrap reports a non-`ok` +/// completion status. +pub fn run( + system: &S, + out_gif: &Path, + no_record: bool, + no_overlay: bool, +) -> Result<()> { + let workspace = system.workspace_root()?; + let layout = prepare_layout(&workspace); + system.print_info(&format!( + "sandbox env: workspace={} no_record={no_record} no_overlay={no_overlay}", + layout.workspace.display(), + )); + + // Ensure the vendored binaries are present on the host before + // we mount them read-only into the sandbox. The sandbox cannot + // populate this cache itself: its network is sandboxed and the + // download would have to repeat on every run. + bin::ensure_bins(system, &layout.bin_dir) + .with_context(|| "preparing target/demo/bin/ for sandbox mount")?; + + // Wipe leftover sentinels and GIFs from previous runs so the + // poll loop can use plain "exists" without a timestamp check. + system.ensure_dir(&layout.out_dir)?; + if system.path_exists(&layout.sentinel) { + system.print_debug(&format!( + "sandbox env: removing stale sentinel {}", + layout.sentinel.display() + )); + std::fs::remove_file(&layout.sentinel).with_context(|| { + format!( + "failed to clear stale sentinel {}", + layout.sentinel.display() + ) + })?; + } + if system.path_exists(&layout.sandbox_gif) { + std::fs::remove_file(&layout.sandbox_gif).with_context(|| { + format!( + "failed to clear stale sandbox-side gif {}", + layout.sandbox_gif.display() + ) + })?; + } + + let wsb = render_wsb(&layout, no_overlay); + system.write_file(&layout.wsb_path, &wsb)?; + system.print_info(&format!( + "sandbox env: wrote {} (mount root {SANDBOX_ROOT})", + layout.wsb_path.display() + )); + + system.spawn_sandbox(&layout.wsb_path)?; + let result = (|| -> Result<()> { + wait_for_sentinel(system, &layout.sentinel)?; + let status = std::fs::read_to_string(&layout.sentinel) + .with_context(|| format!("reading sentinel {}", layout.sentinel.display()))?; + let status_trim = status.trim(); + if status_trim != "ok" { + bail!("sandbox bootstrap reported non-ok status: {}", status_trim); + } + if !system.path_exists(&layout.sandbox_gif) { + bail!( + "sandbox reported success but {} is missing", + layout.sandbox_gif.display() + ); + } + system.copy_file(&layout.sandbox_gif, out_gif)?; + system.print_info(&format!( + "sandbox env: copied recorded GIF to {}", + out_gif.display() + )); + Ok(()) + })(); + + if let Err(e) = system.terminate_sandbox() { + system.print_debug(&format!("terminate_sandbox failed: {e}")); + } + result +} + +#[cfg(test)] +#[path = "../../tests/test_demo_env_sandbox.rs"] +mod tests; diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index bba8c71e..521fbbca 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -9,15 +9,23 @@ //! subprocess spawning, sleeps). Tests mock [`DemoSystem`] to assert //! step semantics with zero real-system effects. //! -//! v0 scope: a single `--env local` provider that runs on the caller's -//! own desktop (no isolation) and a hard-coded canonical script that -//! launches `csshw alpha bravo`, types a broadcast command, and stops. -//! Sandbox + Carnac + visual normalisation arrive in v1; CI workflows -//! and the orphan-branch publish flow arrive in v2; the full -//! control-mode + vim + ping scene arrives in v3. +//! v1 scope: two `--env` providers (`local` and `sandbox`) sharing +//! the v0 hard-coded canonical script that launches `csshw alpha +//! bravo`, types a broadcast command, and stops. The recorder uses +//! SHA-pinned vendored ffmpeg + gifski + Carnac (downloaded once +//! into `target/demo/bin/` and verified by [`bin::ensure_bins`]), +//! so a developer no longer needs ffmpeg, gifski, or Carnac on +//! `PATH`. The sandbox provider boots the demo inside a fresh +//! Windows Sandbox VM with a normalised desktop (wallpaper, console +//! font, DPI) and an optional Carnac keystroke overlay; Sandbox +//! cannot run on GitHub-hosted runners (no nested virtualisation), +//! so v1 is the local-iteration path. CI workflows and the +//! orphan-branch publish flow arrive in v2; the full control-mode + +//! vim + ping scene arrives in v3. #![cfg_attr(coverage_nightly, coverage(off))] +pub mod bin; pub mod config_override; pub mod driver; pub mod dsl; @@ -43,6 +51,14 @@ pub enum DemoEnv { /// No isolation - the caller is expected to step away while the /// demo records. Local, + /// Run inside a fresh Windows Sandbox VM. Mounts the workspace + /// read-only, mounts a writable output folder for the GIF, mounts + /// the cached vendored binaries, and runs the demo via a + /// `LogonCommand` that boots + /// `xtask/demo-assets/sandbox-bootstrap.ps1`. Cannot run on + /// GitHub-hosted runners because they lack nested virtualisation; + /// `--env ci-runner` (v2) is the canonical recording path. + Sandbox, } /// One top-level window snapshot returned by [`DemoSystem::enum_windows`]. @@ -132,6 +148,40 @@ pub trait DemoSystem { /// (frame extraction + gifski), and produce `out_gif`. fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> Result<()>; + /// Return `true` when `path` exists on the host filesystem. Used + /// for cache hits in [`bin`] and for the sandbox sentinel poll in + /// [`env::sandbox`]. + fn path_exists(&self, path: &Path) -> bool; + + /// Return the size of `path` in bytes. Used by the recorder to + /// poll until ffmpeg has written its first capture frames. + fn file_size(&self, path: &Path) -> Result; + + /// Download `url` to `dest`, replacing any existing file. Failure + /// to fetch (HTTP error, redirect loop, transport error) returns + /// an error. + fn http_download(&self, url: &str, dest: &Path) -> Result<()>; + + /// Compute the lower-case hex SHA-256 digest of `path`. + fn sha256_file(&self, path: &Path) -> Result; + + /// Extract `archive` into `dest_dir`. Supports `.zip` and + /// `.tar.xz` based on the file's extension. The destination is + /// created if it does not exist; existing contents are not + /// removed (callers are expected to extract into a clean + /// directory). + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> Result<()>; + + /// Launch `WindowsSandbox.exe` against `wsb_path`. Production + /// impl tracks the child internally so + /// [`terminate_sandbox`](Self::terminate_sandbox) can shut it + /// down on cleanup. + fn spawn_sandbox(&self, wsb_path: &Path) -> Result<()>; + + /// Best-effort terminate the in-flight Windows Sandbox process. + /// Idempotent. + fn terminate_sandbox(&self) -> Result<()>; + /// Print an informational message to stdout. fn print_info(&self, message: &str); @@ -141,13 +191,15 @@ pub trait DemoSystem { /// Production implementation of [`DemoSystem`]. /// -/// Holds two long-lived child processes between method calls: -/// the in-flight ffmpeg gdigrab capture, and the spawned csshw -/// daemon. All Windows-API calls live in the `windows_input` private -/// module behind `cfg(target_os = "windows")`. +/// Holds three long-lived child processes between method calls: +/// the in-flight ffmpeg gdigrab capture, the spawned csshw daemon, +/// and (for `--env sandbox`) the WindowsSandbox.exe host. All +/// Windows-API calls live in the `windows_input` private module +/// behind `cfg(target_os = "windows")`. pub struct RealSystem { capture: std::sync::Mutex>, csshw: std::sync::Mutex>, + sandbox: std::sync::Mutex>, } impl RealSystem { @@ -156,6 +208,7 @@ impl RealSystem { Self { capture: std::sync::Mutex::new(None), csshw: std::sync::Mutex::new(None), + sandbox: std::sync::Mutex::new(None), } } } @@ -253,23 +306,172 @@ impl DemoSystem for RealSystem { } fn start_recording(&self, out_raw: &Path) -> Result<()> { + let workspace = self.workspace_root()?; + let bin_dir = workspace.join("target").join("demo").join("bin"); + let bins = bin::ensure_bins(self, &bin_dir)?; let mut slot = self.capture.lock().expect("capture mutex poisoned"); if slot.is_some() { anyhow::bail!("start_recording called while a capture is already running"); } - let child = recorder::spawn_ffmpeg_gdigrab(out_raw)?; + let child = recorder::spawn_ffmpeg_gdigrab(&bins.ffmpeg, out_raw)?; + // Block until ffmpeg has actually started writing frames so + // the demo's first keystrokes are captured. The trait `sleep` + // and `file_size` are used so tests can short-circuit. + recorder::wait_for_capture_baseline(self, out_raw)?; *slot = Some(child); Ok(()) } fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> Result<()> { + let workspace = self.workspace_root()?; + let bin_dir = workspace.join("target").join("demo").join("bin"); + let bins = bin::ensure_bins(self, &bin_dir)?; let child = self .capture .lock() .expect("capture mutex poisoned") .take() .ok_or_else(|| anyhow::anyhow!("stop_recording called with no active capture"))?; - recorder::stop_ffmpeg_and_encode(child, out_raw, out_gif) + recorder::stop_ffmpeg_and_encode(child, &bins.ffmpeg, &bins.gifski, out_raw, out_gif) + } + + fn path_exists(&self, path: &Path) -> bool { + path.exists() + } + + fn file_size(&self, path: &Path) -> Result { + std::fs::metadata(path) + .map(|m| m.len()) + .map_err(|e| anyhow::anyhow!("failed to stat {}: {e}", path.display())) + } + + fn http_download(&self, url: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + self.ensure_dir(parent)?; + } + // Use PowerShell's Invoke-WebRequest so we inherit the OS's + // TLS root store and avoid pulling a Rust HTTP client into + // xtask. `-UseBasicParsing` skips the IE engine warm-up; the + // first run on a fresh sandbox would otherwise prompt for IE + // first-launch configuration. Single-quoted PS strings keep + // backslashes in `dest` literal. + let dest_str = dest.to_string_lossy().replace('\'', "''"); + let url_str = url.replace('\'', "''"); + let script = format!( + "$ProgressPreference='SilentlyContinue';\ + [Net.ServicePointManager]::SecurityProtocol=\ + [Net.SecurityProtocolType]::Tls12;\ + Invoke-WebRequest -UseBasicParsing -Uri '{url_str}' -OutFile '{dest_str}'" + ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn powershell for download: {e}"))?; + if !status.success() { + anyhow::bail!("powershell Invoke-WebRequest {url} -> {dest:?} failed: {status}"); + } + Ok(()) + } + + fn sha256_file(&self, path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut file = std::fs::File::open(path) + .map_err(|e| anyhow::anyhow!("failed to open {} for hashing: {e}", path.display()))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher) + .map_err(|e| anyhow::anyhow!("failed to read {} for hashing: {e}", path.display()))?; + let digest = hasher.finalize(); + Ok(digest.iter().map(|b| format!("{b:02x}")).collect()) + } + + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> Result<()> { + self.ensure_dir(dest_dir)?; + let name = archive + .file_name() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if name.ends_with(".tar.xz") || name.ends_with(".tar.gz") || name.ends_with(".tar") { + // Windows 10 1803+ ships BSD tar.exe on PATH. We invoke it + // with `-C` so the destination is unambiguous. + let status = std::process::Command::new("tar") + .arg("-xf") + .arg(archive) + .arg("-C") + .arg(dest_dir) + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn tar: {e}"))?; + if !status.success() { + anyhow::bail!("tar -xf {} failed: {status}", archive.display()); + } + return Ok(()); + } + if name.ends_with(".zip") || name.ends_with(".nupkg") { + let archive_str = archive.to_string_lossy().replace('\'', "''"); + let dest_str = dest_dir.to_string_lossy().replace('\'', "''"); + // Expand-Archive is idempotent only with -Force; we + // already create the dest fresh in callers so -Force is + // safe. + let script = format!( + "$ProgressPreference='SilentlyContinue';\ + Expand-Archive -Force -LiteralPath '{archive_str}' \ + -DestinationPath '{dest_str}'" + ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn powershell for extract: {e}"))?; + if !status.success() { + anyhow::bail!("Expand-Archive {} failed: {status}", archive.display()); + } + return Ok(()); + } + anyhow::bail!( + "extract_archive: unsupported archive extension for {}", + archive.display() + ) + } + + fn spawn_sandbox(&self, wsb_path: &Path) -> Result<()> { + let mut slot = self.sandbox.lock().expect("sandbox mutex poisoned"); + if slot.is_some() { + anyhow::bail!("spawn_sandbox called while a sandbox is already running"); + } + let child = std::process::Command::new("WindowsSandbox.exe") + .arg(wsb_path) + .spawn() + .map_err(|e| { + anyhow::anyhow!( + "failed to spawn WindowsSandbox.exe. Enable the \ + \"Windows Sandbox\" optional feature first \ + (`Enable-WindowsOptionalFeature -Online \ + -FeatureName Containers-DisposableClientVM`): {e}" + ) + })?; + *slot = Some(child); + Ok(()) + } + + fn terminate_sandbox(&self) -> Result<()> { + if let Some(mut child) = self.sandbox.lock().expect("sandbox mutex poisoned").take() { + let _ = child.kill(); + let _ = child.wait(); + } + // Belt-and-braces: WindowsSandbox.exe is the launcher, but + // the sandbox VM itself is hosted by `vmcompute` and the + // user-facing `WindowsSandboxClient.exe`. A stale client + // can outlive the launcher. Best-effort taskkill mirrors + // [`Self::terminate_csshw`]. + let _ = std::process::Command::new("taskkill") + .args(["/IM", "WindowsSandboxClient.exe", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let _ = std::process::Command::new("taskkill") + .args(["/IM", "WindowsSandbox.exe", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Ok(()) } fn print_info(&self, message: &str) { @@ -320,6 +522,7 @@ pub fn record_demo( )); match env { DemoEnv::Local => env::local::run(system, &script, &out, no_record)?, + DemoEnv::Sandbox => env::sandbox::run(system, &out, no_record, no_overlay)?, } Ok(()) } diff --git a/xtask/src/demo/recorder.rs b/xtask/src/demo/recorder.rs index 5bb51429..fdcefeaa 100644 --- a/xtask/src/demo/recorder.rs +++ b/xtask/src/demo/recorder.rs @@ -7,19 +7,35 @@ //! -> PNG frames in `target/demo/frames/` //! 3. `gifski` -> the final `.gif` //! -//! v0 expects `ffmpeg` and `gifski` on `PATH`. v1 will SHA-pin -//! vendored binaries downloaded into `target/demo/bin/`. +//! v1 invokes the SHA-pinned vendored binaries cached under +//! `target/demo/bin/` by [`crate::demo::bin::ensure_bins`]. The exe +//! paths are passed in by [`crate::demo::RealSystem`] so `recorder` +//! itself stays a side-effect-free orchestrator from the tests' +//! perspective (the actual `Command::status()` calls are mock-free +//! because `RealSystem` is the only caller). //! -//! These free functions are called from [`crate::demo::RealSystem`]. -//! They are kept out of the [`crate::demo::DemoSystem`] trait so the -//! trait can be mocked without dragging in `std::process::Child`. +//! # Capture readiness +//! +//! ffmpeg's gdigrab takes a non-trivial amount of time to bring up +//! the screen-grabber the first time and to write the .mkv header. +//! Sending input before the header is written produces a recording +//! whose first frames are missing the action that just happened. +//! [`wait_for_capture_baseline`] polls +//! [`DemoSystem::file_size`](crate::demo::DemoSystem::file_size) +//! until ffmpeg has written enough bytes to guarantee the capture +//! pipeline is live, with a generous timeout. The DSL stays unaware +//! of this readiness contract: the script just emits `StartCapture` +//! and trusts the recorder. use std::io::Write; use std::path::Path; use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; +use super::DemoSystem; + /// Capture resolution and framerate. Pinned to keep recordings /// identical across developer machines and CI runners. const CAPTURE_FRAMERATE: &str = "30"; @@ -31,16 +47,37 @@ const ENCODE_FPS: &str = "20"; const ENCODE_WIDTH: &str = "1280"; const ENCODE_QUALITY: &str = "90"; +/// Bytes the .mkv must reach before the capture is considered live. +/// gdigrab writes a Matroska header (~600-800 bytes) plus at least +/// one frame's worth of huffyuv-encoded data before flushing. 8 KiB +/// gives us comfortable margin without being so high that we wait +/// for several frames on a slow machine. +const CAPTURE_BASELINE_BYTES: u64 = 8 * 1024; + +/// Hard ceiling on the readiness wait. ffmpeg gdigrab on a clean +/// Windows Sandbox boots in ~1-2 seconds; 15 seconds covers slow +/// disks and the Carnac overlay's first foreground steal. +const CAPTURE_BASELINE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Poll interval for [`wait_for_capture_baseline`]. +const CAPTURE_BASELINE_POLL: Duration = Duration::from_millis(100); + /// Spawn the long-running ffmpeg gdigrab capture writing to `out_raw`. /// /// Returns the child process so [`stop_ffmpeg_and_encode`] can shut it /// down cleanly via `q\n` on stdin. -pub fn spawn_ffmpeg_gdigrab(out_raw: &Path) -> Result { +/// +/// # Arguments +/// +/// * `ffmpeg_exe` - absolute path to the vendored ffmpeg.exe (see +/// [`crate::demo::bin`]). +/// * `out_raw` - destination `.mkv`; parent directories are created. +pub fn spawn_ffmpeg_gdigrab(ffmpeg_exe: &Path, out_raw: &Path) -> Result { if let Some(parent) = out_raw.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - let child = Command::new("ffmpeg") + let child = Command::new(ffmpeg_exe) .args([ "-y", "-f", @@ -59,18 +96,76 @@ pub fn spawn_ffmpeg_gdigrab(out_raw: &Path) -> Result { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .context( - "failed to spawn `ffmpeg`. v0 requires ffmpeg on PATH; \ - install via winget (`winget install Gyan.FFmpeg`) or chocolatey", - )?; + .with_context(|| { + format!( + "failed to spawn vendored ffmpeg at {}", + ffmpeg_exe.display() + ) + })?; Ok(child) } +/// Block until ffmpeg has written at least +/// [`CAPTURE_BASELINE_BYTES`] to `out_raw`, indicating the capture +/// pipeline is live and subsequent input will be recorded. +/// +/// Polls [`DemoSystem::file_size`] at [`CAPTURE_BASELINE_POLL`]; the +/// `system.sleep` is used between polls so unit tests can short- +/// circuit the wait. +/// +/// # Errors +/// +/// Returns an error when the file does not reach the baseline within +/// [`CAPTURE_BASELINE_TIMEOUT`]. The caller (the trait method +/// `start_recording`) is responsible for any teardown. +pub fn wait_for_capture_baseline(system: &S, out_raw: &Path) -> Result<()> { + let deadline = Instant::now() + CAPTURE_BASELINE_TIMEOUT; + loop { + if system.path_exists(out_raw) { + // file_size can transiently fail on Windows while ffmpeg + // holds an exclusive write handle; treat that as "not + // yet" and keep polling. + if let Ok(size) = system.file_size(out_raw) { + if size >= CAPTURE_BASELINE_BYTES { + system.print_debug(&format!( + "recorder: capture baseline reached ({size} bytes)" + )); + return Ok(()); + } + } + } + if Instant::now() >= deadline { + bail!( + "ffmpeg did not reach capture baseline ({} bytes) within {:?}; \ + was the gdigrab device available?", + CAPTURE_BASELINE_BYTES, + CAPTURE_BASELINE_TIMEOUT + ); + } + system.sleep(CAPTURE_BASELINE_POLL); + } +} + /// Stop the in-flight ffmpeg, run the frame-extract step, then gifski. /// /// `out_raw` is the lossless `.mkv` ffmpeg has been writing. /// `out_gif` is the final GIF the caller asked for. -pub fn stop_ffmpeg_and_encode(mut child: Child, out_raw: &Path, out_gif: &Path) -> Result<()> { +/// +/// # Arguments +/// +/// * `child` - the running ffmpeg gdigrab process. +/// * `ffmpeg_exe` - absolute path to the vendored ffmpeg.exe (used +/// again for the frame-extract step). +/// * `gifski_exe` - absolute path to the vendored gifski.exe. +/// * `out_raw` - the lossless `.mkv` written by `child`. +/// * `out_gif` - destination GIF path. +pub fn stop_ffmpeg_and_encode( + mut child: Child, + ffmpeg_exe: &Path, + gifski_exe: &Path, + out_raw: &Path, + out_gif: &Path, +) -> Result<()> { // Politely ask ffmpeg to flush + exit by sending `q\n` on stdin; // it converts the partial buffer into a valid container. if let Some(stdin) = child.stdin.as_mut() { @@ -100,8 +195,8 @@ pub fn stop_ffmpeg_and_encode(mut child: Child, out_raw: &Path, out_gif: &Path) std::fs::create_dir_all(&frames_dir) .with_context(|| format!("failed to create {}", frames_dir.display()))?; - // Frame extraction. - let extract_status = Command::new("ffmpeg") + // Frame extraction (vendored ffmpeg). + let extract_status = Command::new(ffmpeg_exe) .args(["-y", "-i"]) .arg(out_raw) .args([ @@ -110,18 +205,23 @@ pub fn stop_ffmpeg_and_encode(mut child: Child, out_raw: &Path, out_gif: &Path) ]) .arg(frames_dir.join("%05d.png")) .status() - .context("failed to spawn `ffmpeg` for frame extraction")?; + .with_context(|| { + format!( + "failed to spawn vendored ffmpeg at {}", + ffmpeg_exe.display() + ) + })?; if !extract_status.success() { bail!("ffmpeg frame extraction failed with {extract_status}"); } - // gifski encode. + // gifski encode (vendored gifski). if let Some(parent) = out_gif.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let frame_glob = frames_dir.join("*.png"); - let gifski_status = Command::new("gifski") + let gifski_status = Command::new(gifski_exe) .args([ "--fps", ENCODE_FPS, @@ -134,12 +234,18 @@ pub fn stop_ffmpeg_and_encode(mut child: Child, out_raw: &Path, out_gif: &Path) .arg(out_gif) .arg(frame_glob) .status() - .context( - "failed to spawn `gifski`. v0 requires gifski on PATH; \ - install via `cargo install gifski` or download from gif.ski", - )?; + .with_context(|| { + format!( + "failed to spawn vendored gifski at {}", + gifski_exe.display() + ) + })?; if !gifski_status.success() { bail!("gifski exited with {gifski_status}"); } Ok(()) } + +#[cfg(test)] +#[path = "../tests/test_demo_recorder.rs"] +mod tests; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c0467cad..5d62cd54 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -68,9 +68,16 @@ enum Command { CheckTypography, /// Record an automated demo of csshw and produce `target/demo/csshw.gif`. /// - /// v0 only supports `--env local` (runs on the caller's interactive - /// desktop session, no isolation) and requires `ffmpeg` and - /// `gifski` on PATH. + /// Two providers are wired: + /// - `--env local` runs on the caller's interactive desktop + /// session; the user must step away while it records. + /// - `--env sandbox` boots a fresh Windows Sandbox VM with a + /// normalised desktop and an optional Carnac keystroke overlay. + /// Requires the `Containers-DisposableClientVM` Windows feature. + /// + /// ffmpeg, gifski, and Carnac are SHA-pinned and downloaded into + /// `target/demo/bin/` on first use; subsequent runs hit the warm + /// cache. RecordDemo { /// Output GIF path. Defaults to /// `/target/demo/csshw.gif`. diff --git a/xtask/src/tests/test_demo_bin.rs b/xtask/src/tests/test_demo_bin.rs new file mode 100644 index 00000000..31048b64 --- /dev/null +++ b/xtask/src/tests/test_demo_bin.rs @@ -0,0 +1,337 @@ +//! Tests for the vendored binary cache module. +//! +//! All side effects (download, hash, extract, fs) flow through +//! [`crate::demo::DemoSystem`], so the cache logic in +//! [`crate::demo::bin`] is exercised against `mockall`-generated +//! mocks with zero network or filesystem effects. The tests focus +//! on the state-machine: cache hit fast path, cold-cache happy +//! path, SHA mismatch, post-extract entry-binary check, and the +//! nested-archive flow Carnac relies on. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::bin::{ensure_pin, Pin}; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +const FAKE: Pin = Pin { + name: "fake", + url: "https://example.test/fake.zip", + sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + archive_name: "fake.zip", + exe_rel: "bin/fake.exe", + inner_archive: None, +}; + +const FAKE_NESTED: Pin = Pin { + name: "fake_nested", + url: "https://example.test/outer.zip", + sha256: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + archive_name: "outer.zip", + exe_rel: "lib/net45/Inner.exe", + inner_archive: Some("inner.nupkg"), +}; + +#[test] +fn test_cache_hit_skips_download_and_extract() { + // Arrange: the entry binary is already present, so ensure_pin + // must not touch the network or invoke extract. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + mock.expect_http_download().times(0); + mock.expect_sha256_file().times(0); + mock.expect_extract_archive().times(0); + mock.expect_ensure_dir().times(0); + + // Act + let path = ensure_pin(&mock, &FAKE, Path::new("/cache")).unwrap(); + + // Assert + let s = path.display().to_string().replace('\\', "/"); + assert!(s.ends_with("fake/bin/fake.exe"), "got {s}"); +} + +#[test] +fn test_cold_cache_downloads_verifies_extracts_and_returns_path() { + // Arrange: first path_exists check (entry exe) returns false, + // second (after extract) returns true. http_download writes the + // archive, sha256 matches the pin, extract_archive succeeds. + let mut mock = quiet_mock(); + let exists_calls: Arc> = Arc::new(Mutex::new(0)); + let slot = exists_calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + // Call sequence: check entry (miss) -> ensure_dir -> download + // -> sha256 -> extract -> check entry (hit). + *n != 1 + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + let download_url: Arc>> = Arc::new(Mutex::new(None)); + let download_dest: Arc>> = Arc::new(Mutex::new(None)); + let url_slot = download_url.clone(); + let dest_slot = download_dest.clone(); + mock.expect_http_download().returning(move |u, p| { + *url_slot.lock().unwrap() = Some(u.to_string()); + *dest_slot.lock().unwrap() = Some(p.to_path_buf()); + Ok(()) + }); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_string())); + let extracted_archive: Arc>> = Arc::new(Mutex::new(None)); + let extracted_dest: Arc>> = Arc::new(Mutex::new(None)); + let arch_slot = extracted_archive.clone(); + let dest_slot2 = extracted_dest.clone(); + mock.expect_extract_archive().returning(move |a, d| { + *arch_slot.lock().unwrap() = Some(a.to_path_buf()); + *dest_slot2.lock().unwrap() = Some(d.to_path_buf()); + Ok(()) + }); + + // Act + let path = ensure_pin(&mock, &FAKE, Path::new("/cache")).unwrap(); + + // Assert + assert_eq!( + download_url.lock().unwrap().as_deref(), + Some(FAKE.url), + "downloaded from the pin URL" + ); + let dest = download_dest.lock().unwrap().clone().unwrap(); + let dest_s = dest.display().to_string().replace('\\', "/"); + assert!( + dest_s.ends_with("fake/fake.zip"), + "archive landed under cache dir: {dest_s}" + ); + let archive_arg = extracted_archive.lock().unwrap().clone().unwrap(); + assert_eq!(archive_arg, dest, "extract_archive received the download"); + let extract_dest = extracted_dest.lock().unwrap().clone().unwrap(); + let extract_s = extract_dest.display().to_string().replace('\\', "/"); + assert!( + extract_s.ends_with("/cache/fake") || extract_s.ends_with("cache/fake"), + "extract dest is the cache dir: {extract_s}" + ); + let returned = path.display().to_string().replace('\\', "/"); + assert!(returned.ends_with("fake/bin/fake.exe"), "got {returned}"); +} + +#[test] +fn test_sha_mismatch_fails_loudly_without_extracting() { + // Arrange + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file().returning(|_| { + Ok("badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad0".to_string()) + }); + mock.expect_extract_archive().times(0); + + // Act + let err = ensure_pin(&mock, &FAKE, Path::new("/cache")) + .expect_err("expected SHA mismatch") + .to_string(); + + // Assert + assert!(err.contains("SHA-256 mismatch"), "got: {err}"); + assert!(err.contains("fake"), "got: {err}"); +} + +#[test] +fn test_sha_compare_is_case_insensitive() { + // Arrange: pin is lower-case, simulator returns the upper-case + // digest PowerShell's `Get-FileHash` produces. + let mut mock = quiet_mock(); + let exists_calls: Arc> = Arc::new(Mutex::new(0)); + let slot = exists_calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n != 1 + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_uppercase())); + mock.expect_extract_archive().returning(|_, _| Ok(())); + + // Act + let res = ensure_pin(&mock, &FAKE, Path::new("/cache")); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_missing_entry_binary_after_extract_errors() { + // Arrange: SHA verifies, extract reports success, but the + // entry exe is never produced; ensure_pin must surface that + // explicitly so a stale [`Pin::exe_rel`] is loud. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_string())); + mock.expect_extract_archive().returning(|_, _| Ok(())); + + // Act + let err = ensure_pin(&mock, &FAKE, Path::new("/cache")) + .expect_err("expected entry-binary check to fail") + .to_string(); + + // Assert + assert!(err.contains("missing after extracting"), "got: {err}"); +} + +#[test] +fn test_inner_archive_is_extracted_after_outer() { + // Arrange: model the Carnac case. The first extract produces + // the inner nupkg; the second extract surfaces the entry exe. + let mut mock = quiet_mock(); + let exists_seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let exists_clone = exists_seen.clone(); + let exe_rel = FAKE_NESTED.exe_rel; + let inner_name = FAKE_NESTED.inner_archive.unwrap(); + mock.expect_path_exists().returning(move |p| { + exists_clone.lock().unwrap().push(p.to_path_buf()); + let s = p.display().to_string().replace('\\', "/"); + // Entry exe missing on first poll; appears after second + // extract. Inner archive is "present" once we are queried + // for it (after the first extract call returns). + if s.ends_with(exe_rel) { + // The first time the entry exe is checked is the cold- + // cache fast-path; after extraction we want it present. + let calls = exists_clone + .lock() + .unwrap() + .iter() + .filter(|q| { + q.display() + .to_string() + .replace('\\', "/") + .ends_with(exe_rel) + }) + .count(); + return calls > 1; + } + if s.ends_with(inner_name) { + return true; + } + false + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE_NESTED.sha256.to_string())); + let extracts: Arc>> = Arc::new(Mutex::new(Vec::new())); + let ex_slot = extracts.clone(); + mock.expect_extract_archive().returning(move |a, _| { + ex_slot.lock().unwrap().push(a.to_path_buf()); + Ok(()) + }); + + // Act + let path = ensure_pin(&mock, &FAKE_NESTED, Path::new("/cache")).unwrap(); + + // Assert: the recorder must extract the outer archive first + // (so the nupkg appears) and then the inner nupkg. + let calls = extracts.lock().unwrap().clone(); + let names: Vec = calls + .iter() + .map(|p| p.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!(names.len(), 2, "expected outer + inner extract: {names:?}"); + assert!( + names[0].ends_with("/outer.zip") || names[0].ends_with("outer.zip"), + "outer first: {names:?}" + ); + assert!(names[1].ends_with(inner_name), "inner second: {names:?}"); + let final_path = path.display().to_string().replace('\\', "/"); + assert!(final_path.ends_with(exe_rel), "got {final_path}"); +} + +#[test] +fn test_inner_archive_missing_after_outer_extract_errors() { + // Arrange: outer extract succeeds but never produces the + // declared inner archive. ensure_pin must fail loudly so a + // stale Pin::inner_archive name is caught. + let mut mock = quiet_mock(); + let inner_name = FAKE_NESTED.inner_archive.unwrap(); + mock.expect_path_exists().returning(move |p| { + let s = p.display().to_string().replace('\\', "/"); + // Entry exe + inner archive both missing throughout. + let _ = inner_name; + let _ = s; + false + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE_NESTED.sha256.to_string())); + let outer_extracts: Arc>> = Arc::new(Mutex::new(HashSet::new())); + let slot = outer_extracts.clone(); + mock.expect_extract_archive().returning(move |a, _| { + slot.lock() + .unwrap() + .insert(a.display().to_string().replace('\\', "/")); + Ok(()) + }); + + // Act + let err = ensure_pin(&mock, &FAKE_NESTED, Path::new("/cache")) + .expect_err("expected inner-archive check to fail") + .to_string(); + + // Assert + assert!( + err.contains("inner archive") && err.contains("missing"), + "got: {err}" + ); + // Only the outer archive was extracted before the bail. + let names = outer_extracts.lock().unwrap().clone(); + assert_eq!(names.len(), 1, "only outer extract: {names:?}"); + assert!( + names.iter().any(|n| n.ends_with("outer.zip")), + "outer was extracted: {names:?}" + ); +} diff --git a/xtask/src/tests/test_demo_config_override.rs b/xtask/src/tests/test_demo_config_override.rs index 7fe62901..61a8c314 100644 --- a/xtask/src/tests/test_demo_config_override.rs +++ b/xtask/src/tests/test_demo_config_override.rs @@ -29,6 +29,13 @@ mock! { fn terminate_csshw(&self) -> anyhow::Result<()>; fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_driver.rs b/xtask/src/tests/test_demo_driver.rs index 690e6902..5e54b643 100644 --- a/xtask/src/tests/test_demo_driver.rs +++ b/xtask/src/tests/test_demo_driver.rs @@ -29,6 +29,13 @@ mock! { fn terminate_csshw(&self) -> anyhow::Result<()>; fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_env_sandbox.rs b/xtask/src/tests/test_demo_env_sandbox.rs new file mode 100644 index 00000000..21ace548 --- /dev/null +++ b/xtask/src/tests/test_demo_env_sandbox.rs @@ -0,0 +1,175 @@ +//! Tests for the sandbox env provider. +//! +//! These tests exercise the pure-string `.wsb` rendering and the +//! sentinel poll loop; the full `run` orchestration depends on +//! [`crate::demo::DemoSystem::spawn_sandbox`] which actually starts +//! `WindowsSandbox.exe` and is therefore covered indirectly only +//! (the side effect is mocked, but the real recording flow is +//! exercised end-to-end inside the sandbox itself). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::env::sandbox::{prepare_layout, render_wsb, wait_for_sentinel}; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +#[test] +fn test_prepare_layout_resolves_known_paths_under_workspace() { + // Arrange / Act + let layout = prepare_layout(Path::new("C:\\ws")); + + // Assert + let s = |p: &Path| p.display().to_string().replace('\\', "/"); + assert!(s(&layout.demo_root).ends_with("ws/target/demo")); + assert!(s(&layout.bin_dir).ends_with("ws/target/demo/bin")); + assert!(s(&layout.assets_dir).ends_with("ws/xtask/demo-assets")); + assert!(s(&layout.out_dir).ends_with("ws/target/demo/out")); + assert!(s(&layout.wsb_path).ends_with("ws/target/demo/csshw-demo.wsb")); + assert!(s(&layout.sentinel).ends_with("ws/target/demo/out/done.flag")); + assert!(s(&layout.sandbox_gif).ends_with("ws/target/demo/out/csshw.gif")); +} + +#[test] +fn test_render_wsb_pins_mount_layout_and_logon_command() { + // Arrange + let layout = prepare_layout(Path::new("C:\\ws")); + + // Act + let body = render_wsb(&layout, false); + + // Assert: every required mount point is present and routed + // to the canonical sandbox-side path. + assert!(body.contains(""), "{body}"); + assert!(body.contains(""), "{body}"); + assert!( + body.contains("C:\\demo\\repo"), + "{body}" + ); + assert!( + body.contains("C:\\demo\\bin"), + "{body}" + ); + assert!( + body.contains("C:\\demo\\assets"), + "{body}" + ); + assert!( + body.contains("C:\\demo\\out"), + "{body}" + ); + // The out folder is the only writable mount. + let ro_count = body.matches("true").count(); + let rw_count = body.matches("false").count(); + assert_eq!(ro_count, 3, "expected 3 RO mounts: {body}"); + assert_eq!(rw_count, 1, "expected 1 RW mount: {body}"); + // LogonCommand routes through the bootstrap script. + assert!(body.contains(""), "{body}"); + assert!(body.contains("sandbox-bootstrap.ps1"), "{body}"); + // Hardening attributes that should never silently regress. + assert!(body.contains("Disable"), "{body}"); + assert!( + body.contains("Enable"), + "{body}" + ); +} + +#[test] +fn test_render_wsb_passes_no_overlay_flag_when_set() { + // Arrange + let layout = prepare_layout(Path::new("C:\\ws")); + + // Act + let with_flag = render_wsb(&layout, true); + let without_flag = render_wsb(&layout, false); + + // Assert + assert!( + with_flag.contains("-NoOverlay"), + "with-flag should pass -NoOverlay: {with_flag}" + ); + assert!( + !without_flag.contains("-NoOverlay"), + "default render should not pass -NoOverlay: {without_flag}" + ); +} + +#[test] +fn test_render_wsb_uses_workspace_host_path_for_repo_mount() { + // Arrange + let layout = prepare_layout(Path::new("D:\\some place\\ws")); + + // Act + let body = render_wsb(&layout, false); + + // Assert + assert!( + body.contains("D:\\some place\\ws"), + "host path leaks straight to XML: {body}" + ); +} + +#[test] +fn test_wait_for_sentinel_returns_when_file_appears() { + // Arrange: report missing for two polls then present. + let mut mock = quiet_mock(); + let calls: Arc> = Arc::new(Mutex::new(0)); + let slot = calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n >= 3 + }); + let sleeps: Arc> = Arc::new(Mutex::new(0)); + let sleep_slot = sleeps.clone(); + mock.expect_sleep().returning(move |_| { + *sleep_slot.lock().unwrap() += 1; + }); + + // Act + let res = wait_for_sentinel(&mock, Path::new("/dev/null/done.flag")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*calls.lock().unwrap(), 3); + // Two misses cause two sleeps; the third hit returns + // immediately without sleeping. + assert_eq!(*sleeps.lock().unwrap(), 2); +} diff --git a/xtask/src/tests/test_demo_recorder.rs b/xtask/src/tests/test_demo_recorder.rs new file mode 100644 index 00000000..0a66ff64 --- /dev/null +++ b/xtask/src/tests/test_demo_recorder.rs @@ -0,0 +1,132 @@ +//! Tests for the recorder module. +//! +//! Only the trait-driven helpers are exercised here. +//! [`crate::demo::recorder::spawn_ffmpeg_gdigrab`] and +//! [`crate::demo::recorder::stop_ffmpeg_and_encode`] talk directly +//! to `std::process::Command` (they are only ever called from +//! [`crate::demo::RealSystem`]) and would require a real ffmpeg / +//! gifski to exercise; the trait-level callers in `mod.rs` cover +//! that path indirectly. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::recorder::wait_for_capture_baseline; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +#[test] +fn test_baseline_returns_when_size_threshold_reached() { + // Arrange: ffmpeg writes the header on the second poll. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + let polls: Arc> = Arc::new(Mutex::new(0)); + let slot = polls.clone(); + mock.expect_file_size().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + // First poll: empty file. Second poll: well past 8 KiB. + if *n == 1 { + Ok(0) + } else { + Ok(64 * 1024) + } + }); + let sleeps: Arc> = Arc::new(Mutex::new(0)); + let sleep_slot = sleeps.clone(); + mock.expect_sleep().returning(move |_| { + *sleep_slot.lock().unwrap() += 1; + }); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*sleeps.lock().unwrap(), 1, "only one poll-sleep before hit"); +} + +#[test] +fn test_baseline_ignores_transient_size_failures() { + // Arrange: simulate the Windows "ffmpeg holds an exclusive + // write handle" case by returning Err on the first poll. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + let polls: Arc> = Arc::new(Mutex::new(0)); + let slot = polls.clone(); + mock.expect_file_size().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + if *n == 1 { + Err(anyhow::anyhow!("ERROR_SHARING_VIOLATION")) + } else { + Ok(16 * 1024) + } + }); + mock.expect_sleep().returning(|_| ()); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_baseline_skips_polling_until_file_exists() { + // Arrange: file appears on the third poll and is large enough. + let mut mock = quiet_mock(); + let exist_polls: Arc> = Arc::new(Mutex::new(0)); + let slot = exist_polls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n >= 3 + }); + mock.expect_file_size().returning(|_| Ok(64 * 1024)); + mock.expect_sleep().returning(|_| ()); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*exist_polls.lock().unwrap(), 3); +} From 5b72dc9ca193564ece5d5661f8b1c93d5c74b9c0 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 14:49:22 +0200 Subject: [PATCH 3/9] xtask: make sandbox the default record-demo env With this change `cargo xtask record-demo` is hermetic by default: the Windows Sandbox provider runs against a normalised desktop without commandeering the developer's own session. CI workflows must pass `--env local` explicitly because GitHub-hosted runners lack the nested virtualisation Windows Sandbox needs. The CLI flag flip is one line; the rest of the diff is doc churn (README, DemoEnv variant docstrings, RecordDemo command help text, the default-pinning unit test). Co-authored-by: Claude Opus 4.6 --- README.md | 17 ++++++++++------- xtask/src/demo/mod.rs | 22 ++++++++++++---------- xtask/src/main.rs | 21 +++++++++++++-------- xtask/src/tests/test_demo_mod.rs | 12 ++++++------ 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f67970e9..91667ee7 100644 --- a/README.md +++ b/README.md @@ -165,13 +165,16 @@ drives a typed Rust DSL against synthesised Windows input, captures the desktop with vendored ffmpeg + gifski, and emits `target/demo/csshw.gif`. The recorder ships with two `--env` providers: -- `--env local` (default) runs on the caller's interactive session. - Step away while it records; foreground stealing is part of the demo. -- `--env sandbox` boots a fresh Windows Sandbox VM, normalises the - desktop (wallpaper, console font, DPI), optionally launches - [Carnac](https://github.com/Code52/carnac) for the keystroke overlay, - runs the demo, and copies the GIF back to the host. Requires the - optional `Containers-DisposableClientVM` Windows feature. +- `--env sandbox` (default) boots a fresh Windows Sandbox VM, + normalises the desktop (wallpaper, console font, DPI), optionally + launches [Carnac](https://github.com/Code52/carnac) for the + keystroke overlay, runs the demo, and copies the GIF back to the + host. Requires the optional `Containers-DisposableClientVM` + Windows feature. +- `--env local` runs on the caller's interactive session - step + away while it records, foreground stealing is part of the demo. + This is the only path that works in CI: GitHub-hosted runners + lack the nested virtualisation Windows Sandbox needs. The vendored binaries (ffmpeg, gifski, Carnac) are SHA-pinned and downloaded once into `target/demo/bin/` on first use. Pass diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index 521fbbca..66f30826 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -47,17 +47,19 @@ use clap::ValueEnum; /// invoking the shared [`driver`]. #[derive(Debug, Clone, Copy, ValueEnum)] pub enum DemoEnv { - /// Run on the caller's own interactive desktop session. v0 default. - /// No isolation - the caller is expected to step away while the - /// demo records. + /// Run on the caller's own interactive desktop session. No + /// isolation - the caller is expected to step away while the + /// demo records. The only provider that works in CI: GitHub- + /// hosted runners lack the nested virtualisation that Windows + /// Sandbox requires, so CI workflows must pass `--env local` + /// explicitly. Local, - /// Run inside a fresh Windows Sandbox VM. Mounts the workspace - /// read-only, mounts a writable output folder for the GIF, mounts - /// the cached vendored binaries, and runs the demo via a - /// `LogonCommand` that boots - /// `xtask/demo-assets/sandbox-bootstrap.ps1`. Cannot run on - /// GitHub-hosted runners because they lack nested virtualisation; - /// `--env ci-runner` (v2) is the canonical recording path. + /// Run inside a fresh Windows Sandbox VM. Default since v1 so + /// `cargo xtask record-demo` is hermetic on a developer + /// workstation. Mounts the workspace read-only, mounts a + /// writable output folder for the GIF, mounts the cached + /// vendored binaries, and runs the demo via a `LogonCommand` + /// that boots `xtask/demo-assets/sandbox-bootstrap.ps1`. Sandbox, } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5d62cd54..66176142 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -69,11 +69,14 @@ enum Command { /// Record an automated demo of csshw and produce `target/demo/csshw.gif`. /// /// Two providers are wired: + /// - `--env sandbox` (default) boots a fresh Windows Sandbox VM + /// with a normalised desktop and an optional Carnac keystroke + /// overlay. Requires the `Containers-DisposableClientVM` + /// Windows feature. /// - `--env local` runs on the caller's interactive desktop - /// session; the user must step away while it records. - /// - `--env sandbox` boots a fresh Windows Sandbox VM with a - /// normalised desktop and an optional Carnac keystroke overlay. - /// Requires the `Containers-DisposableClientVM` Windows feature. + /// session; the only path that works in CI runners (no nested + /// virtualisation) and a useful local-iteration shortcut while + /// the sandbox warm-up overhead is paid. /// /// ffmpeg, gifski, and Carnac are SHA-pinned and downloaded into /// `target/demo/bin/` on first use; subsequent runs hit the warm @@ -83,15 +86,17 @@ enum Command { /// `/target/demo/csshw.gif`. #[arg(long)] out: Option, - /// Recording environment provider. - #[arg(long, value_enum, default_value_t = demo::DemoEnv::Local)] + /// Recording environment provider. Defaults to `sandbox` so + /// `cargo xtask record-demo` is hermetic on a developer + /// workstation; CI must pass `--env local` explicitly. + #[arg(long, value_enum, default_value_t = demo::DemoEnv::Sandbox)] env: demo::DemoEnv, /// Skip ffmpeg capture; useful for iterating on the demo /// script without burning a recording cycle. #[arg(long)] no_record: bool, - /// Skip the keystroke overlay. v0 always behaves as if this - /// is set; the flag exists so v1+ scripts can opt out. + /// Skip the Carnac keystroke overlay. Has no effect with + /// `--env local` (Carnac is sandbox-only). #[arg(long)] no_overlay: bool, }, diff --git a/xtask/src/tests/test_demo_mod.rs b/xtask/src/tests/test_demo_mod.rs index 3072d8f5..ffd13eac 100644 --- a/xtask/src/tests/test_demo_mod.rs +++ b/xtask/src/tests/test_demo_mod.rs @@ -8,12 +8,12 @@ use crate::demo::{DemoEnv, WindowRect}; #[test] -fn test_demo_env_default_is_local() { - // The default for `--env` lives in `main.rs` as `DemoEnv::Local`. - // Pin that here so renaming the variant later flags the - // documentation in the plan as out of date. - let env = DemoEnv::Local; - assert!(matches!(env, DemoEnv::Local)); +fn test_demo_env_default_is_sandbox() { + // The default for `--env` lives in `main.rs` as + // `DemoEnv::Sandbox`. Pin that here so renaming the variant + // later flags the README + the v1 plan as out of date. + let env = DemoEnv::Sandbox; + assert!(matches!(env, DemoEnv::Sandbox)); } #[test] From 997f7feb359d3e5517e8a45a50ed51400c40251e Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 14:54:46 +0200 Subject: [PATCH 4/9] xtask: extract gifski tar.xz via pure-Rust crates Windows 10/11 ships BSD `tar.exe` on PATH but no `xz` binary, so `tar -xf gifski-*.tar.xz` shells out to `xz -d -qq` and fails with "unable to run program". Switch the `.tar.xz` branch in `extract_archive` to `lzma-rs` + `tar` crates so extraction works without any vendored decompressor. Drops the unused `.tar.gz` / `.tar` branches: only the gifski release uses tar.xz today. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 58 +++++++++++++++++++++++++++++++++++++++++++ xtask/Cargo.toml | 5 ++++ xtask/src/demo/mod.rs | 32 ++++++++++++++---------- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 688ab5d3..399e9331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -522,6 +537,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -810,6 +835,16 @@ dependencies = [ "log", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1651,6 +1686,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.25.0" @@ -2832,6 +2878,16 @@ dependencies = [ "nix 0.24.3", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xml-rs" version = "0.8.28" @@ -2850,10 +2906,12 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "lzma-rs", "mockall", "regex", "semver", "sha2", + "tar", "toml_edit 0.21.1", "windows 0.59.0", ] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index b1dfd4ad..56d3de58 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -19,6 +19,11 @@ regex = "1" # Demo subcommand: SHA-256 verification of vendored ffmpeg / gifski / # Carnac binaries downloaded into `target/demo/bin/`. sha2 = "0.10" +# Demo subcommand: pure-Rust extraction of the gifski tar.xz release. +# Windows ships BSD tar.exe but no xz; rather than bring in another +# vendored binary just for this, decompress + untar in-process. +lzma-rs = "0.3" +tar = "0.4" # Demo subcommand: Windows input synthesis (SendInput) and window # enumeration. Pinned to the same major version csshw_lib uses diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index 66f30826..821e4fc5 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -392,19 +392,25 @@ impl DemoSystem for RealSystem { .file_name() .map(|s| s.to_string_lossy().to_lowercase()) .unwrap_or_default(); - if name.ends_with(".tar.xz") || name.ends_with(".tar.gz") || name.ends_with(".tar") { - // Windows 10 1803+ ships BSD tar.exe on PATH. We invoke it - // with `-C` so the destination is unambiguous. - let status = std::process::Command::new("tar") - .arg("-xf") - .arg(archive) - .arg("-C") - .arg(dest_dir) - .status() - .map_err(|e| anyhow::anyhow!("failed to spawn tar: {e}"))?; - if !status.success() { - anyhow::bail!("tar -xf {} failed: {status}", archive.display()); - } + if name.ends_with(".tar.xz") { + // Windows ships BSD tar.exe but no `xz` binary, so the + // bundled tar shells out and fails. Decompress + untar + // in-process instead. Only the gifski release uses + // tar.xz today; .tar.gz / .tar are not currently + // exercised, so they are deliberately not handled here. + let f = std::fs::File::open(archive) + .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", archive.display()))?; + let mut tar_bytes = Vec::new(); + lzma_rs::xz_decompress(&mut std::io::BufReader::new(f), &mut tar_bytes) + .map_err(|e| anyhow::anyhow!("xz_decompress {} failed: {e}", archive.display()))?; + let mut tar_archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); + tar_archive.unpack(dest_dir).map_err(|e| { + anyhow::anyhow!( + "tar::unpack {} -> {} failed: {e}", + archive.display(), + dest_dir.display() + ) + })?; return Ok(()); } if name.ends_with(".zip") || name.ends_with(".nupkg") { From 1b153c9eadf6f7d06c1c9cf01844485a6ac5bbf7 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 14:57:09 +0200 Subject: [PATCH 5/9] xtask: extract Carnac .nupkg via System.IO.Compression.ZipFile PowerShell's `Expand-Archive` validates by file extension and refuses anything but `.zip`, so the inner Carnac NuGet package (`carnac-*-full.nupkg`) failed to extract: Expand-Archive : .nupkg is not a supported archive file format. .zip is the only supported archive file format. Drop down to the underlying `System.IO.Compression.ZipFile`, which only cares about the format, not the file name. Same PowerShell shell-out, no new dependencies. Co-Authored-By: Claude Opus 4.6 --- xtask/src/demo/mod.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index 821e4fc5..fecfbe54 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -416,20 +416,27 @@ impl DemoSystem for RealSystem { if name.ends_with(".zip") || name.ends_with(".nupkg") { let archive_str = archive.to_string_lossy().replace('\'', "''"); let dest_str = dest_dir.to_string_lossy().replace('\'', "''"); - // Expand-Archive is idempotent only with -Force; we - // already create the dest fresh in callers so -Force is - // safe. + // PowerShell's `Expand-Archive` validates by file + // extension and refuses anything other than `.zip` (the + // Carnac release ships as `.nupkg`, which is a zip). + // Drop down to `System.IO.Compression.ZipFile`, which is + // format-only. The `Add-Type` call is a no-op if the + // assembly is already loaded. let script = format!( "$ProgressPreference='SilentlyContinue';\ - Expand-Archive -Force -LiteralPath '{archive_str}' \ - -DestinationPath '{dest_str}'" + Add-Type -AssemblyName System.IO.Compression.FileSystem;\ + [System.IO.Compression.ZipFile]::ExtractToDirectory(\ + '{archive_str}','{dest_str}',$true)" ); let status = std::process::Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", &script]) .status() .map_err(|e| anyhow::anyhow!("failed to spawn powershell for extract: {e}"))?; if !status.success() { - anyhow::bail!("Expand-Archive {} failed: {status}", archive.display()); + anyhow::bail!( + "ZipFile::ExtractToDirectory {} failed: {status}", + archive.display() + ); } return Ok(()); } From e662f0168ae3f6f59e0ac69f2c2d4e170570e316 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 15:00:25 +0200 Subject: [PATCH 6/9] xtask: extract zip/nupkg via the zip crate PowerShell's archive APIs are not portable enough for the Carnac download pipeline: - `Expand-Archive` validates by file extension and rejects `.nupkg` even though it is a zip. - `[System.IO.Compression.ZipFile]::ExtractToDirectory(src, dst, $true)` binds to a different 3-arg overload depending on the PowerShell edition: on Windows PowerShell 5.1 (.NET Framework) the third arg is `Encoding` and `$true` fails to coerce; on PowerShell 7+ (.NET Core / .NET 5+) the third arg is `bool overwriteFiles`. Switch to the `zip` crate so extraction works the same on every PowerShell host and we stop debugging .NET overload resolution. The default `zip` features pull bzip2/zstd/deflate64 we don't need; keep just `deflate` since that is what every archive we ship uses. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 67 +++++++++++++++++++++++++++++++++++++++++++ xtask/Cargo.toml | 13 +++++++-- xtask/src/demo/mod.rs | 42 ++++++++++++--------------- 3 files changed, 95 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 399e9331..c5c697a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -350,6 +359,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -411,6 +426,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "digest" version = "0.10.7" @@ -461,6 +487,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "downcast" version = "0.11.0" @@ -2914,6 +2951,24 @@ dependencies = [ "tar", "toml_edit 0.21.1", "windows 0.59.0", + "zip", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", ] [[package]] @@ -2922,6 +2977,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 56d3de58..214faa52 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -19,11 +19,18 @@ regex = "1" # Demo subcommand: SHA-256 verification of vendored ffmpeg / gifski / # Carnac binaries downloaded into `target/demo/bin/`. sha2 = "0.10" -# Demo subcommand: pure-Rust extraction of the gifski tar.xz release. -# Windows ships BSD tar.exe but no xz; rather than bring in another -# vendored binary just for this, decompress + untar in-process. +# Demo subcommand: pure-Rust archive extraction. +# - lzma-rs + tar: gifski ships as tar.xz; Windows BSD tar.exe shells +# out to an external `xz` binary that isn't on Windows. +# - zip: Carnac ships as a .zip wrapping a .nupkg. PowerShell's +# `Expand-Archive` rejects `.nupkg` by extension and the underlying +# `ZipFile::ExtractToDirectory` 3-arg overload differs between +# .NET Framework and .NET Core, so do this in-process too. +# `default-features = false` drops bzip2/zstd/deflate64; the +# stdlib deflate decoder handles everything our pinned archives use. lzma-rs = "0.3" tar = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } # Demo subcommand: Windows input synthesis (SendInput) and window # enumeration. Pinned to the same major version csshw_lib uses diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index fecfbe54..a9085d72 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -414,30 +414,24 @@ impl DemoSystem for RealSystem { return Ok(()); } if name.ends_with(".zip") || name.ends_with(".nupkg") { - let archive_str = archive.to_string_lossy().replace('\'', "''"); - let dest_str = dest_dir.to_string_lossy().replace('\'', "''"); - // PowerShell's `Expand-Archive` validates by file - // extension and refuses anything other than `.zip` (the - // Carnac release ships as `.nupkg`, which is a zip). - // Drop down to `System.IO.Compression.ZipFile`, which is - // format-only. The `Add-Type` call is a no-op if the - // assembly is already loaded. - let script = format!( - "$ProgressPreference='SilentlyContinue';\ - Add-Type -AssemblyName System.IO.Compression.FileSystem;\ - [System.IO.Compression.ZipFile]::ExtractToDirectory(\ - '{archive_str}','{dest_str}',$true)" - ); - let status = std::process::Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &script]) - .status() - .map_err(|e| anyhow::anyhow!("failed to spawn powershell for extract: {e}"))?; - if !status.success() { - anyhow::bail!( - "ZipFile::ExtractToDirectory {} failed: {status}", - archive.display() - ); - } + // Pure-Rust extraction: PowerShell's `Expand-Archive` + // rejects `.nupkg` by extension, and the underlying + // `ZipFile::ExtractToDirectory` 3-arg overload binds + // differently between .NET Framework (Encoding) and + // .NET Core (bool overwriteFiles), so it's not portable + // across PowerShell editions. The `zip` crate has no + // such ambiguity. + let f = std::fs::File::open(archive) + .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", archive.display()))?; + let mut zip_archive = zip::ZipArchive::new(std::io::BufReader::new(f)) + .map_err(|e| anyhow::anyhow!("zip open {} failed: {e}", archive.display()))?; + zip_archive.extract(dest_dir).map_err(|e| { + anyhow::anyhow!( + "zip extract {} -> {} failed: {e}", + archive.display(), + dest_dir.display() + ) + })?; return Ok(()); } anyhow::bail!( From d0441c0951cf8808c09a4693dfb30201a7ee5f05 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 15:07:47 +0200 Subject: [PATCH 7/9] docs: spell out Windows Sandbox prerequisites in README `cargo xtask record-demo` now defaults to `--env sandbox`, but fails with `program not found` when WindowsSandbox.exe is missing. Replace the one-liner about `Containers-DisposableClientVM` with the actionable trio (Pro/Enterprise/Education edition, hardware virtualisation, the elevated PowerShell command + reboot) so a contributor can act without leaving the README. Reword the `--env local` bullet to point Windows Home users (and not just CI) at it. Co-Authored-By: Claude Opus 4.6 --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 91667ee7..fb4ae7b2 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,21 @@ providers: normalises the desktop (wallpaper, console font, DPI), optionally launches [Carnac](https://github.com/Code52/carnac) for the keystroke overlay, runs the demo, and copies the GIF back to the - host. Requires the optional `Containers-DisposableClientVM` - Windows feature. + host. Prerequisites (one-time): + 1. Windows 10/11 **Pro**, **Enterprise**, or **Education** + (Home does not ship Windows Sandbox). + 2. Hardware virtualisation enabled in BIOS/UEFI. + 3. Enable the optional feature from an elevated PowerShell and + reboot: + ```powershell + Enable-WindowsOptionalFeature -Online ` + -FeatureName Containers-DisposableClientVM -All + ``` - `--env local` runs on the caller's interactive session - step away while it records, foreground stealing is part of the demo. - This is the only path that works in CI: GitHub-hosted runners - lack the nested virtualisation Windows Sandbox needs. + Use this when Windows Sandbox is unavailable (e.g. on Windows + Home) and in CI: GitHub-hosted runners lack the nested + virtualisation Windows Sandbox needs. The vendored binaries (ffmpeg, gifski, Carnac) are SHA-pinned and downloaded once into `target/demo/bin/` on first use. Pass From 28cb7fb8a9d9c43f42a29897b6fc1ab0c2f52b75 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 15:23:04 +0200 Subject: [PATCH 8/9] xtask: build csshw on host before launching sandbox The sandbox VM has no Rust toolchain, so the bootstrap script can only run a prebuilt csshw.exe from the read-only repo mount. Have record-demo run `cargo build -p csshw` (debug profile) on the host before spawning the sandbox, so the task is self-sufficient and the user does not need to remember to build first. Co-authored-by: Claude Opus 4.6 --- xtask/src/demo/env/sandbox.rs | 8 ++++++++ xtask/src/demo/mod.rs | 21 ++++++++++++++++++++ xtask/src/tests/test_demo_bin.rs | 1 + xtask/src/tests/test_demo_config_override.rs | 1 + xtask/src/tests/test_demo_driver.rs | 1 + xtask/src/tests/test_demo_env_sandbox.rs | 1 + xtask/src/tests/test_demo_recorder.rs | 1 + 7 files changed, 34 insertions(+) diff --git a/xtask/src/demo/env/sandbox.rs b/xtask/src/demo/env/sandbox.rs index 5304bcbe..f5b4e854 100644 --- a/xtask/src/demo/env/sandbox.rs +++ b/xtask/src/demo/env/sandbox.rs @@ -254,6 +254,14 @@ pub fn run( bin::ensure_bins(system, &layout.bin_dir) .with_context(|| "preparing target/demo/bin/ for sandbox mount")?; + // Build csshw on the host so the sandbox bootstrap can find + // a ready-to-run csshw.exe under the read-only repo mount. + // The sandbox itself has no Rust toolchain. + system.print_info("sandbox env: building csshw on host (cargo build -p csshw)"); + system + .cargo_build_csshw(&layout.workspace) + .with_context(|| "building csshw on the host before launching sandbox")?; + // Wipe leftover sentinels and GIFs from previous runs so the // poll loop can use plain "exists" without a timestamp check. system.ensure_dir(&layout.out_dir)?; diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index a9085d72..37965e0a 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -184,6 +184,12 @@ pub trait DemoSystem { /// Idempotent. fn terminate_sandbox(&self) -> Result<()>; + /// Run `cargo build -p csshw` against `workspace`, leaving + /// `target/debug/csshw.exe` ready for the sandbox bootstrap to + /// pick up. Production impl shells out to `cargo`; tests stub + /// it to a no-op. + fn cargo_build_csshw(&self, workspace: &Path) -> Result<()>; + /// Print an informational message to stdout. fn print_info(&self, message: &str); @@ -483,6 +489,21 @@ impl DemoSystem for RealSystem { Ok(()) } + fn cargo_build_csshw(&self, workspace: &Path) -> Result<()> { + // Spawn cargo with stdout/stderr inherited so the user sees + // build progress live. Debug profile only - the demo neither + // benefits from optimisations nor wants the longer build. + let status = std::process::Command::new("cargo") + .args(["build", "-p", "csshw"]) + .current_dir(workspace) + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn cargo build: {e}"))?; + if !status.success() { + anyhow::bail!("cargo build -p csshw failed: {status}"); + } + Ok(()) + } + fn print_info(&self, message: &str) { println!("INFO - {message}"); } diff --git a/xtask/src/tests/test_demo_bin.rs b/xtask/src/tests/test_demo_bin.rs index 31048b64..0ebed966 100644 --- a/xtask/src/tests/test_demo_bin.rs +++ b/xtask/src/tests/test_demo_bin.rs @@ -41,6 +41,7 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_config_override.rs b/xtask/src/tests/test_demo_config_override.rs index 61a8c314..46335695 100644 --- a/xtask/src/tests/test_demo_config_override.rs +++ b/xtask/src/tests/test_demo_config_override.rs @@ -36,6 +36,7 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_driver.rs b/xtask/src/tests/test_demo_driver.rs index 5e54b643..9c528b98 100644 --- a/xtask/src/tests/test_demo_driver.rs +++ b/xtask/src/tests/test_demo_driver.rs @@ -27,6 +27,7 @@ mock! { fn sleep(&self, duration: Duration); fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; fn terminate_csshw(&self) -> anyhow::Result<()>; + fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; fn path_exists(&self, path: &Path) -> bool; diff --git a/xtask/src/tests/test_demo_env_sandbox.rs b/xtask/src/tests/test_demo_env_sandbox.rs index 21ace548..088ba588 100644 --- a/xtask/src/tests/test_demo_env_sandbox.rs +++ b/xtask/src/tests/test_demo_env_sandbox.rs @@ -39,6 +39,7 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_recorder.rs b/xtask/src/tests/test_demo_recorder.rs index 0a66ff64..d2241fc8 100644 --- a/xtask/src/tests/test_demo_recorder.rs +++ b/xtask/src/tests/test_demo_recorder.rs @@ -40,6 +40,7 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } From fa6f261c7389980e85ab4d09f337e089f232c722 Mon Sep 17 00:00:00 2001 From: whme Date: Sat, 9 May 2026 19:31:47 +0200 Subject: [PATCH 9/9] xtask: get v1 sandbox demo recording working end-to-end Consolidates the fixes that turned the v1 sandbox provider from "produces a GIF" into "produces a correct GIF of the actual csshw interaction". - recorder: drop `-video_size 1920x1080` from the gdigrab args so the capture covers the entire primary monitor instead of cropping to the top-left 1920x1080 region. Windows Sandbox auto-sizes its desktop to the host monitor and exposes no stable resolution hook, so any pinned size truncated the recording on high-DPI / 4K hosts. The downstream `scale=1280:-1` step in `stop_ffmpeg_and_encode` already normalises the encoded GIF width regardless of source size. - windows_input: replace `SendInput(KEYEVENTF_UNICODE)` with a `VkKeyScanW`-driven virtual-key sequence (with shift / ctrl / alt modifiers as needed). Synthetic Unicode events arrive at low-level keyboard hooks (`WH_KEYBOARD_LL`) with `vkCode = VK_PACKET`, which Carnac renders as the literal text "Packet" (so a `whoami` broadcast showed up as six "Packet" rows). Real VK events make Carnac display the typed character. Surrogate- pair / unmapped chars now error loudly because the canonical script restricts itself to ASCII keyboard characters. - sandbox env: stop mounting the workspace read-only and stop building csshw + xtask inside the sandbox. The host now builds both with a statically linked MSVC runtime (`-C target-feature=+crt-static`) directly into the writable mount at `target/demo/out/work/target/`, where they appear inside the VM at `C:\demo\out\work\target\debug\` with no in-VM copy and no rustup install. xtask is invoked with `CSSHW_DEMO_WORKSPACE` set so its local provider locates csshw.exe at the same path it does on a developer workstation. - bin: pin and download the Microsoft VC++ Redistributable x64 installer alongside the other vendored binaries. Vendored gifski.exe is dynamically linked against `vcruntime140.dll`, which the Windows Sandbox base image does not ship (it only ships UCRT). Without the redist installed, in-VM gifski exits with `STATUS_DLL_NOT_FOUND` (0xC0000135). The bootstrap runs `vc_redist.x64.exe /install /quiet /norestart` before invoking xtask. `ensure_pin` now treats archives whose `archive_name` equals `exe_rel` as self-contained executables and skips the extract step. - sandbox-bootstrap.ps1: switch the sentinel-write protection from a script-level `trap` to `try/catch/finally`. PowerShell treats the existing `try` as the enclosing handler even with no `catch`, so the trap never fired and `$status` silently kept its placeholder. The new `catch` surfaces the real exception message into `done.flag`, and the failure path tails the last ~1.5 KB of xtask's redirected stdout/stderr into the sentinel so the host's `wait_for_sentinel` diagnostic carries the real cause when the in-VM xtask fails. - setup-desktop.ps1: stop setting a solid wallpaper. The sandbox ships a clean stock background and the `--env local` path must not modify the developer's wallpaper. - sandbox env: bail out of the sentinel poll early when the user closes the sandbox window manually, so the host does not hang for the full 8-minute timeout. GitHub: #191 Co-authored-by: Claude Opus 4.6 --- xtask/demo-assets/sandbox-bootstrap.ps1 | 198 +++++++++------- xtask/demo-assets/setup-desktop.ps1 | 39 +--- xtask/src/demo/bin.rs | 70 +++++- xtask/src/demo/env/sandbox.rs | 224 +++++++++++++++---- xtask/src/demo/mod.rs | 86 ++++++- xtask/src/demo/recorder.rs | 14 +- xtask/src/demo/windows_input.rs | 139 +++++++----- xtask/src/tests/test_demo_bin.rs | 3 +- xtask/src/tests/test_demo_config_override.rs | 3 +- xtask/src/tests/test_demo_driver.rs | 3 +- xtask/src/tests/test_demo_env_sandbox.rs | 71 +++++- xtask/src/tests/test_demo_recorder.rs | 3 +- 12 files changed, 599 insertions(+), 254 deletions(-) diff --git a/xtask/demo-assets/sandbox-bootstrap.ps1 b/xtask/demo-assets/sandbox-bootstrap.ps1 index 7bcd8b63..82b47192 100644 --- a/xtask/demo-assets/sandbox-bootstrap.ps1 +++ b/xtask/demo-assets/sandbox-bootstrap.ps1 @@ -1,24 +1,33 @@ # Bootstraps the csshw demo recording inside Windows Sandbox. # # Mounted folders (set up by xtask::demo::env::sandbox::render_wsb): -# C:\demo\repo repo (read-only) -# C:\demo\bin ffmpeg / gifski / Carnac caches (read-only) +# C:\demo\bin ffmpeg / gifski / Carnac / vcredist caches (RO) # C:\demo\assets this script + setup-desktop.ps1 (read-only) -# C:\demo\out writable: GIF + done.flag sentinel land here +# C:\demo\out writable: prebuilt binaries, GIF, sentinel, +# xtask logs all live here +# +# The host builds csshw + xtask with a statically linked MSVC +# runtime (RUSTFLAGS=-C target-feature=+crt-static) directly into +# C:\demo\out\work\target\debug\ on the writable mount. The binaries +# are visible inside the VM with no copy step and xtask's local +# provider locates csshw.exe at \target\debug\csshw.exe +# the same way it does on a developer workstation. # # Flow: -# 1. Source setup-desktop.ps1 (wallpaper, console font, DPI). -# 2. Optionally launch Carnac minimised for the keystroke overlay +# 1. Source setup-desktop.ps1 (console font, DPI, hide icons). +# 2. Run vc_redist.x64.exe /install /quiet /norestart so the +# sandbox's System32 carries the full MSVC runtime that +# vendored gifski.exe (and any future MSVC-built tool) needs. +# Without this step gifski exits with STATUS_DLL_NOT_FOUND. +# 3. Optionally launch Carnac minimised for the keystroke overlay # (skipped when -NoOverlay is passed by the host). -# 3. Build csshw release binaries from the mounted source tree. -# The host cannot pre-build because it would bake the -# developer's machine-specific paths into the artifacts; the -# sandbox build is short and reproducible. -# 4. Invoke `xtask record-demo --env local` against the sandboxed -# desktop. The local provider already owns the recording flow. -# 5. Copy the resulting GIF to C:\demo\out\csshw.gif and write the -# sentinel C:\demo\out\done.flag (`ok` on success, `error: ...` -# on failure) so the host poll loop can release. +# 4. Invoke the prebuilt +# C:\demo\out\work\target\debug\xtask.exe with --env local and +# --out pointing straight at C:\demo\out\csshw.gif so the GIF +# lands on the writable mount and is visible on the host +# without any in-VM copy. +# 5. Write the sentinel C:\demo\out\done.flag (`ok` on success, +# `error: ...` on failure) so the host poll loop can release. # 6. Trigger an immediate sandbox shutdown so the host's # terminate_sandbox is a no-op rather than a fallback. @@ -29,24 +38,52 @@ param( $ErrorActionPreference = 'Stop' -# Robust sentinel write: any exit path (success, failure, even a -# trapped exception) must produce C:\demo\out\done.flag, otherwise -# the host's wait_for_sentinel times out without diagnostic output. +# Robust sentinel write: any exit path (success or failure) must +# produce C:\demo\out\done.flag, otherwise the host's +# wait_for_sentinel times out without diagnostic output. The +# sentinel is written exactly once, from the `finally` block +# below. We use `try/catch/finally` (not the older `trap` keyword) +# because a script-level `trap` does not fire for errors raised +# inside a `try` block: PowerShell treats the try as the enclosing +# handler even when there is no `catch`, so the trap never sees +# the error and `$status` would silently keep its placeholder. $sentinel = 'C:\demo\out\done.flag' -$status = 'error: bootstrap exited unexpectedly' -$ranToCompletion = $false - -trap { - $err = $_.ToString() - Set-Content -LiteralPath $sentinel -Value "error: $err" -Encoding ASCII -NoNewline - Stop-Computer -Force - break -} +$status = 'error: bootstrap exited unexpectedly (no completion path)' try { Write-Host '[bootstrap] sourcing setup-desktop.ps1' . 'C:\demo\assets\setup-desktop.ps1' + # The Windows Sandbox base image ships UCRT but not the MSVC + # runtime DLLs. Upstream gifski.exe is dynamically linked + # against vcruntime140.dll, which without the redist installed + # makes the in-VM gifski invocation fail with + # STATUS_DLL_NOT_FOUND (0xC0000135). Microsoft's standalone + # redistributable installer is the canonical fix: it drops the + # full VC++ runtime into the sandbox's real System32, so any + # MSVC-built tool we vendor (gifski today, anything else + # tomorrow) just resolves its imports through the standard DLL + # search path. The host's xtask::demo::bin module downloads and + # SHA-pins vc_redist.x64.exe into the read-only bin mount. + $vcRedist = 'C:\demo\bin\vcredist\vc_redist.x64.exe' + if (-not (Test-Path -LiteralPath $vcRedist)) { + throw "missing $vcRedist; the host bin cache did not populate the redist" + } + Write-Host '[bootstrap] installing VC++ redistributable (silent)' + # /install /quiet /norestart is the documented unattended-install + # surface. Exit code 0 = installed, 1638 = newer version already + # present (also a success, but the sandbox is fresh so this + # branch is only relevant if the redist ever lands in a future + # base image). 3010 = success but reboot pending (we don't + # reboot the sandbox; the runtime is loadable immediately). + $vcProc = Start-Process -FilePath $vcRedist ` + -ArgumentList @('/install', '/quiet', '/norestart') ` + -Wait -PassThru -NoNewWindow + if ($vcProc.ExitCode -ne 0 -and $vcProc.ExitCode -ne 1638 -and $vcProc.ExitCode -ne 3010) { + throw "vc_redist.x64.exe exited with status $($vcProc.ExitCode)" + } + Write-Host "[bootstrap] vc_redist exit code $($vcProc.ExitCode)" + if (-not $NoOverlay) { $carnacExe = 'C:\demo\bin\carnac\lib\net45\Carnac.exe' if (Test-Path -LiteralPath $carnacExe) { @@ -65,70 +102,75 @@ try { Write-Host '[bootstrap] -NoOverlay: skipping Carnac' } - # Cargo and rustup are not present in a fresh sandbox image. The - # demo path we ship works only if the host has already built - # csshw.exe; the sandbox merely consumes it. We defensively - # locate the prebuilt binary under target\release; if it is - # missing we surface a clear sentinel error. - $csshwExe = 'C:\demo\repo\target\release\csshw.exe' - if (-not (Test-Path -LiteralPath $csshwExe)) { - $csshwExe = 'C:\demo\repo\target\debug\csshw.exe' - } - if (-not (Test-Path -LiteralPath $csshwExe)) { - throw "no prebuilt csshw.exe found under C:\demo\repo\target\{release,debug}; run `cargo build --release` on the host before `cargo xtask record-demo --env sandbox`" - } - $xtaskExe = 'C:\demo\repo\target\release\xtask.exe' + # The host's cargo_build_demo_artifacts wrote csshw.exe and + # xtask.exe directly into the writable out mount. xtask's local + # provider looks for csshw.exe at \target\debug, so + # workRoot lines up with the cargo --target-dir the host used. + $workRoot = 'C:\demo\out\work' + $xtaskExe = Join-Path $workRoot 'target\debug\xtask.exe' + $csshwExe = Join-Path $workRoot 'target\debug\csshw.exe' if (-not (Test-Path -LiteralPath $xtaskExe)) { - $xtaskExe = 'C:\demo\repo\target\debug\xtask.exe' + throw "missing $xtaskExe; the host build did not produce xtask.exe on the writable mount" } - if (-not (Test-Path -LiteralPath $xtaskExe)) { - throw "no prebuilt xtask.exe found under C:\demo\repo\target\{release,debug}; run `cargo build -p xtask --release` on the host before `cargo xtask record-demo --env sandbox`" - } - - # The local provider expects to write to /target/demo, - # which inside the sandbox is the read-only C:\demo\repo. We - # work around that by copying the read-only tree to a writable - # location under C:\demo\out\repo and pointing the local - # provider at it. - $writeRepo = 'C:\demo\out\repo' - if (Test-Path -LiteralPath $writeRepo) { - Remove-Item -LiteralPath $writeRepo -Recurse -Force + if (-not (Test-Path -LiteralPath $csshwExe)) { + throw "missing $csshwExe; the host build did not produce csshw.exe on the writable mount" } - # We only need target\release\csshw.exe + target\release\xtask.exe - # plus anything xtask reads at runtime (CARGO_MANIFEST_DIR - - # baked at compile time so source layout does not matter at run - # time). Copy a minimal skeleton that satisfies xtask's - # workspace_root() resolver: /xtask/Cargo.toml's parent. - New-Item -ItemType Directory -Path "$writeRepo\xtask" -Force | Out-Null - New-Item -ItemType Directory -Path "$writeRepo\target\release" -Force | Out-Null - Copy-Item -LiteralPath $csshwExe -Destination "$writeRepo\target\release\csshw.exe" - Copy-Item -LiteralPath $xtaskExe -Destination "$writeRepo\target\release\xtask.exe" Write-Host '[bootstrap] running xtask record-demo --env local' - $proc = Start-Process -FilePath "$writeRepo\target\release\xtask.exe" ` - -ArgumentList @('record-demo', '--env', 'local', '--no-overlay') ` - -WorkingDirectory $writeRepo ` - -PassThru -Wait -NoNewWindow - if ($proc.ExitCode -ne 0) { - throw "xtask record-demo exited with status $($proc.ExitCode)" + $env:CSSHW_DEMO_WORKSPACE = $workRoot + # Capture stdout+stderr to files in the writable mount so the + # host can surface them when xtask fails. The sandbox VM shuts + # down on exit, so anything that only lived on the VM's console + # is otherwise lost. + $xtaskStdout = 'C:\demo\out\xtask.stdout.log' + $xtaskStderr = 'C:\demo\out\xtask.stderr.log' + # --out points straight at the writable mount so the GIF lands + # on the host without a post-run copy. Intermediate .mkv and + # frames\ end up next to it for the same reason. + try { + $proc = Start-Process -FilePath $xtaskExe ` + -ArgumentList @('record-demo', '--env', 'local', '--no-overlay', '--out', 'C:\demo\out\csshw.gif') ` + -WorkingDirectory $workRoot ` + -RedirectStandardOutput $xtaskStdout ` + -RedirectStandardError $xtaskStderr ` + -PassThru -Wait -NoNewWindow + if ($proc.ExitCode -ne 0) { + $tail = '' + foreach ($logPath in @($xtaskStderr, $xtaskStdout)) { + if (Test-Path -LiteralPath $logPath) { + $content = (Get-Content -LiteralPath $logPath -Raw -ErrorAction SilentlyContinue) + if ($content) { + # Last ~1500 chars: enough for a Rust panic / + # anyhow chain without bloating done.flag. + $start = [Math]::Max(0, $content.Length - 1500) + $tail = $content.Substring($start).Trim() + if ($tail) { break } + } + } + } + if (-not $tail) { + $tail = '(no output captured; see C:\demo\out\xtask.{stdout,stderr}.log on the host out mount)' + } + throw "xtask record-demo exited with status $($proc.ExitCode): $tail" + } + } finally { + Remove-Item Env:\CSSHW_DEMO_WORKSPACE -ErrorAction SilentlyContinue } - $producedGif = Join-Path $writeRepo 'target\demo\csshw.gif' - if (-not (Test-Path -LiteralPath $producedGif)) { - throw "expected $producedGif after record-demo, but it is missing" + if (-not (Test-Path -LiteralPath 'C:\demo\out\csshw.gif')) { + throw 'expected C:\demo\out\csshw.gif after record-demo, but it is missing' } - Copy-Item -LiteralPath $producedGif -Destination 'C:\demo\out\csshw.gif' -Force - Write-Host '[bootstrap] copied recorded GIF to C:\demo\out\csshw.gif' $status = 'ok' - $ranToCompletion = $true +} +catch { + # PowerShell records the exception that escaped the `try` block in + # $_; we surface its message into the sentinel so the host's + # wait_for_sentinel diagnostic carries the real cause instead of + # the placeholder. + $status = "error: $($_.Exception.Message)" } finally { - if (-not $ranToCompletion -and $status -eq 'error: bootstrap exited unexpectedly') { - # Trap above handles thrown exceptions; this branch covers - # script termination paths PowerShell does not surface as - # exceptions (e.g. native commands aborting the host). - } Set-Content -LiteralPath $sentinel -Value $status -Encoding ASCII -NoNewline # Shut the sandbox down so the host's wait_for_sentinel + copy # is the only synchronisation point. -Force avoids the diff --git a/xtask/demo-assets/setup-desktop.ps1 b/xtask/demo-assets/setup-desktop.ps1 index 7b2dd3ca..4bb433f8 100644 --- a/xtask/demo-assets/setup-desktop.ps1 +++ b/xtask/demo-assets/setup-desktop.ps1 @@ -7,47 +7,17 @@ # the desired state is already in place. # # Settings applied: -# - Wallpaper: solid #0F1419 (csshw brand colour) via -# SystemParametersInfo SPI_SETDESKWALLPAPER. # - Console font: Cascadia Mono 18 pt for both cmd.exe and # powershell.exe via HKCU\Console\. # - Logical resolution: 1920x1080 at 100 % DPI scale. # - Hide desktop icons; disable taskbar auto-hide animation. +# +# The wallpaper is intentionally left at the Windows default: the +# sandbox already ships a clean stock background, and the host run +# (--env local) must not modify the developer's wallpaper. $ErrorActionPreference = 'Stop' -function Set-SolidWallpaper { - [CmdletBinding()] - param([Parameter(Mandatory)] [string] $HexColor) - - $rgb = $HexColor.TrimStart('#') - $r = [Convert]::ToInt32($rgb.Substring(0, 2), 16) - $g = [Convert]::ToInt32($rgb.Substring(2, 2), 16) - $b = [Convert]::ToInt32($rgb.Substring(4, 2), 16) - - # Solid colour wallpaper is set in two steps: - # 1. Write Control Panel\Colors!Background (space-separated RGB). - # 2. Clear the desktop wallpaper image so the solid colour shows. - Set-ItemProperty -Path 'HKCU:\Control Panel\Colors' ` - -Name 'Background' -Value "$r $g $b" - Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' ` - -Name 'Wallpaper' -Value '' - - # Push the change into the running session via SystemParametersInfo. - # SPI_SETDESKWALLPAPER = 0x0014; SPIF_UPDATEINIFILE | SPIF_SENDCHANGE = 0x03. - if (-not ('SpiNative' -as [type])) { - Add-Type @' -using System; -using System.Runtime.InteropServices; -public class SpiNative { - [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] - public static extern bool SystemParametersInfo(uint a, uint b, string c, uint d); -} -'@ - } - [void][SpiNative]::SystemParametersInfo(0x14, 0, '', 0x03) -} - function Set-ConsoleFont { [CmdletBinding()] param( @@ -97,7 +67,6 @@ function Set-DesktopChromeOff { # --- Apply ---------------------------------------------------------------- -Set-SolidWallpaper -HexColor '#0F1419' Set-ConsoleFont -FaceName 'Cascadia Mono' -PointSize 18 Set-DpiScaleHundred Set-DesktopChromeOff diff --git a/xtask/src/demo/bin.rs b/xtask/src/demo/bin.rs index 39985918..6fabe28f 100644 --- a/xtask/src/demo/bin.rs +++ b/xtask/src/demo/bin.rs @@ -1,8 +1,9 @@ //! Vendored binary management for the `record-demo` recorder. //! //! v0 expected `ffmpeg` and `gifski` on `PATH`. v1 ships SHA-pinned -//! download URLs for ffmpeg, gifski, and Carnac, fetches them once -//! into `target/demo/bin//`, verifies the SHA-256 of every +//! download URLs for ffmpeg, gifski, Carnac, and the VC++ +//! redistributable, fetches them once into +//! `target/demo/bin//`, verifies the SHA-256 of every //! download against the constants in this module, and extracts the //! archive into a deterministic on-disk layout that //! [`crate::demo::recorder`] and the sandbox bootstrap can rely on. @@ -14,6 +15,7 @@ //! ffmpeg//bin/ffmpeg.exe # Gyan ffmpeg essentials zip //! gifski/win/gifski.exe # gifski release tar.xz //! carnac/lib/net45/Carnac.exe # Carnac release zip (nested) +//! vcredist/vc_redist.x64.exe # VC++ redist installer (no extract) //! ``` //! //! Where `` is `ffmpeg--essentials_build`. The expected @@ -117,6 +119,39 @@ pub const CARNAC: Pin = Pin { inner_archive: Some("carnac-2.3.13-full.nupkg"), }; +/// Visual C++ Redistributable pin (x64). +/// +/// The Windows Sandbox base image ships UCRT but **not** the MSVC +/// runtime DLLs (`vcruntime140.dll`, `msvcp140.dll`, ...). Upstream +/// gifski for Windows is dynamically linked against `vcruntime140`, +/// so without the redist installed in the sandbox the in-VM +/// `gifski.exe` fails immediately with `STATUS_DLL_NOT_FOUND` +/// (NTSTATUS `0xC0000135`). +/// +/// Vendoring Microsoft's standalone redistributable installer is +/// the canonical fix: `sandbox-bootstrap.ps1` runs +/// `vc_redist.x64.exe /install /quiet /norestart` before invoking +/// xtask, so the sandbox's real `System32` carries the full MSVC +/// runtime by the time gifski is launched. This handles every +/// future MSVC-built tool we may vendor in addition to gifski. +/// +/// Unlike the other pins this one is a self-contained executable +/// rather than an archive: `archive_name` and `exe_rel` are equal, +/// which signals [`ensure_pin`] to skip the extraction step. +/// +/// The `aka.ms` URL is Microsoft's permalink; if Microsoft ships a +/// new redist version with a different SHA, the pin verification +/// fails loudly and the developer refreshes the constants below +/// using the same workflow as for the other pins. +pub const VC_REDIST: Pin = Pin { + name: "vcredist", + url: "https://aka.ms/vs/17/release/vc_redist.x64.exe", + sha256: "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b", + archive_name: "vc_redist.x64.exe", + exe_rel: "vc_redist.x64.exe", + inner_archive: None, +}; + /// Resolved paths to the cached vendored binaries used by the /// recorder. /// @@ -161,6 +196,10 @@ pub fn ensure_bins(system: &S, bin_root: &Path) -> Result // referenced from Rust; the bootstrap script uses its // canonical sandbox-side mount path. ensure_pin(system, &CARNAC, bin_root)?; + // Same for the VC++ redistributable: the bootstrap installs it + // inside the sandbox via its canonical mount path, the host + // never invokes it directly. + ensure_pin(system, &VC_REDIST, bin_root)?; Ok(BinSet { ffmpeg, gifski }) } @@ -199,17 +238,24 @@ pub fn ensure_pin(system: &S, pin: &Pin, bin_root: &Path) -> Resu "bin: {} sha256 verified ({})", pin.name, pin.sha256 )); - system.extract_archive(&archive, &cache)?; - if let Some(inner) = pin.inner_archive { - let inner_path = cache.join(inner); - if !system.path_exists(&inner_path) { - bail!( - "bin: inner archive {} missing after extracting {}", - inner_path.display(), - archive.display() - ); + // Self-contained executables (e.g. the VC++ redistributable + // installer) live in pins where `archive_name` equals + // `exe_rel`: the downloaded file IS the entry binary. Skip + // extraction in that case - calling `extract_archive` on a + // standalone .exe would fail. + if pin.archive_name != pin.exe_rel { + system.extract_archive(&archive, &cache)?; + if let Some(inner) = pin.inner_archive { + let inner_path = cache.join(inner); + if !system.path_exists(&inner_path) { + bail!( + "bin: inner archive {} missing after extracting {}", + inner_path.display(), + archive.display() + ); + } + system.extract_archive(&inner_path, &cache)?; } - system.extract_archive(&inner_path, &cache)?; } if !system.path_exists(&exe) { bail!( diff --git a/xtask/src/demo/env/sandbox.rs b/xtask/src/demo/env/sandbox.rs index f5b4e854..2c5a14df 100644 --- a/xtask/src/demo/env/sandbox.rs +++ b/xtask/src/demo/env/sandbox.rs @@ -5,23 +5,43 @@ //! v1's hermetic recording path. The host: //! //! 1. Ensures `target/demo/bin/` is populated (vendored ffmpeg, -//! gifski, Carnac with SHA verification) via -//! [`crate::demo::bin::ensure_bins`]. -//! 2. Builds `target/demo/csshw-demo.wsb` from a string template -//! that mounts the workspace (read-only), the bin cache -//! (read-only), `xtask/demo-assets/` (read-only), and a -//! writable output folder (`target/demo/out/`) into known paths -//! inside the sandbox. -//! 3. Launches the sandbox via +//! gifski, Carnac, and the VC++ redistributable installer with +//! SHA verification) via [`crate::demo::bin::ensure_bins`]. +//! 2. Builds csshw + xtask with a statically linked MSVC runtime via +//! [`DemoSystem::cargo_build_demo_artifacts`](crate::demo::DemoSystem::cargo_build_demo_artifacts) +//! directly into the writable sandbox mount at +//! `target/demo/out/work/target/`. Static linking removes the +//! runtime dependency on `VCRUNTIME140.dll` for csshw and xtask +//! themselves; vendored binaries (gifski) still need it, and +//! that gap is closed by the bootstrap-time vc_redist install +//! described below. Building straight into the writable mount +//! means the VM can run the binaries at +//! `C:\demo\out\work\target\debug\` with no in-VM copy and no +//! extra mount. +//! 3. Builds `target/demo/csshw-demo.wsb` from a string template +//! that mounts the bin cache (read-only), +//! `xtask/demo-assets/` (read-only), and the writable output +//! folder `target/demo/out/` into known paths inside the +//! sandbox. The workspace itself is intentionally not mounted: +//! the writable mount already carries the only host-side payload +//! the VM needs (the freshly built `.exe`s under `out\work\`). +//! 4. Launches the sandbox via //! [`DemoSystem::spawn_sandbox`](crate::demo::DemoSystem::spawn_sandbox). -//! The `LogonCommand` runs `sandbox-bootstrap.ps1`, which sources -//! `setup-desktop.ps1`, optionally launches Carnac, builds csshw, -//! invokes `xtask record-demo --env local`, copies the resulting -//! GIF to `C:\demo\out\csshw.gif`, and writes a sentinel -//! `C:\demo\out\done.flag` with the exit status before shutting -//! the sandbox VM down. -//! 4. Polls the host-side mount for `done.flag`, copies the GIF +//! The `LogonCommand` runs `sandbox-bootstrap.ps1`, which +//! sources `setup-desktop.ps1`, runs the vendored +//! `vc_redist.x64.exe /install /quiet /norestart` to give the +//! sandbox the MSVC runtime DLLs gifski needs, optionally +//! launches Carnac, sets `CSSHW_DEMO_WORKSPACE=C:\demo\out\work`, +//! and invokes +//! `xtask record-demo --env local --out C:\demo\out\csshw.gif`. +//! Because the GIF lands directly on the writable mount no +//! in-VM copy is needed; the sentinel `C:\demo\out\done.flag` +//! carries the exit status. +//! 5. Polls the host-side mount for `done.flag`, copies the GIF //! back to the user-requested path, and tears the sandbox down. +//! The poll loop also bails out early if the user closes the +//! sandbox window manually so the host does not hang for the +//! full sentinel timeout. //! //! Windows Sandbox is unavailable on GitHub-hosted runners (no //! nested virtualisation), so this provider is the local-iteration @@ -40,7 +60,6 @@ const SANDBOX_ROOT: &str = "C:\\demo"; /// Sandbox-side mount points. Hard-coded so the bootstrap script /// (PowerShell, no command-line plumbing) can reference them. -const SANDBOX_REPO: &str = "C:\\demo\\repo"; const SANDBOX_BIN: &str = "C:\\demo\\bin"; const SANDBOX_ASSETS: &str = "C:\\demo\\assets"; const SANDBOX_OUT: &str = "C:\\demo\\out"; @@ -56,9 +75,9 @@ const SENTINEL_NAME: &str = "done.flag"; const SANDBOX_GIF_NAME: &str = "csshw.gif"; /// Hard ceiling on how long we wait for the sentinel to appear. -/// Sandbox boot + cargo build + 5-second capture + gifski encode -/// fits comfortably in 8 minutes even on a cold cache; longer than -/// that suggests the bootstrap itself wedged. +/// Sandbox boot + 5-second capture + gifski encode fits comfortably +/// in 8 minutes even on a cold cache; longer than that suggests the +/// bootstrap itself wedged. const SENTINEL_TIMEOUT: Duration = Duration::from_secs(8 * 60); /// Poll interval for [`wait_for_sentinel`]. Quick enough that the @@ -66,6 +85,24 @@ const SENTINEL_TIMEOUT: Duration = Duration::from_secs(8 * 60); /// slow enough not to hammer NTFS. const SENTINEL_POLL: Duration = Duration::from_millis(500); +/// How many times [`read_sentinel_with_retry`] retries when reading +/// the sentinel races the bootstrap's still-open write handle. The +/// in-VM `Set-Content` releases the handle in milliseconds; we retry +/// for ~5 seconds to absorb a slow shutdown without hanging. +const SENTINEL_READ_ATTEMPTS: u32 = 50; + +/// Backoff between sentinel-read retries. +const SENTINEL_READ_RETRY: Duration = Duration::from_millis(100); + +/// Number of poll iterations before [`wait_for_sentinel`] starts +/// querying [`DemoSystem::is_sandbox_running`]. `WindowsSandbox.exe` +/// returns from `spawn` before `WindowsSandboxClient.exe` is up, so +/// an immediate liveness check would race and false-negative. At +/// [`SENTINEL_POLL`] = 500 ms, 40 iterations is ~20 seconds, which +/// covers cold-boot reliably without significantly delaying the +/// "user closed the sandbox" detection path. +const LIVENESS_GRACE_POLLS: u32 = 40; + /// Resolved layout of the demo working tree on the host. Returned /// by [`prepare_layout`] so [`run`] and the unit tests share the /// path-building code. @@ -79,8 +116,23 @@ pub struct SandboxLayout { pub bin_dir: PathBuf, /// `/xtask/demo-assets/`. pub assets_dir: PathBuf, - /// `/target/demo/out/`. + /// `/target/demo/out/`. Writable mount; visible + /// inside the VM at [`SANDBOX_OUT`]. pub out_dir: PathBuf, + /// `/target/demo/out/work/`. Sandbox-side workspace + /// root passed to xtask via `CSSHW_DEMO_WORKSPACE`. Lives + /// under [`out_dir`](Self::out_dir) so files written by the + /// in-VM xtask appear on the host through the writable mount + /// without any extra copy step. + pub work_dir: PathBuf, + /// `/target/demo/out/work/target/`. Cargo target + /// directory for the static-CRT demo build. Placed under + /// [`work_dir`](Self::work_dir) so the freshly built + /// `csshw.exe` and `xtask.exe` land at exactly the path + /// xtask's local provider looks them up at + /// (`/target/debug/csshw.exe`) without any in-VM + /// staging. + pub build_target_dir: PathBuf, /// `/target/demo/csshw-demo.wsb`. pub wsb_path: PathBuf, /// Host path of the sentinel file the bootstrap writes. @@ -98,12 +150,16 @@ pub struct SandboxLayout { pub fn prepare_layout(workspace: &Path) -> SandboxLayout { let demo_root = workspace.join("target").join("demo"); let out_dir = demo_root.join("out"); + let work_dir = out_dir.join("work"); + let build_target_dir = work_dir.join("target"); SandboxLayout { workspace: workspace.to_path_buf(), demo_root: demo_root.clone(), bin_dir: demo_root.join("bin"), assets_dir: workspace.join("xtask").join("demo-assets"), out_dir: out_dir.clone(), + work_dir, + build_target_dir, wsb_path: demo_root.join("csshw-demo.wsb"), sentinel: out_dir.join(SENTINEL_NAME), sandbox_gif: out_dir.join(SANDBOX_GIF_NAME), @@ -112,23 +168,29 @@ pub fn prepare_layout(workspace: &Path) -> SandboxLayout { /// Build the `.wsb` XML body that boots the demo. /// -/// Five mount points are pinned to fixed sandbox-side paths so the +/// Three mount points are pinned to fixed sandbox-side paths so the /// bootstrap PowerShell script can hard-code them without command- /// line plumbing: /// -/// | Host path | Sandbox path | RO | -/// |----------------------------------------|------------------|-----| -/// | `` | [`SANDBOX_REPO`] | yes | -/// | `/target/demo/bin` | [`SANDBOX_BIN`] | yes | -/// | `/xtask/demo-assets` | [`SANDBOX_ASSETS`]| yes | -/// | `/target/demo/out` | [`SANDBOX_OUT`] | no | +/// | Host path | Sandbox path | RO | +/// |----------------------------------------|---------------------|-----| +/// | `/target/demo/bin` | [`SANDBOX_BIN`] | yes | +/// | `/xtask/demo-assets` | [`SANDBOX_ASSETS`] | yes | +/// | `/target/demo/out` | [`SANDBOX_OUT`] | no | +/// +/// The workspace itself is intentionally *not* mounted. The host +/// builds `csshw.exe` and `xtask.exe` straight into +/// `target/demo/out/work/target/debug/`, which is below the +/// writable out mount, so the binaries are visible inside the VM +/// at `C:\demo\out\work\target\debug\` with no in-VM copy. /// /// `` is intentionally not set: as of Windows 11 23H2 /// the sandbox config schema does not expose a stable resolution /// element. The bootstrap script normalises the desktop (1920x1080, -/// 100 % scale, wallpaper, console font) by sourcing +/// 100 % scale, console font, hidden icons) by sourcing /// `setup-desktop.ps1` after first sign-in, which is the only place -/// these settings reliably apply. +/// these settings reliably apply. The wallpaper is left at the +/// Windows default. /// /// `no_overlay` is forwarded to the bootstrap via a positional /// argument so the same `.wsb` template covers both code paths. @@ -150,7 +212,6 @@ pub fn render_wsb(layout: &SandboxLayout, no_overlay: bool) -> String { \x20\x20Disable\r\n\ \x20\x20Enable\r\n\ \x20\x20\r\n\ - {repo}\ {bins}\ {assets}\ {out}\ @@ -159,7 +220,6 @@ pub fn render_wsb(layout: &SandboxLayout, no_overlay: bool) -> String { \x20\x20\x20\x20{bootstrap}\r\n\ \x20\x20\r\n\ \r\n", - repo = mapped_folder(&layout.workspace, SANDBOX_REPO, true), bins = mapped_folder(&layout.bin_dir, SANDBOX_BIN, true), assets = mapped_folder(&layout.assets_dir, SANDBOX_ASSETS, true), out = mapped_folder(&layout.out_dir, SANDBOX_OUT, false), @@ -184,24 +244,34 @@ fn mapped_folder(host: &Path, sandbox: &str, read_only: bool) -> String { ) } -/// Block until `sentinel` exists, then return its contents. +/// Block until `sentinel` exists, then return. /// /// Polls [`DemoSystem::path_exists`] every [`SENTINEL_POLL`] until -/// either the file appears or [`SENTINEL_TIMEOUT`] elapses. Uses +/// either the file appears, the sandbox VM disappears (the user +/// closed it manually), or [`SENTINEL_TIMEOUT`] elapses. Uses /// [`DemoSystem::sleep`] so unit tests can short-circuit the wait. /// /// # Errors /// -/// Returns an error on timeout, including the elapsed duration so -/// the user can distinguish "sandbox never booted" from "demo took -/// too long" by checking the host-side log. +/// Returns an error if the sandbox stops running before the sentinel +/// is written, or on timeout. The error message identifies which +/// case fired so the user can distinguish "sandbox never booted" +/// from "user closed the sandbox" from "demo took too long". pub fn wait_for_sentinel(system: &S, sentinel: &Path) -> Result<()> { - let start = Instant::now(); - let deadline = start + SENTINEL_TIMEOUT; + let deadline = Instant::now() + SENTINEL_TIMEOUT; + let mut polls: u32 = 0; loop { if system.path_exists(sentinel) { return Ok(()); } + if polls >= LIVENESS_GRACE_POLLS && !system.is_sandbox_running() { + bail!( + "sandbox VM is no longer running and {} was not written; \ + the sandbox window was likely closed manually before the \ + demo finished", + sentinel.display() + ); + } if Instant::now() >= deadline { bail!( "sandbox sentinel {} did not appear within {:?}; \ @@ -211,7 +281,64 @@ pub fn wait_for_sentinel(system: &S, sentinel: &Path) -> Result<( ); } system.sleep(SENTINEL_POLL); + polls = polls.saturating_add(1); + } +} + +/// Read the sentinel, retrying briefly when Windows reports a +/// share violation. The bootstrap writes the file via PowerShell's +/// `Set-Content` and immediately calls `Stop-Computer -Force`; the +/// host can race the still-open write handle and see "being used +/// by another process" (`ERROR_SHARING_VIOLATION`, os error 32). +/// +/// Polls [`DemoSystem::sleep`] so unit tests can short-circuit the +/// retry loop. +fn read_sentinel_with_retry(system: &S, sentinel: &Path) -> Result { + let mut last_err: Option = None; + for _ in 0..SENTINEL_READ_ATTEMPTS { + match std::fs::read_to_string(sentinel) { + Ok(s) => return Ok(s), + Err(e) if e.raw_os_error() == Some(32) => { + last_err = Some(e); + system.sleep(SENTINEL_READ_RETRY); + } + Err(e) => { + return Err(anyhow::anyhow!( + "reading sentinel {}: {e}", + sentinel.display() + )); + } + } + } + let detail = last_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + bail!( + "reading sentinel {} kept hitting a sharing violation after {} attempts: {detail}", + sentinel.display(), + SENTINEL_READ_ATTEMPTS + ) +} + +/// Verify the host build placed `csshw.exe` and `xtask.exe` at the +/// paths the in-VM bootstrap expects. Pure check kept separate from +/// [`run`] for testability. +fn verify_built_artifacts(system: &S, layout: &SandboxLayout) -> Result<()> { + let built_csshw = layout.build_target_dir.join("debug").join("csshw.exe"); + let built_xtask = layout.build_target_dir.join("debug").join("xtask.exe"); + if !system.path_exists(&built_csshw) { + bail!( + "expected {} after cargo_build_demo_artifacts, but it is missing", + built_csshw.display() + ); + } + if !system.path_exists(&built_xtask) { + bail!( + "expected {} after cargo_build_demo_artifacts, but it is missing", + built_xtask.display() + ); } + Ok(()) } /// Prepare and run the demo inside a fresh Windows Sandbox VM. @@ -254,13 +381,19 @@ pub fn run( bin::ensure_bins(system, &layout.bin_dir) .with_context(|| "preparing target/demo/bin/ for sandbox mount")?; - // Build csshw on the host so the sandbox bootstrap can find - // a ready-to-run csshw.exe under the read-only repo mount. - // The sandbox itself has no Rust toolchain. - system.print_info("sandbox env: building csshw on host (cargo build -p csshw)"); + // Build csshw + xtask on the host with a statically linked MSVC + // runtime directly into `target/demo/out/work/target/`. That + // path is below the writable sandbox mount, so the binaries + // appear inside the VM at `C:\demo\out\work\target\debug\` - + // exactly where xtask's local provider looks for csshw.exe - + // with no in-VM copy step. + system.ensure_dir(&layout.work_dir)?; + system.print_info("sandbox env: building csshw + xtask on host (static MSVC runtime)"); system - .cargo_build_csshw(&layout.workspace) - .with_context(|| "building csshw on the host before launching sandbox")?; + .cargo_build_demo_artifacts(&layout.workspace, &layout.build_target_dir) + .with_context(|| "building static-CRT demo artifacts on the host")?; + verify_built_artifacts(system, &layout) + .with_context(|| "verifying static-CRT demo artifacts after build")?; // Wipe leftover sentinels and GIFs from previous runs so the // poll loop can use plain "exists" without a timestamp check. @@ -296,8 +429,7 @@ pub fn run( system.spawn_sandbox(&layout.wsb_path)?; let result = (|| -> Result<()> { wait_for_sentinel(system, &layout.sentinel)?; - let status = std::fs::read_to_string(&layout.sentinel) - .with_context(|| format!("reading sentinel {}", layout.sentinel.display()))?; + let status = read_sentinel_with_retry(system, &layout.sentinel)?; let status_trim = status.trim(); if status_trim != "ok" { bail!("sandbox bootstrap reported non-ok status: {}", status_trim); diff --git a/xtask/src/demo/mod.rs b/xtask/src/demo/mod.rs index 37965e0a..51ec972c 100644 --- a/xtask/src/demo/mod.rs +++ b/xtask/src/demo/mod.rs @@ -16,7 +16,7 @@ //! into `target/demo/bin/` and verified by [`bin::ensure_bins`]), //! so a developer no longer needs ffmpeg, gifski, or Carnac on //! `PATH`. The sandbox provider boots the demo inside a fresh -//! Windows Sandbox VM with a normalised desktop (wallpaper, console +//! Windows Sandbox VM with a normalised desktop (console //! font, DPI) and an optional Carnac keystroke overlay; Sandbox //! cannot run on GitHub-hosted runners (no nested virtualisation), //! so v1 is the local-iteration path. CI workflows and the @@ -184,11 +184,23 @@ pub trait DemoSystem { /// Idempotent. fn terminate_sandbox(&self) -> Result<()>; - /// Run `cargo build -p csshw` against `workspace`, leaving - /// `target/debug/csshw.exe` ready for the sandbox bootstrap to - /// pick up. Production impl shells out to `cargo`; tests stub - /// it to a no-op. - fn cargo_build_csshw(&self, workspace: &Path) -> Result<()>; + /// Return `true` while the in-flight Windows Sandbox VM is still + /// alive. Used by the sentinel poll loop to detect the case where + /// the user closes the sandbox window manually before the + /// bootstrap writes `done.flag`. The launcher process exits soon + /// after spawning the VM, so this checks the user-facing + /// `WindowsSandboxClient.exe` by image name. + fn is_sandbox_running(&self) -> bool; + + /// Build the demo's csshw + xtask binaries with a statically + /// linked MSVC runtime into `target_dir`, ready to be staged + /// into the sandbox. Static linking removes the runtime + /// dependency on `VCRUNTIME140.dll` / `MSVCP140.dll`, which the + /// Windows Sandbox base image does not ship. A separate target + /// directory is used so the static-CRT artifacts do not + /// invalidate the developer's normal cargo cache. Production + /// impl shells out to `cargo`; tests stub it to a no-op. + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> Result<()>; /// Print an informational message to stdout. fn print_info(&self, message: &str); @@ -229,8 +241,20 @@ impl Default for RealSystem { mod windows_input; +/// Environment variable that, if set, overrides the workspace path +/// resolved by [`RealSystem::workspace_root`]. The sandbox bootstrap +/// sets it before invoking `xtask record-demo --env local` because +/// `CARGO_MANIFEST_DIR` is baked at compile time on the host and so +/// points at a path that does not exist inside the VM. +const WORKSPACE_OVERRIDE_ENV: &str = "CSSHW_DEMO_WORKSPACE"; + impl DemoSystem for RealSystem { fn workspace_root(&self) -> Result { + if let Ok(override_path) = std::env::var(WORKSPACE_OVERRIDE_ENV) { + if !override_path.is_empty() { + return Ok(PathBuf::from(override_path)); + } + } let manifest_dir = env!("CARGO_MANIFEST_DIR"); Path::new(manifest_dir) .parent() @@ -489,17 +513,57 @@ impl DemoSystem for RealSystem { Ok(()) } - fn cargo_build_csshw(&self, workspace: &Path) -> Result<()> { + fn is_sandbox_running(&self) -> bool { + // `WindowsSandbox.exe` is just a launcher - it returns + // almost immediately after the VM starts, so the spawned + // child handle is unreliable. The user-facing client is + // the long-lived process; query it by image name. + let output = std::process::Command::new("tasklist") + .args([ + "/FI", + "IMAGENAME eq WindowsSandboxClient.exe", + "/NH", + "/FO", + "CSV", + ]) + .stderr(std::process::Stdio::null()) + .output(); + match output { + Ok(o) if o.status.success() => { + // `tasklist` prints a localised "no tasks" banner + // when nothing matches; on a hit it prints one CSV + // row containing the image name. + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.contains("WindowsSandboxClient.exe") + } + _ => false, + } + } + + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> Result<()> { + // Static MSVC runtime so the resulting .exe has no runtime + // dependency on `VCRUNTIME140.dll` / `MSVCP140.dll`. The + // Windows Sandbox base image ships without the VC++ + // Redistributable, so a default cargo build produces + // executables that fail to load inside the VM. + // + // A dedicated `--target-dir` is used so the differing + // RUSTFLAGS do not invalidate the developer's normal cargo + // cache on every demo run. + // // Spawn cargo with stdout/stderr inherited so the user sees - // build progress live. Debug profile only - the demo neither - // benefits from optimisations nor wants the longer build. + // build progress live. Debug profile only - the demo + // neither benefits from optimisations nor wants the longer + // release build. let status = std::process::Command::new("cargo") - .args(["build", "-p", "csshw"]) + .args(["build", "-p", "csshw", "-p", "xtask", "--target-dir"]) + .arg(target_dir) .current_dir(workspace) + .env("RUSTFLAGS", "-C target-feature=+crt-static") .status() .map_err(|e| anyhow::anyhow!("failed to spawn cargo build: {e}"))?; if !status.success() { - anyhow::bail!("cargo build -p csshw failed: {status}"); + anyhow::bail!("cargo build for demo artifacts failed: {status}"); } Ok(()) } diff --git a/xtask/src/demo/recorder.rs b/xtask/src/demo/recorder.rs index fdcefeaa..f90fe5c7 100644 --- a/xtask/src/demo/recorder.rs +++ b/xtask/src/demo/recorder.rs @@ -36,10 +36,16 @@ use anyhow::{bail, Context, Result}; use super::DemoSystem; -/// Capture resolution and framerate. Pinned to keep recordings -/// identical across developer machines and CI runners. +/// Capture framerate. Pinned to keep recordings identical across +/// developer machines and CI runners. The capture resolution is +/// deliberately *not* pinned: gdigrab's `-video_size` crops from +/// the desktop's top-left corner, and Windows Sandbox auto-sizes +/// to the host monitor with no stable hook for forcing a specific +/// resolution. Letting gdigrab default to the actual primary +/// monitor size means the entire sandbox window is captured; the +/// downstream `scale=1280:-1` step in [`stop_ffmpeg_and_encode`] +/// normalises the encoded GIF width regardless of source size. const CAPTURE_FRAMERATE: &str = "30"; -const CAPTURE_VIDEO_SIZE: &str = "1920x1080"; /// Encode parameters for the GIF. Re-used in the retry ladder if the /// output exceeds the size budget (deferred to v3). @@ -84,8 +90,6 @@ pub fn spawn_ffmpeg_gdigrab(ffmpeg_exe: &Path, out_raw: &Path) -> Result "gdigrab", "-framerate", CAPTURE_FRAMERATE, - "-video_size", - CAPTURE_VIDEO_SIZE, "-i", "desktop", "-c:v", diff --git a/xtask/src/demo/windows_input.rs b/xtask/src/demo/windows_input.rs index 421ef3aa..fdb09e04 100644 --- a/xtask/src/demo/windows_input.rs +++ b/xtask/src/demo/windows_input.rs @@ -20,8 +20,8 @@ mod imp { use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; use windows::Win32::System::Threading::AttachThreadInput; use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, - KEYEVENTF_UNICODE, VIRTUAL_KEY, + SendInput, VkKeyScanW, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, + KEYEVENTF_KEYUP, VIRTUAL_KEY, }; use windows::Win32::UI::WindowsAndMessaging::{ EnumWindows, GetForegroundWindow, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, @@ -119,75 +119,108 @@ mod imp { Ok(()) } - /// Send a single Unicode codepoint via `SendInput(KEYEVENTF_UNICODE)`. + /// Send a single character by translating it into virtual-key + /// events via `VkKeyScanW`, applying shift / ctrl / alt modifiers + /// as needed. + /// + /// The earlier implementation used `SendInput(KEYEVENTF_UNICODE)`, + /// which delivers the keystroke to the foreground window's message + /// queue but surfaces at low-level keyboard hooks + /// (`WH_KEYBOARD_LL`) with `vkCode = VK_PACKET (0xE7)`. Carnac + /// reads the hook and renders unmapped vkCodes as the literal text + /// "Packet", so a `whoami` broadcast showed up in the overlay as + /// six "Packet" rows. Translating to a real virtual-key sequence + /// first means the hook sees the actual key and the overlay + /// displays the character that was typed. + /// + /// Only characters that the current keyboard layout maps to a + /// single keystroke are supported. The canonical demo script types + /// ASCII text on the en-US layout the sandbox boots into, where + /// every character has a `VkKeyScanW` entry; surrogate-pair + /// codepoints, dead keys, and unmapped chars are rejected with an + /// error so the demo fails loudly rather than silently injecting + /// a `VK_PACKET` Carnac cannot decode. pub fn send_unicode_char(c: char) -> Result<()> { - // BMP characters fit in a single u16; supplementary plane - // codepoints need surrogate pairs. We synthesise both halves - // when needed. + // BMP-only: every char in the v0 script is ASCII, and Unicode + // codepoints that need a surrogate pair would require multiple + // VkKeyScanW lookups with no guarantee of a meaningful mapping. let mut buf = [0u16; 2]; let units = c.encode_utf16(&mut buf); - for unit in units.iter().copied() { - push_unicode(unit)?; + if units.len() != 1 { + anyhow::bail!( + "send_unicode_char: {c:?} requires a UTF-16 surrogate pair; \ + the demo script is restricted to BMP keyboard characters" + ); } - Ok(()) - } - - /// Send VK_DOWN + VK_UP for a single Unicode code unit. - fn push_unicode(unit: u16) -> Result<()> { - let down = INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(0), - wScan: unit, - dwFlags: KEYEVENTF_UNICODE, - time: 0, - dwExtraInfo: 0, - }, - }, - }; - let up = INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(0), - wScan: unit, - dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, - time: 0, - dwExtraInfo: 0, - }, - }, - }; - send_pair(&[down, up]) + // SAFETY: VkKeyScanW takes a UTF-16 code unit by value and has + // no out-pointer. Returns -1 when no key on the active layout + // produces this character. + let scan = unsafe { VkKeyScanW(units[0]) }; + if scan == -1 { + anyhow::bail!( + "send_unicode_char: no VkKeyScanW mapping for {c:?} on the current layout" + ); + } + let vk = (scan & 0xFF) as u16; + let shift_state = (scan >> 8) & 0xFF; + // VkKeyScanW shift-state bits: 1=Shift, 2=Ctrl, 4=Alt. + let shift = shift_state & 1 != 0; + let ctrl = shift_state & 2 != 0; + let alt = shift_state & 4 != 0; + /// Windows `VK_SHIFT`. + const VK_SHIFT: u16 = 0x10; + /// Windows `VK_CONTROL`. + const VK_CONTROL: u16 = 0x11; + /// Windows `VK_MENU` (Alt). + const VK_MENU: u16 = 0x12; + let mut events: Vec = Vec::with_capacity(8); + if shift { + events.push(make_vk_input(VK_SHIFT, false)); + } + if ctrl { + events.push(make_vk_input(VK_CONTROL, false)); + } + if alt { + events.push(make_vk_input(VK_MENU, false)); + } + events.push(make_vk_input(vk, false)); + events.push(make_vk_input(vk, true)); + if alt { + events.push(make_vk_input(VK_MENU, true)); + } + if ctrl { + events.push(make_vk_input(VK_CONTROL, true)); + } + if shift { + events.push(make_vk_input(VK_SHIFT, true)); + } + send_pair(&events) } /// Send a virtual-key down + up pair. pub fn send_vk(vk: u16) -> Result<()> { - let down = INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(vk), - wScan: 0, - dwFlags: KEYBD_EVENT_FLAGS(0), - time: 0, - dwExtraInfo: 0, - }, - }, + send_pair(&[make_vk_input(vk, false), make_vk_input(vk, true)]) + } + + /// Build a single `INPUT_KEYBOARD` event for the given virtual key. + fn make_vk_input(vk: u16, key_up: bool) -> INPUT { + let flags = if key_up { + KEYEVENTF_KEYUP + } else { + KEYBD_EVENT_FLAGS(0) }; - let up = INPUT { + INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { wVk: VIRTUAL_KEY(vk), wScan: 0, - dwFlags: KEYEVENTF_KEYUP, + dwFlags: flags, time: 0, dwExtraInfo: 0, }, }, - }; - send_pair(&[down, up]) + } } fn send_pair(events: &[INPUT]) -> Result<()> { diff --git a/xtask/src/tests/test_demo_bin.rs b/xtask/src/tests/test_demo_bin.rs index 0ebed966..3235bbfa 100644 --- a/xtask/src/tests/test_demo_bin.rs +++ b/xtask/src/tests/test_demo_bin.rs @@ -41,7 +41,8 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; - fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_config_override.rs b/xtask/src/tests/test_demo_config_override.rs index 46335695..da262ff6 100644 --- a/xtask/src/tests/test_demo_config_override.rs +++ b/xtask/src/tests/test_demo_config_override.rs @@ -36,7 +36,8 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; - fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_driver.rs b/xtask/src/tests/test_demo_driver.rs index 9c528b98..9651ff21 100644 --- a/xtask/src/tests/test_demo_driver.rs +++ b/xtask/src/tests/test_demo_driver.rs @@ -27,7 +27,7 @@ mock! { fn sleep(&self, duration: Duration); fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; fn terminate_csshw(&self) -> anyhow::Result<()>; - fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; fn path_exists(&self, path: &Path) -> bool; @@ -37,6 +37,7 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } diff --git a/xtask/src/tests/test_demo_env_sandbox.rs b/xtask/src/tests/test_demo_env_sandbox.rs index 088ba588..00274016 100644 --- a/xtask/src/tests/test_demo_env_sandbox.rs +++ b/xtask/src/tests/test_demo_env_sandbox.rs @@ -39,7 +39,8 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; - fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); } @@ -63,6 +64,14 @@ fn test_prepare_layout_resolves_known_paths_under_workspace() { assert!(s(&layout.bin_dir).ends_with("ws/target/demo/bin")); assert!(s(&layout.assets_dir).ends_with("ws/xtask/demo-assets")); assert!(s(&layout.out_dir).ends_with("ws/target/demo/out")); + // work_dir lives under the writable out mount so files the + // in-VM xtask writes (and the binaries the host builds for it) + // surface on the host without an extra copy. + assert!(s(&layout.work_dir).ends_with("ws/target/demo/out/work")); + // build_target_dir is `/target` so cargo's debug exes + // land at the path xtask's local provider expects + // (`/target/debug/csshw.exe`). + assert!(s(&layout.build_target_dir).ends_with("ws/target/demo/out/work/target")); assert!(s(&layout.wsb_path).ends_with("ws/target/demo/csshw-demo.wsb")); assert!(s(&layout.sentinel).ends_with("ws/target/demo/out/done.flag")); assert!(s(&layout.sandbox_gif).ends_with("ws/target/demo/out/csshw.gif")); @@ -80,10 +89,6 @@ fn test_render_wsb_pins_mount_layout_and_logon_command() { // to the canonical sandbox-side path. assert!(body.contains(""), "{body}"); assert!(body.contains(""), "{body}"); - assert!( - body.contains("C:\\demo\\repo"), - "{body}" - ); assert!( body.contains("C:\\demo\\bin"), "{body}" @@ -96,10 +101,22 @@ fn test_render_wsb_pins_mount_layout_and_logon_command() { body.contains("C:\\demo\\out"), "{body}" ); + // The workspace itself is intentionally not mounted: the host + // builds the binaries directly into the writable out mount. + assert!( + !body.contains("C:\\demo\\repo"), + "the legacy whole-workspace mount must not regress: {body}" + ); + // The previous design carried a separate read-only stage mount; + // the writable out mount now subsumes it. + assert!( + !body.contains("C:\\demo\\stage"), + "the old stage mount must not reappear: {body}" + ); // The out folder is the only writable mount. let ro_count = body.matches("true").count(); let rw_count = body.matches("false").count(); - assert_eq!(ro_count, 3, "expected 3 RO mounts: {body}"); + assert_eq!(ro_count, 2, "expected 2 RO mounts: {body}"); assert_eq!(rw_count, 1, "expected 1 RW mount: {body}"); // LogonCommand routes through the bootstrap script. assert!(body.contains(""), "{body}"); @@ -133,23 +150,27 @@ fn test_render_wsb_passes_no_overlay_flag_when_set() { } #[test] -fn test_render_wsb_uses_workspace_host_path_for_repo_mount() { +fn test_render_wsb_uses_workspace_host_path_for_out_mount() { // Arrange let layout = prepare_layout(Path::new("D:\\some place\\ws")); // Act let body = render_wsb(&layout, false); - // Assert + // Assert: the writable out mount carries the full host path + // through unescaped (Windows paths cannot contain XML special + // chars). assert!( - body.contains("D:\\some place\\ws"), + body.contains("D:\\some place\\ws\\target\\demo\\out"), "host path leaks straight to XML: {body}" ); } #[test] fn test_wait_for_sentinel_returns_when_file_appears() { - // Arrange: report missing for two polls then present. + // Arrange: report missing for two polls then present. The + // 20-second liveness grace window means is_sandbox_running is + // never queried in this fast-success path. let mut mock = quiet_mock(); let calls: Arc> = Arc::new(Mutex::new(0)); let slot = calls.clone(); @@ -174,3 +195,33 @@ fn test_wait_for_sentinel_returns_when_file_appears() { // immediately without sleeping. assert_eq!(*sleeps.lock().unwrap(), 2); } + +#[test] +fn test_wait_for_sentinel_bails_when_sandbox_closes_after_grace_window() { + // Arrange: the sentinel never appears. is_sandbox_running is + // only consulted after the grace window of poll iterations, + // which the mocked sleeps make zero-cost in wall-clock terms. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_sleep().returning(|_| ()); + let liveness_calls: Arc> = Arc::new(Mutex::new(0)); + let liveness_slot = liveness_calls.clone(); + mock.expect_is_sandbox_running().returning(move || { + *liveness_slot.lock().unwrap() += 1; + false + }); + + // Act + let res = wait_for_sentinel(&mock, Path::new("/dev/null/done.flag")); + + // Assert + let err = res.expect_err("expected an error when the sandbox disappears"); + let msg = err.to_string(); + assert!( + msg.contains("sandbox VM is no longer running"), + "error should explain the sandbox closure: {msg}" + ); + // Liveness is queried exactly once - the first check after the + // grace window fires the bail. + assert_eq!(*liveness_calls.lock().unwrap(), 1); +} diff --git a/xtask/src/tests/test_demo_recorder.rs b/xtask/src/tests/test_demo_recorder.rs index d2241fc8..a585cef5 100644 --- a/xtask/src/tests/test_demo_recorder.rs +++ b/xtask/src/tests/test_demo_recorder.rs @@ -40,7 +40,8 @@ mock! { fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; fn terminate_sandbox(&self) -> anyhow::Result<()>; - fn cargo_build_csshw(&self, workspace: &Path) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; fn print_info(&self, message: &str); fn print_debug(&self, message: &str); }