diff --git a/Cargo.lock b/Cargo.lock
index 55dab27d..c5c697a7 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"
@@ -73,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"
@@ -115,6 +133,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"
@@ -299,6 +326,30 @@ 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 = "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"
@@ -308,6 +359,22 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "csshw"
version = "0.18.1"
@@ -359,6 +426,27 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "directories"
version = "5.0.1"
@@ -399,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"
@@ -475,6 +574,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"
@@ -538,6 +647,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"
@@ -753,6 +872,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"
@@ -1246,6 +1375,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"
@@ -1403,6 +1561,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"
@@ -1554,6 +1723,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"
@@ -1834,6 +2014,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"
@@ -2729,6 +2915,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"
@@ -2747,9 +2943,32 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
+ "lzma-rs",
"mockall",
+ "regex",
"semver",
+ "sha2",
+ "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]]
@@ -2758,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/README.md b/README.md
index 52f08dc3..fb4ae7b2 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,39 @@ 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 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. 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.
+ 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
+`--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 62537353..214faa52 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -14,6 +14,35 @@ 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: SHA-256 verification of vendored ffmpeg / gifski /
+# Carnac binaries downloaded into `target/demo/bin/`.
+sha2 = "0.10"
+# 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
+# (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/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..82b47192
--- /dev/null
+++ b/xtask/demo-assets/sandbox-bootstrap.ps1
@@ -0,0 +1,180 @@
+# Bootstraps the csshw demo recording inside Windows Sandbox.
+#
+# Mounted folders (set up by xtask::demo::env::sandbox::render_wsb):
+# C:\demo\bin ffmpeg / gifski / Carnac / vcredist caches (RO)
+# C:\demo\assets this script + setup-desktop.ps1 (read-only)
+# 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 (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).
+# 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.
+
+[CmdletBinding()]
+param(
+ [switch] $NoOverlay
+)
+
+$ErrorActionPreference = 'Stop'
+
+# 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 (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) {
+ 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'
+ }
+
+ # 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)) {
+ throw "missing $xtaskExe; the host build did not produce xtask.exe on the writable mount"
+ }
+ if (-not (Test-Path -LiteralPath $csshwExe)) {
+ throw "missing $csshwExe; the host build did not produce csshw.exe on the writable mount"
+ }
+
+ Write-Host '[bootstrap] running xtask record-demo --env local'
+ $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
+ }
+
+ if (-not (Test-Path -LiteralPath 'C:\demo\out\csshw.gif')) {
+ throw 'expected C:\demo\out\csshw.gif after record-demo, but it is missing'
+ }
+
+ $status = 'ok'
+}
+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 {
+ 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..4bb433f8
--- /dev/null
+++ b/xtask/demo-assets/setup-desktop.ps1
@@ -0,0 +1,74 @@
+# 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:
+# - 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-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-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..6fabe28f
--- /dev/null
+++ b/xtask/src/demo/bin.rs
@@ -0,0 +1,283 @@
+//! Vendored binary management for the `record-demo` recorder.
+//!
+//! v0 expected `ffmpeg` and `gifski` on `PATH`. v1 ships SHA-pinned
+//! 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.
+//!
+//! # 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)
+//! vcredist/vc_redist.x64.exe # VC++ redist installer (no extract)
+//! ```
+//!
+//! 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"),
+};
+
+/// 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.
+///
+/// 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)?;
+ // 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 })
+}
+
+/// 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
+ ));
+ // 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)?;
+ }
+ }
+ 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/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..f60ec90f
--- /dev/null
+++ b/xtask/src/demo/env/mod.rs
@@ -0,0 +1,11 @@
+//! 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`] - 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..2c5a14df
--- /dev/null
+++ b/xtask/src/demo/env/sandbox.rs
@@ -0,0 +1,459 @@
+//! 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, 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`, 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
+//! 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_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 + 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);
+
+/// 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.
+#[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/`. 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.
+ 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");
+ 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),
+ }
+}
+
+/// Build the `.wsb` XML body that boots the demo.
+///
+/// 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 |
+/// |----------------------------------------|---------------------|-----|
+/// | `/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, console font, hidden icons) by sourcing
+/// `setup-desktop.ps1` after first sign-in, which is the only place
+/// 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.
+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\
+ {bins}\
+ {assets}\
+ {out}\
+ \x20\x20\r\n\
+ \x20\x20\r\n\
+ \x20\x20\x20\x20{bootstrap}\r\n\
+ \x20\x20\r\n\
+ \r\n",
+ 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.
+///
+/// Polls [`DemoSystem::path_exists`] every [`SENTINEL_POLL`] until
+/// 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 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 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 {:?}; \
+ the in-sandbox bootstrap likely wedged",
+ sentinel.display(),
+ SENTINEL_TIMEOUT
+ );
+ }
+ 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.
+///
+/// # 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")?;
+
+ // 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_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.
+ 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 = 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);
+ }
+ 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
new file mode 100644
index 00000000..51ec972c
--- /dev/null
+++ b/xtask/src/demo/mod.rs
@@ -0,0 +1,626 @@
+//! 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.
+//!
+//! 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 (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;
+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. 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. 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,
+}
+
+/// 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<()>;
+
+ /// 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<()>;
+
+ /// 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);
+
+ /// Print a verbose message to stderr (gated on `CSSHW_XTASK_VERBOSE`).
+ fn print_debug(&self, message: &str);
+}
+
+/// Production implementation of [`DemoSystem`].
+///
+/// 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