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>, + csshw: std::sync::Mutex>, + sandbox: std::sync::Mutex>, +} + +impl RealSystem { + /// Construct a [`RealSystem`] with no in-flight children. + pub fn new() -> Self { + Self { + capture: std::sync::Mutex::new(None), + csshw: std::sync::Mutex::new(None), + sandbox: std::sync::Mutex::new(None), + } + } +} + +impl Default for RealSystem { + fn default() -> Self { + Self::new() + } +} + +mod windows_input; + +/// Environment variable that, if set, overrides the workspace path +/// resolved by [`RealSystem::workspace_root`]. The sandbox bootstrap +/// sets it before invoking `xtask record-demo --env local` because +/// `CARGO_MANIFEST_DIR` is baked at compile time on the host and so +/// points at a path that does not exist inside the VM. +const WORKSPACE_OVERRIDE_ENV: &str = "CSSHW_DEMO_WORKSPACE"; + +impl DemoSystem for RealSystem { + fn workspace_root(&self) -> Result { + if let Ok(override_path) = std::env::var(WORKSPACE_OVERRIDE_ENV) { + if !override_path.is_empty() { + return Ok(PathBuf::from(override_path)); + } + } + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest_dir) + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow::anyhow!("failed to resolve workspace root")) + } + + fn ensure_dir(&self, path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .map_err(|e| anyhow::anyhow!("failed to create {}: {e}", path.display())) + } + + fn write_file(&self, path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + self.ensure_dir(parent)?; + } + std::fs::write(path, content) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display())) + } + + fn copy_file(&self, from: &Path, to: &Path) -> Result<()> { + if let Some(parent) = to.parent() { + self.ensure_dir(parent)?; + } + std::fs::copy(from, to).map(|_| ()).map_err(|e| { + anyhow::anyhow!("failed to copy {} -> {}: {e}", from.display(), to.display()) + }) + } + + fn enum_windows(&self) -> Result> { + windows_input::enum_windows() + } + + fn set_foreground(&self, hwnd: u64) -> Result<()> { + windows_input::set_foreground(hwnd) + } + + fn send_unicode_char(&self, c: char) -> Result<()> { + windows_input::send_unicode_char(c) + } + + fn send_vk(&self, vk: u16) -> Result<()> { + windows_input::send_vk(vk) + } + + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } + + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> Result<()> { + let mut slot = self.csshw.lock().expect("csshw mutex poisoned"); + if slot.is_some() { + anyhow::bail!("spawn_csshw called while a daemon is already running"); + } + let mut cmd = std::process::Command::new(exe); + cmd.args(hosts).current_dir(cwd); + let child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("failed to spawn {}: {e}", exe.display()))?; + *slot = Some(child); + Ok(()) + } + + fn terminate_csshw(&self) -> Result<()> { + // Kill the daemon child we tracked. + if let Some(mut child) = self.csshw.lock().expect("csshw mutex poisoned").take() { + let _ = child.kill(); + let _ = child.wait(); + } + // Belt-and-braces: the daemon spawns clients via + // CreateProcessW(CREATE_NEW_CONSOLE), which detaches them from + // the daemon. Kill any lingering csshw.exe by image name. + // This is acceptable in dev contexts; v1 will switch to a + // Job Object so cleanup is automatic and safe. + let _ = std::process::Command::new("taskkill") + .args(["/IM", "csshw.exe", "/T", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Ok(()) + } + + fn start_recording(&self, out_raw: &Path) -> Result<()> { + let workspace = self.workspace_root()?; + let bin_dir = workspace.join("target").join("demo").join("bin"); + let bins = bin::ensure_bins(self, &bin_dir)?; + let mut slot = self.capture.lock().expect("capture mutex poisoned"); + if slot.is_some() { + anyhow::bail!("start_recording called while a capture is already running"); + } + let child = recorder::spawn_ffmpeg_gdigrab(&bins.ffmpeg, out_raw)?; + // Block until ffmpeg has actually started writing frames so + // the demo's first keystrokes are captured. The trait `sleep` + // and `file_size` are used so tests can short-circuit. + recorder::wait_for_capture_baseline(self, out_raw)?; + *slot = Some(child); + Ok(()) + } + + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> Result<()> { + let workspace = self.workspace_root()?; + let bin_dir = workspace.join("target").join("demo").join("bin"); + let bins = bin::ensure_bins(self, &bin_dir)?; + let child = self + .capture + .lock() + .expect("capture mutex poisoned") + .take() + .ok_or_else(|| anyhow::anyhow!("stop_recording called with no active capture"))?; + recorder::stop_ffmpeg_and_encode(child, &bins.ffmpeg, &bins.gifski, out_raw, out_gif) + } + + fn path_exists(&self, path: &Path) -> bool { + path.exists() + } + + fn file_size(&self, path: &Path) -> Result { + std::fs::metadata(path) + .map(|m| m.len()) + .map_err(|e| anyhow::anyhow!("failed to stat {}: {e}", path.display())) + } + + fn http_download(&self, url: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + self.ensure_dir(parent)?; + } + // Use PowerShell's Invoke-WebRequest so we inherit the OS's + // TLS root store and avoid pulling a Rust HTTP client into + // xtask. `-UseBasicParsing` skips the IE engine warm-up; the + // first run on a fresh sandbox would otherwise prompt for IE + // first-launch configuration. Single-quoted PS strings keep + // backslashes in `dest` literal. + let dest_str = dest.to_string_lossy().replace('\'', "''"); + let url_str = url.replace('\'', "''"); + let script = format!( + "$ProgressPreference='SilentlyContinue';\ + [Net.ServicePointManager]::SecurityProtocol=\ + [Net.SecurityProtocolType]::Tls12;\ + Invoke-WebRequest -UseBasicParsing -Uri '{url_str}' -OutFile '{dest_str}'" + ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn powershell for download: {e}"))?; + if !status.success() { + anyhow::bail!("powershell Invoke-WebRequest {url} -> {dest:?} failed: {status}"); + } + Ok(()) + } + + fn sha256_file(&self, path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut file = std::fs::File::open(path) + .map_err(|e| anyhow::anyhow!("failed to open {} for hashing: {e}", path.display()))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher) + .map_err(|e| anyhow::anyhow!("failed to read {} for hashing: {e}", path.display()))?; + let digest = hasher.finalize(); + Ok(digest.iter().map(|b| format!("{b:02x}")).collect()) + } + + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> Result<()> { + self.ensure_dir(dest_dir)?; + let name = archive + .file_name() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if name.ends_with(".tar.xz") { + // Windows ships BSD tar.exe but no `xz` binary, so the + // bundled tar shells out and fails. Decompress + untar + // in-process instead. Only the gifski release uses + // tar.xz today; .tar.gz / .tar are not currently + // exercised, so they are deliberately not handled here. + let f = std::fs::File::open(archive) + .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", archive.display()))?; + let mut tar_bytes = Vec::new(); + lzma_rs::xz_decompress(&mut std::io::BufReader::new(f), &mut tar_bytes) + .map_err(|e| anyhow::anyhow!("xz_decompress {} failed: {e}", archive.display()))?; + let mut tar_archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); + tar_archive.unpack(dest_dir).map_err(|e| { + anyhow::anyhow!( + "tar::unpack {} -> {} failed: {e}", + archive.display(), + dest_dir.display() + ) + })?; + return Ok(()); + } + if name.ends_with(".zip") || name.ends_with(".nupkg") { + // Pure-Rust extraction: PowerShell's `Expand-Archive` + // rejects `.nupkg` by extension, and the underlying + // `ZipFile::ExtractToDirectory` 3-arg overload binds + // differently between .NET Framework (Encoding) and + // .NET Core (bool overwriteFiles), so it's not portable + // across PowerShell editions. The `zip` crate has no + // such ambiguity. + let f = std::fs::File::open(archive) + .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", archive.display()))?; + let mut zip_archive = zip::ZipArchive::new(std::io::BufReader::new(f)) + .map_err(|e| anyhow::anyhow!("zip open {} failed: {e}", archive.display()))?; + zip_archive.extract(dest_dir).map_err(|e| { + anyhow::anyhow!( + "zip extract {} -> {} failed: {e}", + archive.display(), + dest_dir.display() + ) + })?; + return Ok(()); + } + anyhow::bail!( + "extract_archive: unsupported archive extension for {}", + archive.display() + ) + } + + fn spawn_sandbox(&self, wsb_path: &Path) -> Result<()> { + let mut slot = self.sandbox.lock().expect("sandbox mutex poisoned"); + if slot.is_some() { + anyhow::bail!("spawn_sandbox called while a sandbox is already running"); + } + let child = std::process::Command::new("WindowsSandbox.exe") + .arg(wsb_path) + .spawn() + .map_err(|e| { + anyhow::anyhow!( + "failed to spawn WindowsSandbox.exe. Enable the \ + \"Windows Sandbox\" optional feature first \ + (`Enable-WindowsOptionalFeature -Online \ + -FeatureName Containers-DisposableClientVM`): {e}" + ) + })?; + *slot = Some(child); + Ok(()) + } + + fn terminate_sandbox(&self) -> Result<()> { + if let Some(mut child) = self.sandbox.lock().expect("sandbox mutex poisoned").take() { + let _ = child.kill(); + let _ = child.wait(); + } + // Belt-and-braces: WindowsSandbox.exe is the launcher, but + // the sandbox VM itself is hosted by `vmcompute` and the + // user-facing `WindowsSandboxClient.exe`. A stale client + // can outlive the launcher. Best-effort taskkill mirrors + // [`Self::terminate_csshw`]. + let _ = std::process::Command::new("taskkill") + .args(["/IM", "WindowsSandboxClient.exe", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let _ = std::process::Command::new("taskkill") + .args(["/IM", "WindowsSandbox.exe", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + Ok(()) + } + + fn is_sandbox_running(&self) -> bool { + // `WindowsSandbox.exe` is just a launcher - it returns + // almost immediately after the VM starts, so the spawned + // child handle is unreliable. The user-facing client is + // the long-lived process; query it by image name. + let output = std::process::Command::new("tasklist") + .args([ + "/FI", + "IMAGENAME eq WindowsSandboxClient.exe", + "/NH", + "/FO", + "CSV", + ]) + .stderr(std::process::Stdio::null()) + .output(); + match output { + Ok(o) if o.status.success() => { + // `tasklist` prints a localised "no tasks" banner + // when nothing matches; on a hit it prints one CSV + // row containing the image name. + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.contains("WindowsSandboxClient.exe") + } + _ => false, + } + } + + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> Result<()> { + // Static MSVC runtime so the resulting .exe has no runtime + // dependency on `VCRUNTIME140.dll` / `MSVCP140.dll`. The + // Windows Sandbox base image ships without the VC++ + // Redistributable, so a default cargo build produces + // executables that fail to load inside the VM. + // + // A dedicated `--target-dir` is used so the differing + // RUSTFLAGS do not invalidate the developer's normal cargo + // cache on every demo run. + // + // Spawn cargo with stdout/stderr inherited so the user sees + // build progress live. Debug profile only - the demo + // neither benefits from optimisations nor wants the longer + // release build. + let status = std::process::Command::new("cargo") + .args(["build", "-p", "csshw", "-p", "xtask", "--target-dir"]) + .arg(target_dir) + .current_dir(workspace) + .env("RUSTFLAGS", "-C target-feature=+crt-static") + .status() + .map_err(|e| anyhow::anyhow!("failed to spawn cargo build: {e}"))?; + if !status.success() { + anyhow::bail!("cargo build for demo artifacts failed: {status}"); + } + Ok(()) + } + + fn print_info(&self, message: &str) { + println!("INFO - {message}"); + } + + fn print_debug(&self, message: &str) { + if std::env::var("CSSHW_XTASK_VERBOSE") + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + eprintln!("DEBUG - {message}"); + } + } +} + +/// Top-level entry point for `cargo xtask record-demo`. +/// +/// Orchestrates: build the canonical [`dsl::Script`], delegate +/// environment preparation to the matching `env::*` module, run the +/// driver, return. +/// +/// # Arguments +/// +/// * `system` - the [`DemoSystem`] (real or mocked). +/// * `out` - desired GIF path. Defaults to +/// `/target/demo/csshw.gif`. +/// * `env` - which environment provider to use. +/// * `no_record` - skip [`dsl::Step::StartCapture`] / +/// [`dsl::Step::StopCapture`]. Useful for iterating on the script +/// without burning capture time. +/// * `no_overlay` - skip the Carnac keystroke overlay. v0 always +/// behaves as if this is true (Carnac arrives in v1). +pub fn record_demo( + system: &S, + out: Option, + env: DemoEnv, + no_record: bool, + no_overlay: bool, +) -> Result<()> { + let workspace = system.workspace_root()?; + let out = out.unwrap_or_else(|| workspace.join("target/demo/csshw.gif")); + let script = script::build_canonical_v0().build()?; + system.print_info(&format!( + "record-demo: env={env:?} out={} steps={} no_record={no_record} no_overlay={no_overlay}", + out.display(), + script.len(), + )); + match env { + DemoEnv::Local => env::local::run(system, &script, &out, no_record)?, + DemoEnv::Sandbox => env::sandbox::run(system, &out, no_record, no_overlay)?, + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/test_demo_mod.rs"] +mod tests; diff --git a/xtask/src/demo/recorder.rs b/xtask/src/demo/recorder.rs new file mode 100644 index 00000000..f90fe5c7 --- /dev/null +++ b/xtask/src/demo/recorder.rs @@ -0,0 +1,255 @@ +//! ffmpeg + gifski subprocess orchestration for the demo recorder. +//! +//! Two-stage pipeline (matches industry practice for high-quality GIFs): +//! +//! 1. `ffmpeg -f gdigrab` -> lossless `.mkv` (writing during the run) +//! 2. `ffmpeg -i raw.mkv -vf "fps=20,scale=1280:-1:flags=lanczos"` +//! -> PNG frames in `target/demo/frames/` +//! 3. `gifski` -> the final `.gif` +//! +//! v1 invokes the SHA-pinned vendored binaries cached under +//! `target/demo/bin/` by [`crate::demo::bin::ensure_bins`]. The exe +//! paths are passed in by [`crate::demo::RealSystem`] so `recorder` +//! itself stays a side-effect-free orchestrator from the tests' +//! perspective (the actual `Command::status()` calls are mock-free +//! because `RealSystem` is the only caller). +//! +//! # Capture readiness +//! +//! ffmpeg's gdigrab takes a non-trivial amount of time to bring up +//! the screen-grabber the first time and to write the .mkv header. +//! Sending input before the header is written produces a recording +//! whose first frames are missing the action that just happened. +//! [`wait_for_capture_baseline`] polls +//! [`DemoSystem::file_size`](crate::demo::DemoSystem::file_size) +//! until ffmpeg has written enough bytes to guarantee the capture +//! pipeline is live, with a generous timeout. The DSL stays unaware +//! of this readiness contract: the script just emits `StartCapture` +//! and trusts the recorder. + +use std::io::Write; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; + +use super::DemoSystem; + +/// Capture framerate. Pinned to keep recordings identical across +/// developer machines and CI runners. The capture resolution is +/// deliberately *not* pinned: gdigrab's `-video_size` crops from +/// the desktop's top-left corner, and Windows Sandbox auto-sizes +/// to the host monitor with no stable hook for forcing a specific +/// resolution. Letting gdigrab default to the actual primary +/// monitor size means the entire sandbox window is captured; the +/// downstream `scale=1280:-1` step in [`stop_ffmpeg_and_encode`] +/// normalises the encoded GIF width regardless of source size. +const CAPTURE_FRAMERATE: &str = "30"; + +/// Encode parameters for the GIF. Re-used in the retry ladder if the +/// output exceeds the size budget (deferred to v3). +const ENCODE_FPS: &str = "20"; +const ENCODE_WIDTH: &str = "1280"; +const ENCODE_QUALITY: &str = "90"; + +/// Bytes the .mkv must reach before the capture is considered live. +/// gdigrab writes a Matroska header (~600-800 bytes) plus at least +/// one frame's worth of huffyuv-encoded data before flushing. 8 KiB +/// gives us comfortable margin without being so high that we wait +/// for several frames on a slow machine. +const CAPTURE_BASELINE_BYTES: u64 = 8 * 1024; + +/// Hard ceiling on the readiness wait. ffmpeg gdigrab on a clean +/// Windows Sandbox boots in ~1-2 seconds; 15 seconds covers slow +/// disks and the Carnac overlay's first foreground steal. +const CAPTURE_BASELINE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Poll interval for [`wait_for_capture_baseline`]. +const CAPTURE_BASELINE_POLL: Duration = Duration::from_millis(100); + +/// Spawn the long-running ffmpeg gdigrab capture writing to `out_raw`. +/// +/// Returns the child process so [`stop_ffmpeg_and_encode`] can shut it +/// down cleanly via `q\n` on stdin. +/// +/// # Arguments +/// +/// * `ffmpeg_exe` - absolute path to the vendored ffmpeg.exe (see +/// [`crate::demo::bin`]). +/// * `out_raw` - destination `.mkv`; parent directories are created. +pub fn spawn_ffmpeg_gdigrab(ffmpeg_exe: &Path, out_raw: &Path) -> Result { + if let Some(parent) = out_raw.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let child = Command::new(ffmpeg_exe) + .args([ + "-y", + "-f", + "gdigrab", + "-framerate", + CAPTURE_FRAMERATE, + "-i", + "desktop", + "-c:v", + "ffvhuff", + ]) + .arg(out_raw) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| { + format!( + "failed to spawn vendored ffmpeg at {}", + ffmpeg_exe.display() + ) + })?; + Ok(child) +} + +/// Block until ffmpeg has written at least +/// [`CAPTURE_BASELINE_BYTES`] to `out_raw`, indicating the capture +/// pipeline is live and subsequent input will be recorded. +/// +/// Polls [`DemoSystem::file_size`] at [`CAPTURE_BASELINE_POLL`]; the +/// `system.sleep` is used between polls so unit tests can short- +/// circuit the wait. +/// +/// # Errors +/// +/// Returns an error when the file does not reach the baseline within +/// [`CAPTURE_BASELINE_TIMEOUT`]. The caller (the trait method +/// `start_recording`) is responsible for any teardown. +pub fn wait_for_capture_baseline(system: &S, out_raw: &Path) -> Result<()> { + let deadline = Instant::now() + CAPTURE_BASELINE_TIMEOUT; + loop { + if system.path_exists(out_raw) { + // file_size can transiently fail on Windows while ffmpeg + // holds an exclusive write handle; treat that as "not + // yet" and keep polling. + if let Ok(size) = system.file_size(out_raw) { + if size >= CAPTURE_BASELINE_BYTES { + system.print_debug(&format!( + "recorder: capture baseline reached ({size} bytes)" + )); + return Ok(()); + } + } + } + if Instant::now() >= deadline { + bail!( + "ffmpeg did not reach capture baseline ({} bytes) within {:?}; \ + was the gdigrab device available?", + CAPTURE_BASELINE_BYTES, + CAPTURE_BASELINE_TIMEOUT + ); + } + system.sleep(CAPTURE_BASELINE_POLL); + } +} + +/// Stop the in-flight ffmpeg, run the frame-extract step, then gifski. +/// +/// `out_raw` is the lossless `.mkv` ffmpeg has been writing. +/// `out_gif` is the final GIF the caller asked for. +/// +/// # Arguments +/// +/// * `child` - the running ffmpeg gdigrab process. +/// * `ffmpeg_exe` - absolute path to the vendored ffmpeg.exe (used +/// again for the frame-extract step). +/// * `gifski_exe` - absolute path to the vendored gifski.exe. +/// * `out_raw` - the lossless `.mkv` written by `child`. +/// * `out_gif` - destination GIF path. +pub fn stop_ffmpeg_and_encode( + mut child: Child, + ffmpeg_exe: &Path, + gifski_exe: &Path, + out_raw: &Path, + out_gif: &Path, +) -> Result<()> { + // Politely ask ffmpeg to flush + exit by sending `q\n` on stdin; + // it converts the partial buffer into a valid container. + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(b"q\n"); + } + let status = child.wait().context("waiting for ffmpeg gdigrab")?; + if !status.success() { + // Non-zero on graceful `q` is rare but documented; do not + // bail unconditionally because the .mkv may still be valid. + eprintln!("ffmpeg gdigrab exited with {status}; continuing to encode"); + } + if !out_raw.exists() { + bail!( + "ffmpeg did not produce {}: cannot continue to gifski", + out_raw.display() + ); + } + + let frames_dir = out_raw + .parent() + .map(|p| p.join("frames")) + .unwrap_or_else(|| Path::new("frames").to_path_buf()); + if frames_dir.exists() { + std::fs::remove_dir_all(&frames_dir) + .with_context(|| format!("failed to clear {}", frames_dir.display()))?; + } + std::fs::create_dir_all(&frames_dir) + .with_context(|| format!("failed to create {}", frames_dir.display()))?; + + // Frame extraction (vendored ffmpeg). + let extract_status = Command::new(ffmpeg_exe) + .args(["-y", "-i"]) + .arg(out_raw) + .args([ + "-vf", + &format!("fps={ENCODE_FPS},scale={ENCODE_WIDTH}:-1:flags=lanczos"), + ]) + .arg(frames_dir.join("%05d.png")) + .status() + .with_context(|| { + format!( + "failed to spawn vendored ffmpeg at {}", + ffmpeg_exe.display() + ) + })?; + if !extract_status.success() { + bail!("ffmpeg frame extraction failed with {extract_status}"); + } + + // gifski encode (vendored gifski). + if let Some(parent) = out_gif.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let frame_glob = frames_dir.join("*.png"); + let gifski_status = Command::new(gifski_exe) + .args([ + "--fps", + ENCODE_FPS, + "--width", + ENCODE_WIDTH, + "--quality", + ENCODE_QUALITY, + "-o", + ]) + .arg(out_gif) + .arg(frame_glob) + .status() + .with_context(|| { + format!( + "failed to spawn vendored gifski at {}", + gifski_exe.display() + ) + })?; + if !gifski_status.success() { + bail!("gifski exited with {gifski_status}"); + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/test_demo_recorder.rs"] +mod tests; diff --git a/xtask/src/demo/script.rs b/xtask/src/demo/script.rs new file mode 100644 index 00000000..01393b43 --- /dev/null +++ b/xtask/src/demo/script.rs @@ -0,0 +1,44 @@ +//! The canonical demo script. +//! +//! This file is the "demo as code" surface: edit it to change what the +//! GIF shows. The DSL ([`crate::demo::dsl`]) is type-checked, so a +//! typo here surfaces as a compile error rather than a recording-time +//! failure. +//! +//! v0 ships [`build_canonical_v0`]: launch csshw with two hosts, wait +//! for both client windows, broadcast a single command, stop. The +//! richer scene (control-mode add-host, vim broadcast, ping/Ctrl+C) +//! arrives in v3 once the chord primitive lands in the DSL. + +use crate::demo::dsl::Script; + +/// Build the v0 canonical demo: launch + broadcast + stop. +/// +/// Returns the unbuilt [`Script`]. Callers (the production +/// `record_demo` entrypoint and unit tests) are expected to call +/// `.build()` to validate and consume into a `Vec`. +/// +/// # Window-title regexes +/// +/// The regexes match titles produced by csshw itself when it spawns +/// console windows. csshw uses titles like `daemon [...]` and +/// `@` for clients; we keep the regexes loose (`(?i)` and +/// no anchors) so future title tweaks do not silently break the demo. +pub fn build_canonical_v0() -> Script { + let mut s = Script::new("csshw-demo-v0"); + s.start_capture() + .marker("v0: launch + broadcast + stop") + .wait_for(r"(?i)daemon") + .wait_for(r"(?i)alpha") + .wait_for(r"(?i)bravo") + .focus(r"(?i)daemon") + .sleep_ms(800) + .type_text("whoami\r") + .sleep_ms(2000) + .stop_capture(); + s +} + +#[cfg(test)] +#[path = "../tests/test_demo_script.rs"] +mod tests; diff --git a/xtask/src/demo/windows_input.rs b/xtask/src/demo/windows_input.rs new file mode 100644 index 00000000..fdb09e04 --- /dev/null +++ b/xtask/src/demo/windows_input.rs @@ -0,0 +1,269 @@ +//! Thin wrappers around the Win32 input + window-enumeration APIs. +//! +//! Kept private to [`crate::demo`] so the rest of the module tree never +//! touches `unsafe`. Only [`crate::demo::RealSystem`] calls in here. +//! All functions return `anyhow::Error` instead of `windows::core::Error` +//! so callers compose with the rest of xtask uniformly. +//! +//! Non-Windows builds still compile (xtask is a workspace member) by +//! returning a clear "Windows-only" error from each entry point. + +use anyhow::Result; + +use super::{WindowInfo, WindowRect}; + +#[cfg(target_os = "windows")] +mod imp { + use super::*; + use std::ffi::c_void; + + use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; + use windows::Win32::System::Threading::AttachThreadInput; + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, VkKeyScanW, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, + KEYEVENTF_KEYUP, VIRTUAL_KEY, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetForegroundWindow, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, + GetWindowThreadProcessId, IsWindowVisible, SetForegroundWindow, + }; + + /// Closure-based EnumWindows callback context. + /// + /// We accumulate visible top-level windows with non-empty titles + /// into a `Vec` passed via `LPARAM`. + extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + // SAFETY: lparam is a `*mut Vec` we set in + // enum_windows() below. The pointer is valid for the duration + // of the EnumWindows call. + let acc = unsafe { &mut *(lparam.0 as *mut Vec) }; + // SAFETY: HWND is valid for the duration of this callback. + let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; + if !visible { + return BOOL(1); + } + // SAFETY: HWND valid; returns text length without trailing NUL. + let len = unsafe { GetWindowTextLengthW(hwnd) }; + if len <= 0 { + return BOOL(1); + } + let mut buf = vec![0u16; (len as usize) + 1]; + // SAFETY: HWND valid; buffer length matches the slot count. + let copied = unsafe { GetWindowTextW(hwnd, &mut buf) }; + if copied <= 0 { + return BOOL(1); + } + let title = String::from_utf16_lossy(&buf[..copied as usize]); + let mut rect = RECT::default(); + // SAFETY: HWND valid; rect is a stack RECT we own. + if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { + return BOOL(1); + } + acc.push(WindowInfo { + hwnd: hwnd.0 as u64, + title, + rect: WindowRect { + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }, + }); + BOOL(1) + } + + /// Enumerate visible top-level windows with non-empty titles. + pub fn enum_windows() -> Result> { + let mut acc: Vec = Vec::new(); + let lparam = LPARAM(&mut acc as *mut _ as isize); + // SAFETY: enum_proc is a valid extern "system" callback; + // EnumWindows blocks until iteration completes so `acc` stays + // valid for the entire call. + unsafe { EnumWindows(Some(enum_proc), lparam) } + .map_err(|e| anyhow::anyhow!("EnumWindows failed: {e}"))?; + Ok(acc) + } + + /// Bring the window to the foreground using the standard + /// `AttachThreadInput` workaround (Windows blocks + /// `SetForegroundWindow` from background processes since Win2K). + pub fn set_foreground(hwnd: u64) -> Result<()> { + let target = HWND(hwnd as *mut c_void); + // SAFETY: HWND value originates from a recent enum_windows() + // call. Worst case it has been destroyed and the API returns + // an error, which we propagate. + let foreground = unsafe { GetForegroundWindow() }; + let mut fg_thread = 0u32; + // SAFETY: foreground is the current foreground window handle + // from the OS; the out-pointer is a stack u32. + let _ = unsafe { GetWindowThreadProcessId(foreground, Some(&mut fg_thread)) }; + let mut target_thread = 0u32; + // SAFETY: target is the window we want to focus; out-pointer + // is a stack u32. + let _ = unsafe { GetWindowThreadProcessId(target, Some(&mut target_thread)) }; + let attached = if fg_thread != 0 && target_thread != 0 && fg_thread != target_thread { + // SAFETY: thread IDs come from GetWindowThreadProcessId. + unsafe { AttachThreadInput(fg_thread, target_thread, true) }.as_bool() + } else { + false + }; + // SAFETY: HWND validated at top of function. + let ok = unsafe { SetForegroundWindow(target) }.as_bool(); + if attached { + // SAFETY: must mirror the AttachThreadInput call above. + let _ = unsafe { AttachThreadInput(fg_thread, target_thread, false) }; + } + if !ok { + anyhow::bail!("SetForegroundWindow returned FALSE for hwnd={hwnd:#x}"); + } + Ok(()) + } + + /// Send a single character by translating it into virtual-key + /// events via `VkKeyScanW`, applying shift / ctrl / alt modifiers + /// as needed. + /// + /// The earlier implementation used `SendInput(KEYEVENTF_UNICODE)`, + /// which delivers the keystroke to the foreground window's message + /// queue but surfaces at low-level keyboard hooks + /// (`WH_KEYBOARD_LL`) with `vkCode = VK_PACKET (0xE7)`. Carnac + /// reads the hook and renders unmapped vkCodes as the literal text + /// "Packet", so a `whoami` broadcast showed up in the overlay as + /// six "Packet" rows. Translating to a real virtual-key sequence + /// first means the hook sees the actual key and the overlay + /// displays the character that was typed. + /// + /// Only characters that the current keyboard layout maps to a + /// single keystroke are supported. The canonical demo script types + /// ASCII text on the en-US layout the sandbox boots into, where + /// every character has a `VkKeyScanW` entry; surrogate-pair + /// codepoints, dead keys, and unmapped chars are rejected with an + /// error so the demo fails loudly rather than silently injecting + /// a `VK_PACKET` Carnac cannot decode. + pub fn send_unicode_char(c: char) -> Result<()> { + // BMP-only: every char in the v0 script is ASCII, and Unicode + // codepoints that need a surrogate pair would require multiple + // VkKeyScanW lookups with no guarantee of a meaningful mapping. + let mut buf = [0u16; 2]; + let units = c.encode_utf16(&mut buf); + if units.len() != 1 { + anyhow::bail!( + "send_unicode_char: {c:?} requires a UTF-16 surrogate pair; \ + the demo script is restricted to BMP keyboard characters" + ); + } + // SAFETY: VkKeyScanW takes a UTF-16 code unit by value and has + // no out-pointer. Returns -1 when no key on the active layout + // produces this character. + let scan = unsafe { VkKeyScanW(units[0]) }; + if scan == -1 { + anyhow::bail!( + "send_unicode_char: no VkKeyScanW mapping for {c:?} on the current layout" + ); + } + let vk = (scan & 0xFF) as u16; + let shift_state = (scan >> 8) & 0xFF; + // VkKeyScanW shift-state bits: 1=Shift, 2=Ctrl, 4=Alt. + let shift = shift_state & 1 != 0; + let ctrl = shift_state & 2 != 0; + let alt = shift_state & 4 != 0; + /// Windows `VK_SHIFT`. + const VK_SHIFT: u16 = 0x10; + /// Windows `VK_CONTROL`. + const VK_CONTROL: u16 = 0x11; + /// Windows `VK_MENU` (Alt). + const VK_MENU: u16 = 0x12; + let mut events: Vec = Vec::with_capacity(8); + if shift { + events.push(make_vk_input(VK_SHIFT, false)); + } + if ctrl { + events.push(make_vk_input(VK_CONTROL, false)); + } + if alt { + events.push(make_vk_input(VK_MENU, false)); + } + events.push(make_vk_input(vk, false)); + events.push(make_vk_input(vk, true)); + if alt { + events.push(make_vk_input(VK_MENU, true)); + } + if ctrl { + events.push(make_vk_input(VK_CONTROL, true)); + } + if shift { + events.push(make_vk_input(VK_SHIFT, true)); + } + send_pair(&events) + } + + /// Send a virtual-key down + up pair. + pub fn send_vk(vk: u16) -> Result<()> { + send_pair(&[make_vk_input(vk, false), make_vk_input(vk, true)]) + } + + /// Build a single `INPUT_KEYBOARD` event for the given virtual key. + fn make_vk_input(vk: u16, key_up: bool) -> INPUT { + let flags = if key_up { + KEYEVENTF_KEYUP + } else { + KEYBD_EVENT_FLAGS(0) + }; + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk), + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + + fn send_pair(events: &[INPUT]) -> Result<()> { + // SAFETY: events is a valid slice; SendInput reads `len` + // entries each of size_of::(). + let sent = unsafe { SendInput(events, std::mem::size_of::() as i32) }; + if sent as usize != events.len() { + anyhow::bail!( + "SendInput injected {sent}/{} events; the input desktop may be locked", + events.len() + ); + } + Ok(()) + } +} + +#[cfg(target_os = "windows")] +pub(super) use imp::{enum_windows, send_unicode_char, send_vk, set_foreground}; + +#[cfg(not(target_os = "windows"))] +mod imp_stub { + use super::*; + + /// Stub that errors on non-Windows hosts. The demo subcommand is + /// Windows-only; this stub exists so `cargo check` on Linux still + /// compiles the rest of the workspace. + fn unsupported() -> Result { + anyhow::bail!("record-demo is Windows-only; this is a non-Windows build") + } + + pub fn enum_windows() -> Result> { + unsupported() + } + pub fn set_foreground(_hwnd: u64) -> Result<()> { + unsupported() + } + pub fn send_unicode_char(_c: char) -> Result<()> { + unsupported() + } + pub fn send_vk(_vk: u16) -> Result<()> { + unsupported() + } +} + +#[cfg(not(target_os = "windows"))] +pub(super) use imp_stub::{enum_windows, send_unicode_char, send_vk, set_foreground}; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f1708e59..66176142 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod changelog; mod coverage; +mod demo; mod inject_agent_token; mod readme; mod release; @@ -65,6 +66,40 @@ enum Command { /// Scan tracked text files for forbidden decorative Unicode /// punctuation and fail with a list of offending locations. CheckTypography, + /// Record an automated demo of csshw and produce `target/demo/csshw.gif`. + /// + /// Two providers are wired: + /// - `--env sandbox` (default) boots a fresh Windows Sandbox VM + /// with a normalised desktop and an optional Carnac keystroke + /// overlay. Requires the `Containers-DisposableClientVM` + /// Windows feature. + /// - `--env local` runs on the caller's interactive desktop + /// session; the only path that works in CI runners (no nested + /// virtualisation) and a useful local-iteration shortcut while + /// the sandbox warm-up overhead is paid. + /// + /// ffmpeg, gifski, and Carnac are SHA-pinned and downloaded into + /// `target/demo/bin/` on first use; subsequent runs hit the warm + /// cache. + RecordDemo { + /// Output GIF path. Defaults to + /// `/target/demo/csshw.gif`. + #[arg(long)] + out: Option, + /// Recording environment provider. Defaults to `sandbox` so + /// `cargo xtask record-demo` is hermetic on a developer + /// workstation; CI must pass `--env local` explicitly. + #[arg(long, value_enum, default_value_t = demo::DemoEnv::Sandbox)] + env: demo::DemoEnv, + /// Skip ffmpeg capture; useful for iterating on the demo + /// script without burning a recording cycle. + #[arg(long)] + no_record: bool, + /// Skip the Carnac keystroke overlay. Has no effect with + /// `--env local` (Carnac is sandbox-only). + #[arg(long)] + no_overlay: bool, + }, } fn main() -> Result<()> { @@ -101,6 +136,14 @@ fn main() -> Result<()> { Command::CheckTypography => { typography::check_typography(&typography::RealSystem)?; } + Command::RecordDemo { + out, + env, + no_record, + no_overlay, + } => { + demo::record_demo(&demo::RealSystem::new(), out, env, no_record, no_overlay)?; + } } Ok(()) } diff --git a/xtask/src/tests/test_demo_bin.rs b/xtask/src/tests/test_demo_bin.rs new file mode 100644 index 00000000..3235bbfa --- /dev/null +++ b/xtask/src/tests/test_demo_bin.rs @@ -0,0 +1,339 @@ +//! Tests for the vendored binary cache module. +//! +//! All side effects (download, hash, extract, fs) flow through +//! [`crate::demo::DemoSystem`], so the cache logic in +//! [`crate::demo::bin`] is exercised against `mockall`-generated +//! mocks with zero network or filesystem effects. The tests focus +//! on the state-machine: cache hit fast path, cold-cache happy +//! path, SHA mismatch, post-extract entry-binary check, and the +//! nested-archive flow Carnac relies on. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::bin::{ensure_pin, Pin}; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +const FAKE: Pin = Pin { + name: "fake", + url: "https://example.test/fake.zip", + sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + archive_name: "fake.zip", + exe_rel: "bin/fake.exe", + inner_archive: None, +}; + +const FAKE_NESTED: Pin = Pin { + name: "fake_nested", + url: "https://example.test/outer.zip", + sha256: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + archive_name: "outer.zip", + exe_rel: "lib/net45/Inner.exe", + inner_archive: Some("inner.nupkg"), +}; + +#[test] +fn test_cache_hit_skips_download_and_extract() { + // Arrange: the entry binary is already present, so ensure_pin + // must not touch the network or invoke extract. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + mock.expect_http_download().times(0); + mock.expect_sha256_file().times(0); + mock.expect_extract_archive().times(0); + mock.expect_ensure_dir().times(0); + + // Act + let path = ensure_pin(&mock, &FAKE, Path::new("/cache")).unwrap(); + + // Assert + let s = path.display().to_string().replace('\\', "/"); + assert!(s.ends_with("fake/bin/fake.exe"), "got {s}"); +} + +#[test] +fn test_cold_cache_downloads_verifies_extracts_and_returns_path() { + // Arrange: first path_exists check (entry exe) returns false, + // second (after extract) returns true. http_download writes the + // archive, sha256 matches the pin, extract_archive succeeds. + let mut mock = quiet_mock(); + let exists_calls: Arc> = Arc::new(Mutex::new(0)); + let slot = exists_calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + // Call sequence: check entry (miss) -> ensure_dir -> download + // -> sha256 -> extract -> check entry (hit). + *n != 1 + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + let download_url: Arc>> = Arc::new(Mutex::new(None)); + let download_dest: Arc>> = Arc::new(Mutex::new(None)); + let url_slot = download_url.clone(); + let dest_slot = download_dest.clone(); + mock.expect_http_download().returning(move |u, p| { + *url_slot.lock().unwrap() = Some(u.to_string()); + *dest_slot.lock().unwrap() = Some(p.to_path_buf()); + Ok(()) + }); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_string())); + let extracted_archive: Arc>> = Arc::new(Mutex::new(None)); + let extracted_dest: Arc>> = Arc::new(Mutex::new(None)); + let arch_slot = extracted_archive.clone(); + let dest_slot2 = extracted_dest.clone(); + mock.expect_extract_archive().returning(move |a, d| { + *arch_slot.lock().unwrap() = Some(a.to_path_buf()); + *dest_slot2.lock().unwrap() = Some(d.to_path_buf()); + Ok(()) + }); + + // Act + let path = ensure_pin(&mock, &FAKE, Path::new("/cache")).unwrap(); + + // Assert + assert_eq!( + download_url.lock().unwrap().as_deref(), + Some(FAKE.url), + "downloaded from the pin URL" + ); + let dest = download_dest.lock().unwrap().clone().unwrap(); + let dest_s = dest.display().to_string().replace('\\', "/"); + assert!( + dest_s.ends_with("fake/fake.zip"), + "archive landed under cache dir: {dest_s}" + ); + let archive_arg = extracted_archive.lock().unwrap().clone().unwrap(); + assert_eq!(archive_arg, dest, "extract_archive received the download"); + let extract_dest = extracted_dest.lock().unwrap().clone().unwrap(); + let extract_s = extract_dest.display().to_string().replace('\\', "/"); + assert!( + extract_s.ends_with("/cache/fake") || extract_s.ends_with("cache/fake"), + "extract dest is the cache dir: {extract_s}" + ); + let returned = path.display().to_string().replace('\\', "/"); + assert!(returned.ends_with("fake/bin/fake.exe"), "got {returned}"); +} + +#[test] +fn test_sha_mismatch_fails_loudly_without_extracting() { + // Arrange + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file().returning(|_| { + Ok("badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad0".to_string()) + }); + mock.expect_extract_archive().times(0); + + // Act + let err = ensure_pin(&mock, &FAKE, Path::new("/cache")) + .expect_err("expected SHA mismatch") + .to_string(); + + // Assert + assert!(err.contains("SHA-256 mismatch"), "got: {err}"); + assert!(err.contains("fake"), "got: {err}"); +} + +#[test] +fn test_sha_compare_is_case_insensitive() { + // Arrange: pin is lower-case, simulator returns the upper-case + // digest PowerShell's `Get-FileHash` produces. + let mut mock = quiet_mock(); + let exists_calls: Arc> = Arc::new(Mutex::new(0)); + let slot = exists_calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n != 1 + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_uppercase())); + mock.expect_extract_archive().returning(|_, _| Ok(())); + + // Act + let res = ensure_pin(&mock, &FAKE, Path::new("/cache")); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_missing_entry_binary_after_extract_errors() { + // Arrange: SHA verifies, extract reports success, but the + // entry exe is never produced; ensure_pin must surface that + // explicitly so a stale [`Pin::exe_rel`] is loud. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE.sha256.to_string())); + mock.expect_extract_archive().returning(|_, _| Ok(())); + + // Act + let err = ensure_pin(&mock, &FAKE, Path::new("/cache")) + .expect_err("expected entry-binary check to fail") + .to_string(); + + // Assert + assert!(err.contains("missing after extracting"), "got: {err}"); +} + +#[test] +fn test_inner_archive_is_extracted_after_outer() { + // Arrange: model the Carnac case. The first extract produces + // the inner nupkg; the second extract surfaces the entry exe. + let mut mock = quiet_mock(); + let exists_seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let exists_clone = exists_seen.clone(); + let exe_rel = FAKE_NESTED.exe_rel; + let inner_name = FAKE_NESTED.inner_archive.unwrap(); + mock.expect_path_exists().returning(move |p| { + exists_clone.lock().unwrap().push(p.to_path_buf()); + let s = p.display().to_string().replace('\\', "/"); + // Entry exe missing on first poll; appears after second + // extract. Inner archive is "present" once we are queried + // for it (after the first extract call returns). + if s.ends_with(exe_rel) { + // The first time the entry exe is checked is the cold- + // cache fast-path; after extraction we want it present. + let calls = exists_clone + .lock() + .unwrap() + .iter() + .filter(|q| { + q.display() + .to_string() + .replace('\\', "/") + .ends_with(exe_rel) + }) + .count(); + return calls > 1; + } + if s.ends_with(inner_name) { + return true; + } + false + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE_NESTED.sha256.to_string())); + let extracts: Arc>> = Arc::new(Mutex::new(Vec::new())); + let ex_slot = extracts.clone(); + mock.expect_extract_archive().returning(move |a, _| { + ex_slot.lock().unwrap().push(a.to_path_buf()); + Ok(()) + }); + + // Act + let path = ensure_pin(&mock, &FAKE_NESTED, Path::new("/cache")).unwrap(); + + // Assert: the recorder must extract the outer archive first + // (so the nupkg appears) and then the inner nupkg. + let calls = extracts.lock().unwrap().clone(); + let names: Vec = calls + .iter() + .map(|p| p.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!(names.len(), 2, "expected outer + inner extract: {names:?}"); + assert!( + names[0].ends_with("/outer.zip") || names[0].ends_with("outer.zip"), + "outer first: {names:?}" + ); + assert!(names[1].ends_with(inner_name), "inner second: {names:?}"); + let final_path = path.display().to_string().replace('\\', "/"); + assert!(final_path.ends_with(exe_rel), "got {final_path}"); +} + +#[test] +fn test_inner_archive_missing_after_outer_extract_errors() { + // Arrange: outer extract succeeds but never produces the + // declared inner archive. ensure_pin must fail loudly so a + // stale Pin::inner_archive name is caught. + let mut mock = quiet_mock(); + let inner_name = FAKE_NESTED.inner_archive.unwrap(); + mock.expect_path_exists().returning(move |p| { + let s = p.display().to_string().replace('\\', "/"); + // Entry exe + inner archive both missing throughout. + let _ = inner_name; + let _ = s; + false + }); + mock.expect_ensure_dir().returning(|_| Ok(())); + mock.expect_http_download().returning(|_, _| Ok(())); + mock.expect_sha256_file() + .returning(|_| Ok(FAKE_NESTED.sha256.to_string())); + let outer_extracts: Arc>> = Arc::new(Mutex::new(HashSet::new())); + let slot = outer_extracts.clone(); + mock.expect_extract_archive().returning(move |a, _| { + slot.lock() + .unwrap() + .insert(a.display().to_string().replace('\\', "/")); + Ok(()) + }); + + // Act + let err = ensure_pin(&mock, &FAKE_NESTED, Path::new("/cache")) + .expect_err("expected inner-archive check to fail") + .to_string(); + + // Assert + assert!( + err.contains("inner archive") && err.contains("missing"), + "got: {err}" + ); + // Only the outer archive was extracted before the bail. + let names = outer_extracts.lock().unwrap().clone(); + assert_eq!(names.len(), 1, "only outer extract: {names:?}"); + assert!( + names.iter().any(|n| n.ends_with("outer.zip")), + "outer was extracted: {names:?}" + ); +} diff --git a/xtask/src/tests/test_demo_config_override.rs b/xtask/src/tests/test_demo_config_override.rs new file mode 100644 index 00000000..da262ff6 --- /dev/null +++ b/xtask/src/tests/test_demo_config_override.rs @@ -0,0 +1,189 @@ +//! Tests for the `csshw-config.toml` override generator. +//! +//! Asserts the generator (a) writes one config file plus per-host +//! enter.bat files, (b) only writes host-specific files for the +//! intended host, and (c) emits a TOML body that targets cmd.exe via +//! a single `dispatcher.bat`. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::{config_override, DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +#[derive(Default, Clone)] +struct WriteCapture { + files: Vec<(PathBuf, String)>, +} + +fn capturing_mock() -> (MockDemoSystemMock, Arc>) { + let cap: Arc> = Arc::new(Mutex::new(WriteCapture::default())); + let mut mock = MockDemoSystemMock::new(); + mock.expect_ensure_dir().returning(|_| Ok(())); + let slot = cap.clone(); + mock.expect_write_file().returning(move |p, c| { + slot.lock() + .unwrap() + .files + .push((p.to_path_buf(), c.to_string())); + Ok(()) + }); + (mock, cap) +} + +#[test] +fn test_generate_writes_config_and_per_host_bat() { + // Arrange + let (mock, cap) = capturing_mock(); + let demo_root = PathBuf::from("/demo"); + + // Act + let layout = config_override::generate(&mock, &demo_root, &["alpha", "bravo"]).unwrap(); + + // Assert + assert_eq!(layout.csshw_cwd, demo_root); + let files = cap.lock().unwrap().files.clone(); + let names: Vec = files + .iter() + .map(|(p, _)| p.display().to_string().replace('\\', "/")) + .collect(); + assert!(names.iter().any(|n| n.ends_with("/csshw-config.toml"))); + assert!(names.iter().any(|n| n.ends_with("/dispatcher.bat"))); + assert!(names + .iter() + .any(|n| n.ends_with("fakehosts/alpha/enter.bat"))); + assert!(names + .iter() + .any(|n| n.ends_with("fakehosts/bravo/enter.bat"))); + // Shared README is written for both hosts. + assert_eq!( + names.iter().filter(|n| n.ends_with("README.txt")).count(), + 2 + ); +} + +#[test] +fn test_generate_writes_host_specific_file_only_for_owning_host() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha", "charlie"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let secret_writes: Vec<_> = files + .iter() + .filter(|(p, _)| p.display().to_string().ends_with("secret.txt")) + .collect(); + assert_eq!(secret_writes.len(), 1, "secret.txt should appear once"); + let path = secret_writes[0].0.display().to_string().replace('\\', "/"); + assert!(path.contains("fakehosts/charlie/"), "got: {path}"); +} + +#[test] +fn test_generated_toml_targets_cmd_exe_via_dispatcher() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let toml = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("csshw-config.toml")) + .map(|(_, c)| c.clone()) + .expect("csshw-config.toml not written"); + assert!(toml.contains("program = \"cmd.exe\""), "toml: {toml}"); + assert!( + toml.contains("{{USERNAME_AT_HOST}}"), + "placeholder missing - toml: {toml}" + ); + assert!(toml.contains("dispatcher.bat"), "toml: {toml}"); +} + +#[test] +fn test_dispatcher_bat_strips_user_prefix() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let dispatcher = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("dispatcher.bat")) + .map(|(_, c)| c.clone()) + .expect("dispatcher.bat not written"); + // Must use cmd's `:*@=` substring substitution. The + // `for /f tokens=2 delims=@` form skips a leading `@` and + // produces only one token, leaving HOST as `@alpha` and + // breaking the call below with "the system cannot find the + // path specified". + assert!( + dispatcher.contains(":*@="), + "dispatcher should use substring substitution: {dispatcher}" + ); + assert!( + !dispatcher.contains("delims=@"), + "dispatcher must not use `for /f delims=@` (mishandles leading @): {dispatcher}" + ); + assert!(dispatcher.contains("fakehosts"), "dispatcher: {dispatcher}"); + assert!(dispatcher.contains("enter.bat"), "dispatcher: {dispatcher}"); +} + +#[test] +fn test_enter_bat_sets_prompt_and_cd() { + // Arrange + let (mock, cap) = capturing_mock(); + + // Act + config_override::generate(&mock, Path::new("/demo"), &["alpha"]).unwrap(); + + // Assert + let files = cap.lock().unwrap().files.clone(); + let bat = files + .iter() + .find(|(p, _)| p.display().to_string().ends_with("enter.bat")) + .map(|(_, c)| c.clone()) + .expect("enter.bat not written"); + assert!(bat.contains("set PROMPT="), "bat: {bat}"); + assert!(bat.contains("cd /d"), "bat: {bat}"); + assert!(bat.contains("alpha"), "bat: {bat}"); +} diff --git a/xtask/src/tests/test_demo_driver.rs b/xtask/src/tests/test_demo_driver.rs new file mode 100644 index 00000000..9651ff21 --- /dev/null +++ b/xtask/src/tests/test_demo_driver.rs @@ -0,0 +1,277 @@ +//! Tests for the demo driver. +//! +//! All side effects route through the [`DemoSystem`] trait, so the +//! driver is fully mockable without any Windows API or filesystem +//! contact. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::dsl::Step; +use crate::demo::{driver, DemoSystem, WindowInfo, WindowRect}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +/// Build a mock with no-op `print_*` and `sleep` so callers only set +/// expectations on the calls they actually want to assert. +fn base_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock.expect_sleep().returning(|_| ()); + mock +} + +/// Single window with a stable rect, used by tests that expect +/// `WaitForWindow` and `Focus` to succeed on the first poll. +fn one_window(title: &str) -> Vec { + vec![WindowInfo { + hwnd: 0xDEAD, + title: title.to_string(), + rect: WindowRect { + x: 0, + y: 0, + width: 800, + height: 600, + }, + }] +} + +#[test] +fn test_no_record_skips_capture_calls() { + // Arrange + let mut mock = base_mock(); + mock.expect_start_recording().times(0); + mock.expect_stop_recording().times(0); + let steps = vec![ + Step::StartCapture, + Step::Sleep(Duration::from_millis(1)), + Step::StopCapture, + ]; + + // Act + let res = driver::run(&mock, &steps, Path::new("ignored.gif"), true); + + // Assert + assert!(res.is_ok()); +} + +#[test] +fn test_capture_calls_are_paired_when_recording() { + // Arrange + let mut mock = base_mock(); + let captured_raw: Arc>> = Arc::new(Mutex::new(None)); + let captured_gif: Arc>> = Arc::new(Mutex::new(None)); + let raw_slot = captured_raw.clone(); + mock.expect_start_recording().times(1).returning(move |p| { + *raw_slot.lock().unwrap() = Some(p.to_path_buf()); + Ok(()) + }); + let gif_slot = captured_gif.clone(); + mock.expect_stop_recording() + .times(1) + .returning(move |_raw, gif| { + *gif_slot.lock().unwrap() = Some(gif.to_path_buf()); + Ok(()) + }); + let steps = vec![Step::StartCapture, Step::StopCapture]; + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!( + captured_raw.lock().unwrap().as_deref(), + Some(Path::new("/x/csshw.mkv")) + ); + assert_eq!( + captured_gif.lock().unwrap().as_deref(), + Some(Path::new("/x/csshw.gif")) + ); +} + +#[test] +fn test_capture_is_cleaned_up_on_step_error() { + // Arrange + let mut mock = base_mock(); + mock.expect_start_recording().times(1).returning(|_| Ok(())); + // The Type step below will fail because send_unicode_char errors; + // the driver MUST still call stop_recording. + mock.expect_stop_recording() + .times(1) + .returning(|_, _| Ok(())); + mock.expect_send_unicode_char() + .returning(|_| Err(anyhow::anyhow!("simulated failure"))); + let steps = vec![ + Step::StartCapture, + Step::Type { + text: "x".into(), + per_char_delay: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("Type") || err.contains("simulated"), + "got: {err}" + ); +} + +#[test] +fn test_wait_for_window_succeeds_when_match_appears() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("daemon [csshw]"))); + let steps = vec![ + Step::StartCapture, + Step::WaitForWindow { + title_regex: r"(?i)daemon".to_string(), + timeout: Duration::from_millis(500), + stable_for: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_wait_for_window_times_out_when_no_match() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("not the right window"))); + let steps = vec![ + Step::StartCapture, + Step::WaitForWindow { + title_regex: r"(?i)daemon".to_string(), + timeout: Duration::from_millis(50), + stable_for: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + let err = res.expect_err("expected timeout").to_string(); + assert!( + err.contains("WaitForWindow") || err.contains("stabilised"), + "got: {err}" + ); +} + +#[test] +fn test_focus_calls_set_foreground_with_matching_hwnd() { + // Arrange + let mut mock = base_mock(); + mock.expect_enum_windows() + .returning(|| Ok(one_window("alpha@alpha-fake"))); + let captured_hwnd: Arc>> = Arc::new(Mutex::new(None)); + let slot = captured_hwnd.clone(); + mock.expect_set_foreground().times(1).returning(move |h| { + *slot.lock().unwrap() = Some(h); + Ok(()) + }); + let steps = vec![ + Step::StartCapture, + Step::Focus { + title_regex: r"(?i)alpha".to_string(), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!(*captured_hwnd.lock().unwrap(), Some(0xDEAD)); +} + +#[test] +fn test_type_text_translates_newline_to_vk_return() { + // Arrange + let mut mock = base_mock(); + let unicode_chars: Arc>> = Arc::new(Mutex::new(Vec::new())); + let vk_codes: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cs = unicode_chars.clone(); + mock.expect_send_unicode_char().returning(move |c| { + cs.lock().unwrap().push(c); + Ok(()) + }); + let vs = vk_codes.clone(); + mock.expect_send_vk().returning(move |vk| { + vs.lock().unwrap().push(vk); + Ok(()) + }); + let steps = vec![ + Step::StartCapture, + Step::Type { + text: "ab\r".into(), + per_char_delay: Duration::from_millis(0), + }, + Step::StopCapture, + ]; + mock.expect_start_recording().returning(|_| Ok(())); + mock.expect_stop_recording().returning(|_, _| Ok(())); + + // Act + let res = driver::run(&mock, &steps, Path::new("/x/csshw.gif"), false); + + // Assert + assert!(res.is_ok()); + assert_eq!(*unicode_chars.lock().unwrap(), vec!['a', 'b']); + // 0x0D is VK_RETURN. + assert_eq!(*vk_codes.lock().unwrap(), vec![0x0Du16]); +} diff --git a/xtask/src/tests/test_demo_dsl.rs b/xtask/src/tests/test_demo_dsl.rs new file mode 100644 index 00000000..ae15ff91 --- /dev/null +++ b/xtask/src/tests/test_demo_dsl.rs @@ -0,0 +1,147 @@ +//! Tests for the demo DSL. +//! +//! Pure data manipulation - no `DemoSystem` mock needed. + +use std::time::Duration; + +use crate::demo::dsl::{ + Script, Step, DEFAULT_PER_CHAR_DELAY, DEFAULT_WAIT_STABLE_FOR, DEFAULT_WAIT_TIMEOUT, +}; + +#[test] +fn test_script_records_steps_in_order() { + // Arrange + let mut s = Script::new("ordering"); + // Act + s.start_capture() + .wait_for("daemon") + .focus("daemon") + .type_text("hi\r") + .sleep_ms(500) + .stop_capture(); + // Assert + let steps = s.build().unwrap(); + assert_eq!(steps.len(), 6); + assert!(matches!(steps[0], Step::StartCapture)); + assert!(matches!(steps[1], Step::WaitForWindow { .. })); + assert!(matches!(steps[2], Step::Focus { .. })); + assert!(matches!(steps[3], Step::Type { .. })); + assert!(matches!(steps[4], Step::Sleep(_))); + assert!(matches!(steps[5], Step::StopCapture)); +} + +#[test] +fn test_wait_for_uses_defaults() { + // Arrange + let mut s = Script::new("defaults"); + s.start_capture().wait_for("d").stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::WaitForWindow { + timeout, + stable_for, + .. + } = &steps[1] + else { + panic!("expected WaitForWindow"); + }; + assert_eq!(*timeout, DEFAULT_WAIT_TIMEOUT); + assert_eq!(*stable_for, DEFAULT_WAIT_STABLE_FOR); +} + +#[test] +fn test_type_text_uses_default_per_char_delay() { + // Arrange + let mut s = Script::new("defaults"); + s.start_capture().type_text("ab").stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::Type { per_char_delay, .. } = &steps[1] else { + panic!("expected Type"); + }; + assert_eq!(*per_char_delay, DEFAULT_PER_CHAR_DELAY); +} + +#[test] +fn test_build_rejects_invalid_regex() { + // Arrange + let mut s = Script::new("bad-regex"); + s.start_capture().wait_for("(unclosed").stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("invalid title_regex"), "got: {err}"); +} + +#[test] +fn test_build_rejects_missing_start_capture() { + // Arrange + let mut s = Script::new("no-start"); + s.wait_for("daemon").stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("missing StartCapture"), "got: {err}"); +} + +#[test] +fn test_build_rejects_missing_stop_capture() { + // Arrange + let mut s = Script::new("no-stop"); + s.start_capture().wait_for("daemon"); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("missing StopCapture"), "got: {err}"); +} + +#[test] +fn test_build_rejects_duplicate_capture() { + // Arrange + let mut s = Script::new("dup"); + s.start_capture().start_capture().stop_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!( + err.contains("StartCapture appears more than once"), + "got: {err}" + ); +} + +#[test] +fn test_build_rejects_stop_before_start() { + // Arrange + let mut s = Script::new("inverted"); + s.stop_capture().start_capture(); + // Act + let err = s.build().unwrap_err().to_string(); + // Assert + assert!(err.contains("precedes StartCapture"), "got: {err}"); +} + +#[test] +fn test_wait_for_with_overrides_durations() { + // Arrange + let custom_timeout = Duration::from_secs(7); + let custom_stable = Duration::from_millis(123); + let mut s = Script::new("custom"); + s.start_capture() + .wait_for_with("d", custom_timeout, custom_stable) + .stop_capture(); + // Act + let steps = s.build().unwrap(); + // Assert + let Step::WaitForWindow { + timeout, + stable_for, + .. + } = &steps[1] + else { + panic!("expected WaitForWindow"); + }; + assert_eq!(*timeout, custom_timeout); + assert_eq!(*stable_for, custom_stable); +} diff --git a/xtask/src/tests/test_demo_env_sandbox.rs b/xtask/src/tests/test_demo_env_sandbox.rs new file mode 100644 index 00000000..00274016 --- /dev/null +++ b/xtask/src/tests/test_demo_env_sandbox.rs @@ -0,0 +1,227 @@ +//! Tests for the sandbox env provider. +//! +//! These tests exercise the pure-string `.wsb` rendering and the +//! sentinel poll loop; the full `run` orchestration depends on +//! [`crate::demo::DemoSystem::spawn_sandbox`] which actually starts +//! `WindowsSandbox.exe` and is therefore covered indirectly only +//! (the side effect is mocked, but the real recording flow is +//! exercised end-to-end inside the sandbox itself). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::env::sandbox::{prepare_layout, render_wsb, wait_for_sentinel}; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +#[test] +fn test_prepare_layout_resolves_known_paths_under_workspace() { + // Arrange / Act + let layout = prepare_layout(Path::new("C:\\ws")); + + // Assert + let s = |p: &Path| p.display().to_string().replace('\\', "/"); + assert!(s(&layout.demo_root).ends_with("ws/target/demo")); + assert!(s(&layout.bin_dir).ends_with("ws/target/demo/bin")); + assert!(s(&layout.assets_dir).ends_with("ws/xtask/demo-assets")); + assert!(s(&layout.out_dir).ends_with("ws/target/demo/out")); + // work_dir lives under the writable out mount so files the + // in-VM xtask writes (and the binaries the host builds for it) + // surface on the host without an extra copy. + assert!(s(&layout.work_dir).ends_with("ws/target/demo/out/work")); + // build_target_dir is `/target` so cargo's debug exes + // land at the path xtask's local provider expects + // (`/target/debug/csshw.exe`). + assert!(s(&layout.build_target_dir).ends_with("ws/target/demo/out/work/target")); + assert!(s(&layout.wsb_path).ends_with("ws/target/demo/csshw-demo.wsb")); + assert!(s(&layout.sentinel).ends_with("ws/target/demo/out/done.flag")); + assert!(s(&layout.sandbox_gif).ends_with("ws/target/demo/out/csshw.gif")); +} + +#[test] +fn test_render_wsb_pins_mount_layout_and_logon_command() { + // Arrange + let layout = prepare_layout(Path::new("C:\\ws")); + + // Act + let body = render_wsb(&layout, false); + + // Assert: every required mount point is present and routed + // to the canonical sandbox-side path. + assert!(body.contains(""), "{body}"); + assert!(body.contains(""), "{body}"); + assert!( + body.contains("C:\\demo\\bin"), + "{body}" + ); + assert!( + body.contains("C:\\demo\\assets"), + "{body}" + ); + assert!( + body.contains("C:\\demo\\out"), + "{body}" + ); + // The workspace itself is intentionally not mounted: the host + // builds the binaries directly into the writable out mount. + assert!( + !body.contains("C:\\demo\\repo"), + "the legacy whole-workspace mount must not regress: {body}" + ); + // The previous design carried a separate read-only stage mount; + // the writable out mount now subsumes it. + assert!( + !body.contains("C:\\demo\\stage"), + "the old stage mount must not reappear: {body}" + ); + // The out folder is the only writable mount. + let ro_count = body.matches("true").count(); + let rw_count = body.matches("false").count(); + assert_eq!(ro_count, 2, "expected 2 RO mounts: {body}"); + assert_eq!(rw_count, 1, "expected 1 RW mount: {body}"); + // LogonCommand routes through the bootstrap script. + assert!(body.contains(""), "{body}"); + assert!(body.contains("sandbox-bootstrap.ps1"), "{body}"); + // Hardening attributes that should never silently regress. + assert!(body.contains("Disable"), "{body}"); + assert!( + body.contains("Enable"), + "{body}" + ); +} + +#[test] +fn test_render_wsb_passes_no_overlay_flag_when_set() { + // Arrange + let layout = prepare_layout(Path::new("C:\\ws")); + + // Act + let with_flag = render_wsb(&layout, true); + let without_flag = render_wsb(&layout, false); + + // Assert + assert!( + with_flag.contains("-NoOverlay"), + "with-flag should pass -NoOverlay: {with_flag}" + ); + assert!( + !without_flag.contains("-NoOverlay"), + "default render should not pass -NoOverlay: {without_flag}" + ); +} + +#[test] +fn test_render_wsb_uses_workspace_host_path_for_out_mount() { + // Arrange + let layout = prepare_layout(Path::new("D:\\some place\\ws")); + + // Act + let body = render_wsb(&layout, false); + + // Assert: the writable out mount carries the full host path + // through unescaped (Windows paths cannot contain XML special + // chars). + assert!( + body.contains("D:\\some place\\ws\\target\\demo\\out"), + "host path leaks straight to XML: {body}" + ); +} + +#[test] +fn test_wait_for_sentinel_returns_when_file_appears() { + // Arrange: report missing for two polls then present. The + // 20-second liveness grace window means is_sandbox_running is + // never queried in this fast-success path. + let mut mock = quiet_mock(); + let calls: Arc> = Arc::new(Mutex::new(0)); + let slot = calls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n >= 3 + }); + let sleeps: Arc> = Arc::new(Mutex::new(0)); + let sleep_slot = sleeps.clone(); + mock.expect_sleep().returning(move |_| { + *sleep_slot.lock().unwrap() += 1; + }); + + // Act + let res = wait_for_sentinel(&mock, Path::new("/dev/null/done.flag")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*calls.lock().unwrap(), 3); + // Two misses cause two sleeps; the third hit returns + // immediately without sleeping. + assert_eq!(*sleeps.lock().unwrap(), 2); +} + +#[test] +fn test_wait_for_sentinel_bails_when_sandbox_closes_after_grace_window() { + // Arrange: the sentinel never appears. is_sandbox_running is + // only consulted after the grace window of poll iterations, + // which the mocked sleeps make zero-cost in wall-clock terms. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| false); + mock.expect_sleep().returning(|_| ()); + let liveness_calls: Arc> = Arc::new(Mutex::new(0)); + let liveness_slot = liveness_calls.clone(); + mock.expect_is_sandbox_running().returning(move || { + *liveness_slot.lock().unwrap() += 1; + false + }); + + // Act + let res = wait_for_sentinel(&mock, Path::new("/dev/null/done.flag")); + + // Assert + let err = res.expect_err("expected an error when the sandbox disappears"); + let msg = err.to_string(); + assert!( + msg.contains("sandbox VM is no longer running"), + "error should explain the sandbox closure: {msg}" + ); + // Liveness is queried exactly once - the first check after the + // grace window fires the bail. + assert_eq!(*liveness_calls.lock().unwrap(), 1); +} diff --git a/xtask/src/tests/test_demo_mod.rs b/xtask/src/tests/test_demo_mod.rs new file mode 100644 index 00000000..ffd13eac --- /dev/null +++ b/xtask/src/tests/test_demo_mod.rs @@ -0,0 +1,37 @@ +//! Top-level smoke tests for the demo module. +//! +//! Per-submodule behaviour is exercised by `test_demo_dsl.rs`, +//! `test_demo_driver.rs`, `test_demo_config_override.rs`, and +//! `test_demo_script.rs`. This file holds only assertions about the +//! module's public surface. + +use crate::demo::{DemoEnv, WindowRect}; + +#[test] +fn test_demo_env_default_is_sandbox() { + // The default for `--env` lives in `main.rs` as + // `DemoEnv::Sandbox`. Pin that here so renaming the variant + // later flags the README + the v1 plan as out of date. + let env = DemoEnv::Sandbox; + assert!(matches!(env, DemoEnv::Sandbox)); +} + +#[test] +fn test_window_rect_is_value_equality() { + // Arrange + let a = WindowRect { + x: 0, + y: 0, + width: 1920, + height: 1080, + }; + let b = WindowRect { + x: 0, + y: 0, + width: 1920, + height: 1080, + }; + // Assert: PartialEq must derive structurally so the driver's + // stability check (rect-equality across polls) works. + assert_eq!(a, b); +} diff --git a/xtask/src/tests/test_demo_recorder.rs b/xtask/src/tests/test_demo_recorder.rs new file mode 100644 index 00000000..a585cef5 --- /dev/null +++ b/xtask/src/tests/test_demo_recorder.rs @@ -0,0 +1,134 @@ +//! Tests for the recorder module. +//! +//! Only the trait-driven helpers are exercised here. +//! [`crate::demo::recorder::spawn_ffmpeg_gdigrab`] and +//! [`crate::demo::recorder::stop_ffmpeg_and_encode`] talk directly +//! to `std::process::Command` (they are only ever called from +//! [`crate::demo::RealSystem`]) and would require a real ffmpeg / +//! gifski to exercise; the trait-level callers in `mod.rs` cover +//! that path indirectly. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mockall::mock; + +use crate::demo::recorder::wait_for_capture_baseline; +use crate::demo::{DemoSystem, WindowInfo}; + +mock! { + DemoSystemMock {} + impl DemoSystem for DemoSystemMock { + fn workspace_root(&self) -> anyhow::Result; + fn ensure_dir(&self, path: &Path) -> anyhow::Result<()>; + fn write_file(&self, path: &Path, content: &str) -> anyhow::Result<()>; + fn copy_file(&self, from: &Path, to: &Path) -> anyhow::Result<()>; + fn enum_windows(&self) -> anyhow::Result>; + fn set_foreground(&self, hwnd: u64) -> anyhow::Result<()>; + fn send_unicode_char(&self, c: char) -> anyhow::Result<()>; + fn send_vk(&self, vk: u16) -> anyhow::Result<()>; + fn sleep(&self, duration: Duration); + fn spawn_csshw(&self, exe: &Path, hosts: &[String], cwd: &Path) -> anyhow::Result<()>; + fn terminate_csshw(&self) -> anyhow::Result<()>; + fn start_recording(&self, out_raw: &Path) -> anyhow::Result<()>; + fn stop_recording(&self, out_raw: &Path, out_gif: &Path) -> anyhow::Result<()>; + fn path_exists(&self, path: &Path) -> bool; + fn file_size(&self, path: &Path) -> anyhow::Result; + fn http_download(&self, url: &str, dest: &Path) -> anyhow::Result<()>; + fn sha256_file(&self, path: &Path) -> anyhow::Result; + fn extract_archive(&self, archive: &Path, dest_dir: &Path) -> anyhow::Result<()>; + fn spawn_sandbox(&self, wsb_path: &Path) -> anyhow::Result<()>; + fn terminate_sandbox(&self) -> anyhow::Result<()>; + fn is_sandbox_running(&self) -> bool; + fn cargo_build_demo_artifacts(&self, workspace: &Path, target_dir: &Path) -> anyhow::Result<()>; + fn print_info(&self, message: &str); + fn print_debug(&self, message: &str); + } +} + +fn quiet_mock() -> MockDemoSystemMock { + let mut mock = MockDemoSystemMock::new(); + mock.expect_print_info().returning(|_| ()); + mock.expect_print_debug().returning(|_| ()); + mock +} + +#[test] +fn test_baseline_returns_when_size_threshold_reached() { + // Arrange: ffmpeg writes the header on the second poll. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + let polls: Arc> = Arc::new(Mutex::new(0)); + let slot = polls.clone(); + mock.expect_file_size().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + // First poll: empty file. Second poll: well past 8 KiB. + if *n == 1 { + Ok(0) + } else { + Ok(64 * 1024) + } + }); + let sleeps: Arc> = Arc::new(Mutex::new(0)); + let sleep_slot = sleeps.clone(); + mock.expect_sleep().returning(move |_| { + *sleep_slot.lock().unwrap() += 1; + }); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*sleeps.lock().unwrap(), 1, "only one poll-sleep before hit"); +} + +#[test] +fn test_baseline_ignores_transient_size_failures() { + // Arrange: simulate the Windows "ffmpeg holds an exclusive + // write handle" case by returning Err on the first poll. + let mut mock = quiet_mock(); + mock.expect_path_exists().returning(|_| true); + let polls: Arc> = Arc::new(Mutex::new(0)); + let slot = polls.clone(); + mock.expect_file_size().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + if *n == 1 { + Err(anyhow::anyhow!("ERROR_SHARING_VIOLATION")) + } else { + Ok(16 * 1024) + } + }); + mock.expect_sleep().returning(|_| ()); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); +} + +#[test] +fn test_baseline_skips_polling_until_file_exists() { + // Arrange: file appears on the third poll and is large enough. + let mut mock = quiet_mock(); + let exist_polls: Arc> = Arc::new(Mutex::new(0)); + let slot = exist_polls.clone(); + mock.expect_path_exists().returning(move |_| { + let mut n = slot.lock().unwrap(); + *n += 1; + *n >= 3 + }); + mock.expect_file_size().returning(|_| Ok(64 * 1024)); + mock.expect_sleep().returning(|_| ()); + + // Act + let res = wait_for_capture_baseline(&mock, Path::new("/tmp/raw.mkv")); + + // Assert + assert!(res.is_ok(), "{res:?}"); + assert_eq!(*exist_polls.lock().unwrap(), 3); +} diff --git a/xtask/src/tests/test_demo_script.rs b/xtask/src/tests/test_demo_script.rs new file mode 100644 index 00000000..91005d0d --- /dev/null +++ b/xtask/src/tests/test_demo_script.rs @@ -0,0 +1,28 @@ +//! Sanity test for the canonical v0 script. +//! +//! The point of having a typed DSL is that the script validates at +//! `cargo build` time. This test pins down the contract: the canonical +//! script must build without errors and contain the expected first / +//! last steps. Future scripts (v1+) can clone this pattern. + +use crate::demo::dsl::Step; +use crate::demo::script; + +#[test] +fn test_canonical_v0_builds() { + // Act + let steps = script::build_canonical_v0().build().unwrap(); + // Assert + assert!(!steps.is_empty()); + assert!(matches!(steps.first(), Some(Step::StartCapture))); + assert!(matches!(steps.last(), Some(Step::StopCapture))); +} + +#[test] +fn test_canonical_v0_contains_a_type_step() { + // Act + let steps = script::build_canonical_v0().build().unwrap(); + // Assert + let typed = steps.iter().any(|s| matches!(s, Step::Type { .. })); + assert!(typed, "canonical v0 should type at least one command"); +}