chore(demo): automate VHS recording for demo GIF and README screenshots

This commit is contained in:
Marco Cadetg
2026-05-02 15:48:39 +02:00
parent 2591e3cf2e
commit d572983d35
11 changed files with 412 additions and 0 deletions
+13
View File
@@ -97,6 +97,19 @@ Stats are collected every 2 seconds in a background thread with minimal performa
</details>
## Screenshots
<table>
<tr>
<td align="center"><strong>Overview</strong><br>Connections table with live stats and sparklines<br><img src="./assets/screenshots/overview.png" width="400"></td>
<td align="center"><strong>Details</strong><br>Per-connection SNI, cipher, GeoIP, DPI<br><img src="./assets/screenshots/details.png" width="400"></td>
</tr>
<tr>
<td align="center"><strong>Graph</strong><br>Traffic chart, app distribution, top processes<br><img src="./assets/screenshots/graph.png" width="400"></td>
<td align="center"><strong>Interfaces</strong><br>Per-interface RX/TX history with errors and drops<br><img src="./assets/screenshots/interfaces.png" width="400"></td>
</tr>
</table>
## Quick Start
### Installation
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+86
View File
@@ -0,0 +1,86 @@
# vhs tape for the rustnet showcase demo.
#
# Render with: scripts/record-rustnet-demo.sh
# (wraps `vhs demo.tape` with sudo pre-cache, background traffic,
# and gifsicle optimization).
#
# Manual render: vhs demo.tape (writes assets/rustnet.gif).
# Pre-build the release binary first: cargo build --release
# Capture needs root: the tape runs `sudo -A`, which reads the password
# from the askpass helper pointed to by $SUDO_ASKPASS. The wrapper script
# above sets that automatically. For manual runs, export SUDO_ASKPASS
# yourself (or on Linux set CAP_NET_RAW on the binary and drop the
# `sudo -A` from the tape).
#
# Storyboard (~30s, 7 beats):
# 1. Land on Overview, let traffic populate the table and sparkline.
# 2. Walk three rows so the right-side panels update (continuity).
# 3. Filter mode: /, type "443", briefly settle, Esc to clear.
# 4. Process grouping: a, Down, Space to expand a node, a to flatten.
# 5. Enter zooms into Details on the selected connection.
# 6. Esc back to Overview, Tab x3 to land on Graph.
# 7. Esc back to Overview to close the loop.
#
# Sized for the ratatui showcase: 1200x720 fits the 800-1200px guidance and
# stays readable on phone-width viewports.
Output assets/rustnet.gif
Set Theme "Catppuccin Mocha"
Set FontSize 14
Set Width 1200
Set Height 720
Set Padding 20
Set TypingSpeed 50ms
Set Framerate 24
Set PlaybackSpeed 1.0
Hide
Type "clear"
Enter
Type "sudo -A target/release/rustnet"
Enter
Sleep 2500ms
Show
Sleep 3s
Down
Sleep 1s
Down
Sleep 1s
Down
Sleep 1500ms
Type "/"
Sleep 400ms
Type "443"
Sleep 1500ms
Escape
Sleep 800ms
Type "a"
Sleep 1500ms
Down
Sleep 600ms
Space
Sleep 1500ms
Type "a"
Sleep 800ms
Enter
Sleep 3s
Escape
Sleep 600ms
Tab@400ms 3
Sleep 3s
Escape
Sleep 1500ms
Hide
Type "q"
Sleep 200ms
Type "q"
+96
View File
@@ -0,0 +1,96 @@
# vhs tape for the rustnet README screenshots.
#
# Render with: scripts/record-rustnet-demo.sh
# (which runs both demo.tape and this tape, then cleans the throwaway GIF).
#
# Manual render: vhs screenshots.tape
# Pre-build the release binary first: cargo build --release
#
# Walks every tab and emits PNGs under assets/screenshots/.
# Sized larger than demo.tape (1600x1000, FontSize 16) for crisper README
# rendering. The GIF Output is a throwaway and is deleted by the script.
#
# Beats and outputs:
# 1. Overview -> assets/screenshots/overview.png
# 2. Filter mode (/port:443) -> assets/screenshots/filter.png
# 3. Process grouping (a, Space) -> assets/screenshots/grouping.png
# 4. Details (Enter) -> assets/screenshots/details.png
# 5. Graph (Tab x3) -> assets/screenshots/graph.png
# 6. Interfaces (i) -> assets/screenshots/interfaces.png
Output assets/screenshots/_throwaway.gif
Set Theme "Catppuccin Mocha"
Set FontSize 16
Set Width 1600
Set Height 1000
Set Padding 20
Set TypingSpeed 50ms
Set Framerate 24
Set PlaybackSpeed 1.0
Hide
Type "clear"
Enter
Type "sudo -A target/release/rustnet"
Enter
Sleep 2500ms
Show
# 1. Overview
Sleep 3s
Down
Sleep 500ms
Down
Sleep 500ms
Screenshot "assets/screenshots/overview.png"
Sleep 500ms
# 2. Filter mode
Type "/"
Sleep 400ms
Type "port:443"
Sleep 1500ms
Screenshot "assets/screenshots/filter.png"
Sleep 500ms
Escape
Sleep 800ms
# 3. Process grouping
Type "a"
Sleep 1500ms
Down
Sleep 400ms
Space
Sleep 1500ms
Screenshot "assets/screenshots/grouping.png"
Sleep 500ms
Type "a"
Sleep 800ms
# 4. Details
Enter
Sleep 2500ms
Screenshot "assets/screenshots/details.png"
Sleep 500ms
Escape
Sleep 800ms
# 5. Graph
Tab@400ms 3
Sleep 2500ms
Screenshot "assets/screenshots/graph.png"
Sleep 500ms
Escape
Sleep 800ms
# 6. Interfaces
Type "i"
Sleep 2500ms
Screenshot "assets/screenshots/interfaces.png"
Sleep 500ms
Hide
Type "q"
Sleep 200ms
Type "q"
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
# Automate the rustnet VHS recording: produces assets/rustnet.gif (showcase)
# and assets/screenshots/*.png (README) in one pass.
#
# Prerequisites:
# brew install vhs # required
# brew install gifsicle # optional, for GIF size optimization
# cargo build --release # auto-run if target/release/rustnet missing
#
# Usage:
# scripts/record-rustnet-demo.sh
#
# What it does:
# 1. Verifies vhs and cargo are installed.
# 2. Builds target/release/rustnet if missing or stale vs Cargo.lock.
# 3. Pre-caches sudo (PKTAP capture on macOS always needs root).
# 4. Spawns a sudo keepalive loop so a long render does not lose creds.
# 5. Spawns a background traffic generator (curl/dig/ping) so the
# connection table is busy and sparklines move.
# 6. Runs `vhs demo.tape` -> assets/rustnet.gif.
# 7. Runs `vhs screenshots.tape` -> assets/screenshots/*.png.
# 8. Optimizes the GIF with gifsicle -O3 if available.
# 9. Cleans up the throwaway GIF and background processes.
# 10. Prints a summary of the produced assets.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RUSTNET_DIR="$(dirname "$SCRIPT_DIR")"
DEMO_TAPE="${RUSTNET_DIR}/demo.tape"
SCREENSHOTS_TAPE="${RUSTNET_DIR}/screenshots.tape"
GIF_FILE="${RUSTNET_DIR}/assets/rustnet.gif"
SCREENSHOTS_DIR="${RUSTNET_DIR}/assets/screenshots"
THROWAWAY_GIF="${SCREENSHOTS_DIR}/_throwaway.gif"
RELEASE_BIN="${RUSTNET_DIR}/target/release/rustnet"
TRAFFIC_PID=""
ASKPASS_SCRIPT=""
PW_FILE=""
cleanup() {
if [[ -n "${TRAFFIC_PID}" ]] && kill -0 "${TRAFFIC_PID}" 2>/dev/null; then
kill "${TRAFFIC_PID}" 2>/dev/null || true
wait "${TRAFFIC_PID}" 2>/dev/null || true
fi
[[ -n "${ASKPASS_SCRIPT}" ]] && rm -f "${ASKPASS_SCRIPT}"
[[ -n "${PW_FILE}" ]] && rm -f "${PW_FILE}"
rm -f "${THROWAWAY_GIF}"
}
trap cleanup EXIT INT TERM
# Kill stale ttyd / headless-Chrome processes left over from prior failed
# vhs runs. VHS uses go-rod, which writes its Chrome profile to a
# `rod/user-data` tmpdir; if a prior run died, the locked profile causes
# the next run to fail with `could not open ttyd: ERR_CONNECTION_REFUSED`.
preflight_cleanup_vhs() {
local stale=0
if pgrep -f "ttyd --port" >/dev/null 2>&1; then
pkill -f "ttyd --port" 2>/dev/null || true
stale=1
fi
if pgrep -f "rod/user-data" >/dev/null 2>&1; then
pkill -f "rod/user-data" 2>/dev/null || true
stale=1
fi
if [[ "${stale}" -eq 1 ]]; then
echo "Cleaned up stale ttyd / Chrome processes from prior vhs run."
sleep 1
fi
rm -rf "${TMPDIR:-/tmp}/rod" 2>/dev/null || true
}
preflight_cleanup_vhs
# Dependency checks
for cmd in vhs cargo; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: $cmd is not installed."
[[ "$cmd" == "vhs" ]] && echo "Install: brew install vhs"
[[ "$cmd" == "cargo" ]] && echo "Install: https://rustup.rs"
exit 1
fi
done
if ! command -v gifsicle &> /dev/null; then
echo "Note: gifsicle not found; skipping GIF optimization."
echo " Install with: brew install gifsicle"
HAS_GIFSICLE=0
else
HAS_GIFSICLE=1
fi
# Tape files must exist
[[ -f "${DEMO_TAPE}" ]] || { echo "Error: ${DEMO_TAPE} missing."; exit 1; }
[[ -f "${SCREENSHOTS_TAPE}" ]] || { echo "Error: ${SCREENSHOTS_TAPE} missing."; exit 1; }
# Build release binary if missing or stale
if [[ ! -x "${RELEASE_BIN}" ]] || [[ "${RUSTNET_DIR}/Cargo.lock" -nt "${RELEASE_BIN}" ]]; then
echo "Building release binary..."
(cd "${RUSTNET_DIR}" && cargo build --release)
fi
# Read sudo password and validate it. macOS sudo uses tty_tickets by default,
# so a `sudo -v` cached in this shell does NOT carry over to the new pty
# that vhs/ttyd spawns. Instead, store the validated password in a private
# file and expose it via SUDO_ASKPASS so the in-tape `sudo -A target/release/rustnet`
# can re-authenticate non-interactively.
echo "Capture requires root. Enter your sudo password (3 attempts)."
SUDO_PW=""
for attempt in 1 2 3; do
printf 'Password: '
IFS= read -rs SUDO_PW
printf '\n'
if [[ -z "${SUDO_PW}" ]]; then
echo " empty password, try again ($attempt/3)"
continue
fi
if printf '%s\n' "${SUDO_PW}" | sudo -S -v 2>/dev/null; then
break
fi
echo " wrong password ($attempt/3)"
SUDO_PW=""
done
if [[ -z "${SUDO_PW}" ]]; then
echo "Error: sudo authentication failed after 3 attempts."
exit 1
fi
# Stash the password in a 0600 temp file and write a tiny askpass helper
# that cats it. SUDO_ASKPASS in the env makes `sudo -A` use this helper.
PW_FILE="$(mktemp -t rustnet-pw.XXXXXX)"
chmod 600 "${PW_FILE}"
printf '%s' "${SUDO_PW}" > "${PW_FILE}"
unset SUDO_PW
ASKPASS_SCRIPT="$(mktemp -t rustnet-askpass.XXXXXX)"
chmod 700 "${ASKPASS_SCRIPT}"
cat > "${ASKPASS_SCRIPT}" <<EOF
#!/bin/sh
cat "${PW_FILE}"
EOF
export SUDO_ASKPASS="${ASKPASS_SCRIPT}"
# Background traffic generator: HTTPS, DNS, ICMP, multiple processes.
mkdir -p "${SCREENSHOTS_DIR}"
(
while true; do
curl -s -o /dev/null --max-time 4 https://example.com || true
curl -s -o /dev/null --max-time 4 https://github.com || true
curl -s -o /dev/null --max-time 4 https://wikipedia.org || true
curl -s -o /dev/null --max-time 4 https://ratatui.rs || true
dig +short +time=2 +tries=1 example.com >/dev/null 2>&1 || true
dig +short +time=2 +tries=1 github.com >/dev/null 2>&1 || true
ping -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 || true
sleep 1
done
) &
TRAFFIC_PID=$!
# Render the demo GIF.
# VHS does not propagate the parent shell's env to the recording shell.
# Inject an `Env SUDO_ASKPASS <path>` directive into each tape so the in-tape
# `sudo -A` can find the askpass helper. VHS requires Env to come AFTER all
# `Set` directives but BEFORE the first command (Hide/Type/Sleep/etc.), so
# we use awk to insert it at the first command line.
render_tape_with_askpass() {
local tape="$1"
local tmp_tape
tmp_tape="$(mktemp -t "$(basename "${tape}").XXXXXX")"
awk -v askpass="${ASKPASS_SCRIPT}" '
BEGIN { injected = 0 }
/^(Output|Set|Env|Require|Source|#|[[:space:]]*$)/ { print; next }
{
if (!injected) {
print "Env SUDO_ASKPASS \"" askpass "\""
injected = 1
}
print
}
' "${RUSTNET_DIR}/${tape}" > "${tmp_tape}"
(cd "${RUSTNET_DIR}" && vhs "${tmp_tape}")
rm -f "${tmp_tape}"
}
echo ""
echo "Rendering demo GIF (vhs demo.tape)..."
render_tape_with_askpass demo.tape
# Render the screenshots.
echo ""
echo "Rendering screenshots (vhs screenshots.tape)..."
render_tape_with_askpass screenshots.tape
# Optimize the GIF.
if [[ "${HAS_GIFSICLE}" -eq 1 ]] && [[ -f "${GIF_FILE}" ]]; then
echo ""
echo "Optimizing GIF with gifsicle -O3..."
BEFORE_SIZE=$(stat -f%z "${GIF_FILE}" 2>/dev/null || stat -c%s "${GIF_FILE}")
gifsicle -O3 --batch "${GIF_FILE}"
AFTER_SIZE=$(stat -f%z "${GIF_FILE}" 2>/dev/null || stat -c%s "${GIF_FILE}")
echo " ${BEFORE_SIZE} -> ${AFTER_SIZE} bytes"
fi
# Summary.
echo ""
echo "Done."
echo ""
echo "GIF:"
ls -lh "${GIF_FILE}" 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
echo ""
echo "Screenshots:"
for png in "${SCREENSHOTS_DIR}"/*.png; do
[[ -f "${png}" ]] && ls -lh "${png}" | awk '{print " " $9 " (" $5 ")"}'
done
echo ""
echo "Preview:"
echo " open ${GIF_FILE}"
echo " open ${SCREENSHOTS_DIR}"