- commit
- 25fce991558143f3c9948615878864ce4e800171
- parent
- 44b5c1152b31f207ff13245e8eaf1bc72cdd4ec0
- author
- Dami <dami.etoile@gmail.com>
| A | .github/ | 43 | ++++ |
| M | README.md | 30 | ++- |
| M | docs/ | 29 | ++- |
| M | scripts/ | 337 | +++++++++++++++++++++++++++++- |
| M | scripts/ | 85 | ++++++- |
| A | scripts/ | 122 | +++++++++++ |
| A | scripts/ | 86 | ++++++++ |
| M | scripts/ | 22 | +- |
| M | src/ | 14 | +- |
| M | src/ | 91 | +++++++-- |
| M | src/ | 168 | +++++++++++++++ |
| A | src/ | 33 | +++ |
| A | src/ | 38 | +++ |
@@ -0,0 +1,43 @@
1+name: CI2+3+on:4+ pull_request:5+ push:6+ branches: [main]7+8+permissions:9+ contents: read10+11+concurrency:12+ group: ci-${{ github.workflow }}-${{ github.ref }}13+ cancel-in-progress: true14+15+jobs:16+ demo-gate:17+ name: Complete demo gate18+ runs-on: ubuntu-24.0419+ timeout-minutes: 4520+ env:21+ CARGO_TERM_COLOR: always22+ RUST_BACKTRACE: "1"23+ steps:24+ - name: Check out repository25+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.126+27+ - name: Install Linux build dependencies28+ run: |29+ sudo apt-get update30+ sudo apt-get install --yes --no-install-recommends \31+ build-essential \32+ file \33+ libayatana-appindicator3-dev \34+ librsvg2-dev \35+ libssl-dev \36+ libudev-dev \37+ libwebkit2gtk-4.1-dev \38+ libxdo-dev \39+ pkg-config \40+ shellcheck41+42+ - name: Run complete demo gate43+ run: ./scripts/verify-demo-build.sh@@ -39,8 +39,10 @@ Steam and games.
3939 - The Bazzite path captures the exact Gamescope PipeWire node and uses AMD4040 GstVA H.264 at the fixed 1280×800/60 first target.4141 - The client delivers encoded frames to WebCodecs through a raw Tauri binary42- channel. The handoff is capped at four frames, the decode queue at two, and43- its transport/frontend/decoder drop counters are reported separately.42+ channel. The handoff is capped at four frames, the decode queue and43+ presentation queue at two, and a bounded watchdog recovers a suspended44+ webview animation-frame callback without presenting a frame twice. Its45+ transport/frontend/decoder drop counters are reported separately.4446 - Media and input use separate Iroh connections; one active client is enforced.4547 - Linux `uinput` supports bounded relative-mouse and keyboard injection plus a4648 separate Xbox-style virtual gamepad, with strict device@@ -94,7 +96,9 @@ Provision a dedicated AMD host with the
9496 [fresh Bazzite host runbook](docs/fresh-bazzite-host.md). The runbook also9597 defines the temporary `slate` stand-in used for protocol and daemon extraction.9698 Run `scripts/bazzite-inventory.sh` on a candidate host for a read-only report;97-add `--smoke` to exercise a bounded 1280×800/60 VA-API encode.99+add `--smoke` to exercise a bounded 1280×800/60 VA-API encode. On the first SSH100+login after a physically headless cold boot, use `--cold-boot` for a strict,101+read-only connector, session, service, PipeWire, and boot-order gate.98102 99103 Run the complete local demo gate before transferring a snapshot:100104 @@ -117,9 +121,12 @@ binaries, their dynamic libraries, and bounded startup commands validate. Host
117121 identity, hardware-specific configuration, and service activation remain118122 separate gates in the Bazzite runbook.119123 120-Create the Bazzite runtime package after the cross-compiled host and probe are121-built. Product packages require a clean worktree and detached Minisign122-signature; the explicit development flags are only for temporary testing:124+Create the Bazzite runtime package from a committed source revision. Product125+mode exports clean `HEAD` and builds the host and probe itself with locked126+`cargo-zigbuild` in an isolated target directory; it never accepts externally127+supplied binaries. Product packages require a clean worktree and detached128+Minisign signature; the explicit development flags are only for temporary129+testing:123130 124131 ```bash125132 scripts/package-bazzite-release.sh \@@ -130,13 +137,22 @@ scripts/package-bazzite-release.sh \
130137 scripts/package-bazzite-release.sh \131138 --output /tmp/sigil-spark-host-dev.tar.gz \132139 --allow-dirty --allow-unsigned140+141+# Temporary externally built binaries require both development flags:142+scripts/package-bazzite-release.sh \143+ --output /tmp/sigil-spark-host-prebuilt-dev.tar.gz \144+ --allow-dirty --allow-unsigned \145+ --host-binary /absolute/path/to/sigil-host \146+ --probe-binary /absolute/path/to/sigil-probe133147 ```134148 135149 The package contains only the two Linux binaries, installer/rollback tools,136150 service/audio/udev assets, license, checksums, and build provenance. It cannot137151 contain the source tree, `.env` files, identities, host configuration, or test138152 evidence. Its release ID is the SHA-256 of the complete installed-file checksum139-manifest. Identical inputs produce a byte-identical archive.153+manifest. Identical inputs produce a byte-identical archive. The manifest marks154+product binaries as built from clean `HEAD`; caller-supplied development155+binaries are explicitly marked as having unverified provenance.140156 141157 Verify the detached signature against the separately trusted public key before142158 extracting, then run `payload/stage-this-release.sh` as the gaming user. Install@@ -460,14 +460,14 @@ The preferred host artifact is the deterministic, allowlisted runtime package.
460460 It contains the generic Linux host/probe binaries, installer and rollback tool,461461 systemd/PipeWire/udev assets, license, complete checksums, and build provenance.462462 It cannot include the worktree, credentials, identity, hardware configuration,463-or evidence. Product packaging fails closed when the worktree is dirty or no463+or evidence. Product packaging exports clean `HEAD`, builds both binaries with464+locked `cargo-zigbuild` in an isolated target directory, and never accepts465+caller-supplied binaries. It fails closed when the worktree is dirty or no464466 Minisign secret key is supplied:465467 466468 ```bash467469 cd /Users/dami/Developer/sigil-spark468470 source ~/.cargo/env469-cargo zigbuild --locked -p sigil-host \470- --target x86_64-unknown-linux-gnu.2.17 --release471471 scripts/package-bazzite-release.sh \472472 --output /tmp/sigil-spark-host.tar.gz \473473 --minisign-key /absolute/path/to/release.key@@ -505,7 +505,17 @@ or `host.toml`, changes `/etc`, restarts PipeWire, or starts/enables the service
505505 For a temporary dirty and unsigned development package only, replace the506506 Minisign option with `--allow-dirty --allow-unsigned`. The manifest records that507507 state and the package prints `publisher_signature=absent-development`; never508-publish that artifact.508+publish that artifact. Externally built host/probe binaries are accepted only509+as an all-or-none pair with both development flags, and their manifest records510+`binary_provenance=caller-supplied-unverified`:511+512+```bash513+scripts/package-bazzite-release.sh \514+ --output /tmp/sigil-spark-host-prebuilt-dev.tar.gz \515+ --allow-dirty --allow-unsigned \516+ --host-binary /absolute/path/to/sigil-host \517+ --probe-binary /absolute/path/to/sigil-probe518+```509519 510520 ## 8. Create the host identity and synthetic configuration511521 @@ -1207,9 +1217,20 @@ session that merely started the user manager:
12071217 loginctl list-sessions12081218 sudo journalctl -b -o short-monotonic --no-pager | \12091219 grep -Ei 'gamescope|steam|sshd.*accepted|session.*sigil'1220+set -o pipefail1221+./scripts/bazzite-inventory.sh --cold-boot | \1222+ tee "$HOME/bazzite-cold-boot.txt"12101223 ```12111224 12121225 The Gamescope session must predate the first accepted SSH login.1226+`--cold-boot` is read-only and makes the gate machine-checkable. It exits zero1227+only when no DRM connector is connected, the SDDM Gaming Mode session and1228+required PipeWire nodes are active, the Sigil service is enabled and ready,1229+and Gamescope plus Sigil both predate the first accepted SSH login. Exit status1230+`1` means observed evidence failed a gate. Exit status `3` means the boot1231+journal is unavailable, starts more than five minutes after boot, or otherwise1232+cannot establish the first SSH ordering; do not treat that as a pass. Run it1233+immediately on the first SSH login so journal rotation cannot erase the proof.12131234 12141235 If the stock session does not start or does not publish a node without a12151236 physical connector, stop at this gate. This is an expected discovery risk on@@ -1,19 +1,6 @@
11 22 set -euo pipefail33 4-if [[ "$(uname -s)" != Linux ]]; then5- echo "bazzite-inventory must run on the Linux host" >&26- exit 17-fi8-9-smoke=false10-if [[ "${1:-}" == "--smoke" ]]; then11- smoke=true12-elif [[ $# -ne 0 ]]; then13- echo "usage: $0 [--smoke]" >&214- exit 215-fi16-174 section() {185 printf '\n## %s\n' "$1"196 }@@ -28,6 +15,328 @@ optional() {
2815 fi2916 }3017 18+cold_boot_failures=019+cold_boot_insufficient=020+21+cold_boot_failure() {22+ printf 'cold_boot_failure=%s\n' "$1"23+ ((cold_boot_failures += 1))24+}25+26+cold_boot_evidence_insufficient() {27+ printf 'cold_boot_evidence_insufficient=%s\n' "$1"28+ ((cold_boot_insufficient += 1))29+}30+31+summary_value() {32+ local key="$1"33+ sed -n "s/^${key}=//p" | head -n 134+}35+36+monotonic_before() {37+ local earlier="$1"38+ local later="$2"39+ awk -v earlier="$earlier" -v later="$later" \40+ 'BEGIN { exit !(earlier < later) }'41+}42+43+summarize_boot_journal() {44+ awk '45+ function monotonic(line, value) {46+ value = line47+ sub(/^[[:space:]]*\[/, "", value)48+ sub(/\].*$/, "", value)49+ return value50+ }51+ {52+ timestamp = monotonic($0)53+ if (timestamp !~ /^[0-9]+([.][0-9]+)?$/) {54+ next55+ }56+ entries += 157+ if (first == "") {58+ first = timestamp59+ }60+ line = tolower($0)61+ if (gamescope == "" && (line ~ /gamescope-session(-plus)?\[[0-9]+\]/ || line ~ /started .*gamescope/)) {62+ gamescope = timestamp63+ }64+ if (sigil_start == "" && line ~ /started sigil-host[.]service/) {65+ sigil_start = timestamp66+ }67+ if (sigil_ready == "" && line ~ /sigil host ready/) {68+ sigil_ready = timestamp69+ }70+ if (ssh_accept == "" && line ~ /accepted (publickey|password)/) {71+ ssh_accept = timestamp72+ }73+ }74+ END {75+ printf "journal_entries=%d\n", entries76+ printf "journal_first_monotonic_seconds=%s\n", first77+ printf "gamescope_first_monotonic_seconds=%s\n", gamescope78+ printf "sigil_unit_first_monotonic_seconds=%s\n", sigil_start79+ printf "sigil_ready_first_monotonic_seconds=%s\n", sigil_ready80+ printf "ssh_accept_first_monotonic_seconds=%s\n", ssh_accept81+ }82+ '83+}84+85+cold_boot_connector_evidence() {86+ local connector_status87+ local connector_name88+ local status89+ local found=false90+ local connected=false91+92+ for connector_status in /sys/class/drm/card*-*/status; do93+ [[ -e "$connector_status" ]] || continue94+ found=true95+ connector_name="$(basename "$(dirname "$connector_status")")"96+ status="$(tr -d '\n' < "$connector_status")"97+ printf 'drm_connector=%s status=%s\n' "$connector_name" "$status"98+ if [[ "$status" == connected ]]; then99+ connected=true100+ cold_boot_failure "physical_connector_connected:$connector_name"101+ fi102+ done103+104+ if [[ "$found" == false ]]; then105+ cold_boot_evidence_insufficient no_drm_connector_status_files106+ elif [[ "$connected" == false ]]; then107+ echo 'headless_connector_state=ok'108+ fi109+}110+111+cold_boot_session_evidence() {112+ local sessions113+ local session_id114+ local session_details115+ local current_user_id116+ local autologin_found=false117+118+ current_user_id="$(id -u)"119+120+ if ! sessions="$(loginctl list-sessions --no-legend 2>&1)"; then121+ printf 'loginctl_error=%s\n' "$sessions"122+ cold_boot_evidence_insufficient login_sessions_unavailable123+ return124+ fi125+126+ while read -r session_id _; do127+ [[ -n "$session_id" ]] || continue128+ if ! session_details="$(loginctl show-session "$session_id" \129+ -p Id -p User -p Remote -p Service -p Type -p Class -p State -p Timestamp 2>&1)"; then130+ printf 'session_id=%s details=unavailable\n' "$session_id"131+ cold_boot_evidence_insufficient "session_details_unavailable:$session_id"132+ continue133+ fi134+ printf 'session_begin=%s\n%s\nsession_end=%s\n' \135+ "$session_id" "$session_details" "$session_id"136+ if grep -qx 'Service=sddm-autologin' <<<"$session_details" &&137+ grep -qx "User=$current_user_id" <<<"$session_details" &&138+ grep -qx 'Remote=no' <<<"$session_details" &&139+ grep -qx 'State=active' <<<"$session_details"; then140+ autologin_found=true141+ fi142+ done <<<"$sessions"143+144+ if [[ "$autologin_found" == true ]]; then145+ echo 'gaming_autologin_session=ok'146+ else147+ cold_boot_failure no_active_local_sddm_autologin_session148+ fi149+}150+151+cold_boot_service_evidence() {152+ local enabled153+ local active154+ local service_details155+156+ enabled="$(systemctl --user is-enabled sigil-host.service 2>&1 || true)"157+ active="$(systemctl --user is-active sigil-host.service 2>&1 || true)"158+ printf 'sigil_host_enabled=%s\n' "$enabled"159+ printf 'sigil_host_active=%s\n' "$active"160+ [[ "$enabled" == enabled ]] || cold_boot_failure sigil_host_not_enabled161+ [[ "$active" == active ]] || cold_boot_failure sigil_host_not_active162+163+ if service_details="$(systemctl --user show sigil-host.service \164+ -p ActiveEnterTimestamp -p ActiveEnterTimestampMonotonic \165+ -p ExecMainStartTimestamp -p FragmentPath -p NRestarts 2>&1)"; then166+ printf '%s\n' "$service_details"167+ else168+ printf 'sigil_host_show_error=%s\n' "$service_details"169+ cold_boot_evidence_insufficient sigil_host_service_details_unavailable170+ fi171+}172+173+cold_boot_pipewire_evidence() {174+ local pipewire_nodes175+ local default_sink176+177+ if ! command -v pw-dump >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then178+ cold_boot_evidence_insufficient pipewire_inventory_tools_missing179+ return180+ fi181+182+ if ! pipewire_nodes="$(pw-dump 2>/dev/null | jq -r '183+ .[]184+ | select(.type == "PipeWire:Interface:Node")185+ | if (186+ .info.props["node.name"] == "gamescope"187+ and .info.props["media.class"] == "Video/Source"188+ ) then "gamescope_video"189+ elif (190+ .info.props["node.name"] == "sigil_spark"191+ and .info.props["media.class"] == "Audio/Sink"192+ ) then "sigil_audio"193+ else empty194+ end195+ ' | sort -u)"; then196+ cold_boot_evidence_insufficient pipewire_nodes_unavailable197+ return198+ fi199+200+ if grep -qx gamescope_video <<<"$pipewire_nodes"; then201+ echo 'gamescope_pipewire_node=ok'202+ else203+ cold_boot_failure gamescope_pipewire_node_missing204+ fi205+ if grep -qx sigil_audio <<<"$pipewire_nodes"; then206+ echo 'sigil_audio_pipewire_node=ok'207+ else208+ cold_boot_failure sigil_audio_pipewire_node_missing209+ fi210+211+ if ! command -v pactl >/dev/null 2>&1; then212+ cold_boot_evidence_insufficient pactl_missing213+ elif default_sink="$(pactl get-default-sink 2>&1)"; then214+ printf 'default_audio_sink=%s\n' "$default_sink"215+ [[ "$default_sink" == sigil_spark ]] || cold_boot_failure default_audio_sink_not_sigil_spark216+ else217+ printf 'pactl_error=%s\n' "$default_sink"218+ cold_boot_evidence_insufficient default_audio_sink_unavailable219+ fi220+}221+222+cold_boot_journal_evidence() {223+ local journal_summary224+ local journal_entries225+ local journal_first226+ local gamescope_first227+ local sigil_unit_first228+ local sigil_ready_first229+ local ssh_accept_first230+231+ if ! journal_summary="$(journalctl -b -o short-monotonic --no-pager 2>/dev/null | \232+ summarize_boot_journal)"; then233+ cold_boot_evidence_insufficient boot_journal_unavailable234+ return235+ fi236+ printf '%s\n' "$journal_summary"237+238+ journal_entries="$(summary_value journal_entries <<<"$journal_summary")"239+ journal_first="$(summary_value journal_first_monotonic_seconds <<<"$journal_summary")"240+ gamescope_first="$(summary_value gamescope_first_monotonic_seconds <<<"$journal_summary")"241+ sigil_unit_first="$(summary_value sigil_unit_first_monotonic_seconds <<<"$journal_summary")"242+ sigil_ready_first="$(summary_value sigil_ready_first_monotonic_seconds <<<"$journal_summary")"243+ ssh_accept_first="$(summary_value ssh_accept_first_monotonic_seconds <<<"$journal_summary")"244+245+ if [[ ! "$journal_entries" =~ ^[1-9][0-9]*$ ||246+ ! "$journal_first" =~ ^[0-9]+([.][0-9]+)?$ ]]; then247+ cold_boot_evidence_insufficient boot_journal_empty_or_unparseable248+ return249+ fi250+ if ! awk -v first="$journal_first" 'BEGIN { exit !(first <= 300) }'; then251+ cold_boot_evidence_insufficient boot_journal_does_not_reach_startup252+ fi253+ if [[ ! "$ssh_accept_first" =~ ^[0-9]+([.][0-9]+)?$ ]]; then254+ cold_boot_evidence_insufficient first_ssh_acceptance_not_observable255+ return256+ fi257+258+ if [[ ! "$gamescope_first" =~ ^[0-9]+([.][0-9]+)?$ ]]; then259+ cold_boot_failure gamescope_start_not_found_in_boot_journal260+ elif ! monotonic_before "$gamescope_first" "$ssh_accept_first"; then261+ cold_boot_failure gamescope_did_not_start_before_first_ssh262+ else263+ echo 'gamescope_before_first_ssh=ok'264+ fi265+266+ if [[ ! "$sigil_unit_first" =~ ^[0-9]+([.][0-9]+)?$ ]]; then267+ cold_boot_failure sigil_unit_start_not_found_in_boot_journal268+ elif ! monotonic_before "$sigil_unit_first" "$ssh_accept_first"; then269+ cold_boot_failure sigil_unit_did_not_start_before_first_ssh270+ else271+ echo 'sigil_unit_before_first_ssh=ok'272+ fi273+274+ if [[ ! "$sigil_ready_first" =~ ^[0-9]+([.][0-9]+)?$ ]]; then275+ cold_boot_failure sigil_ready_not_found_in_boot_journal276+ elif ! monotonic_before "$sigil_ready_first" "$ssh_accept_first"; then277+ cold_boot_failure sigil_was_not_ready_before_first_ssh278+ else279+ echo 'sigil_ready_before_first_ssh=ok'280+ fi281+}282+283+cold_boot_audit() {284+ local boot_id285+286+ section cold_boot_evidence287+ if boot_id="$(< /proc/sys/kernel/random/boot_id)" && [[ -n "$boot_id" ]]; then288+ printf 'boot_id=%s\n' "$boot_id"289+ else290+ cold_boot_evidence_insufficient boot_id_unavailable291+ fi292+293+ cold_boot_connector_evidence294+ cold_boot_session_evidence295+ cold_boot_service_evidence296+ cold_boot_pipewire_evidence297+ cold_boot_journal_evidence298+299+ printf 'cold_boot_failure_count=%d\n' "$cold_boot_failures"300+ printf 'cold_boot_insufficient_count=%d\n' "$cold_boot_insufficient"301+ if ((cold_boot_insufficient > 0)); then302+ echo 'cold_boot_result=insufficient'303+ return 3304+ fi305+ if ((cold_boot_failures > 0)); then306+ echo 'cold_boot_result=fail'307+ return 1308+ fi309+ echo 'cold_boot_result=pass'310+}311+312+if [[ "${SIGIL_INVENTORY_SOURCE_ONLY:-}" == 1 ]]; then313+ if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then314+ return 0315+ fi316+ exit 0317+fi318+319+if [[ "$(uname -s)" != Linux ]]; then320+ echo "bazzite-inventory must run on the Linux host" >&2321+ exit 1322+fi323+324+mode=inventory325+if [[ "${1:-}" == "--smoke" && $# -eq 1 ]]; then326+ mode=smoke327+elif [[ "${1:-}" == "--cold-boot" && $# -eq 1 ]]; then328+ mode=cold-boot329+elif [[ $# -ne 0 ]]; then330+ echo "usage: $0 [--smoke | --cold-boot]" >&2331+ exit 2332+fi333+334+if [[ "$mode" == cold-boot ]]; then335+ cold_boot_status=0336+ cold_boot_audit || cold_boot_status=$?337+ exit "$cold_boot_status"338+fi339+31340 section identity32341 date --iso-8601=ns33342 hostnamectl@@ -141,7 +450,7 @@ else
141450 echo "pipewire_node_inventory=unavailable"142451 fi143452 144-if [[ "$smoke" == true ]]; then453+if [[ "$mode" == smoke ]]; then145454 section h264_smoke146455 if [[ -z "$render_node" ]]; then147456 echo "AMD render node is required for --smoke" >&2@@ -4,31 +4,38 @@ set -euo pipefail
44 55 script_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"66 repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"7-host_binary="$repo_dir/target/x86_64-unknown-linux-gnu/release/sigil-host"8-probe_binary="$repo_dir/target/x86_64-unknown-linux-gnu/release/sigil-probe"7+host_binary=""8+probe_binary=""99 output_path=""1010 allow_dirty=false1111 allow_unsigned=false1212 minisign_secret_key=""13+linux_target="x86_64-unknown-linux-gnu.2.17"14+linux_output_target="x86_64-unknown-linux-gnu"1315 1416 usage() {1517 cat <<'EOF'1618 Usage: scripts/package-bazzite-release.sh --output /absolute/path/package.tar.gz [options]1719 18-Create an allowlisted, deterministic Bazzite host runtime package from the19-prebuilt generic Linux release binaries. The package includes atomic install,20+Create an allowlisted, deterministic Bazzite host runtime package. Product mode21+exports the clean source HEAD and builds both generic Linux release binaries in22+an isolated Cargo target directory. The package includes atomic install,2023 upgrade/rollback support, the systemd user unit, PipeWire sink, staged udev2124 rule, complete checksums, and build provenance. It never includes the source2225 tree, host identity, hardware configuration, environment files, or evidence.2326 2427 Options:2528 --output PATH New .tar.gz bundle path (required; must not exist)26- --host-binary PATH Generic Linux sigil-host (default: target cross release)27- --probe-binary PATH Generic Linux sigil-probe (default: target cross release)29+ --host-binary PATH Development-only prebuilt generic Linux sigil-host30+ --probe-binary PATH Development-only prebuilt generic Linux sigil-probe2831 --minisign-key PATH Create detached PACKAGE.minisig with this secret key2932 --allow-dirty Permit development packaging from a dirty worktree3033 --allow-unsigned Permit a package without a detached publisher signature3134 --help Show this help35+36+The two prebuilt-binary options are an all-or-none development escape hatch and37+require both --allow-dirty and --allow-unsigned. Product packages never accept38+caller-supplied binaries.3239 EOF3340 }3441 @@ -92,8 +99,17 @@ esac
9299 [[ ! -e "$output_path" ]] || die "output already exists: $output_path"93100 [[ ! -e "$output_path.sha256" ]] || die "digest output already exists: $output_path.sha256"94101 [[ ! -e "$output_path.minisig" ]] || die "signature output already exists: $output_path.minisig"95-[[ -f "$host_binary" && -x "$host_binary" ]] || die "host binary is not executable: $host_binary"96-[[ -f "$probe_binary" && -x "$probe_binary" ]] || die "probe binary is not executable: $probe_binary"102+103+caller_supplied_binaries=false104+if [[ -n "$host_binary" || -n "$probe_binary" ]]; then105+ [[ -n "$host_binary" && -n "$probe_binary" ]] \106+ || die "--host-binary and --probe-binary must be supplied together"107+ if ! $allow_dirty || ! $allow_unsigned; then108+ die "caller-supplied binaries require both --allow-dirty and --allow-unsigned; product mode builds both binaries from clean HEAD"109+ fi110+ caller_supplied_binaries=true111+fi112+97113 [[ -x "$script_dir/install-bazzite-package.sh" ]] || die "package installer is missing"98114 [[ -x "$script_dir/rollback-bazzite-release.sh" ]] || die "rollback helper is missing"99115 [[ -f "$script_dir/sigil-host.service" ]] || die "systemd user unit is missing"@@ -136,6 +152,37 @@ cleanup() {
136152 }137153 trap cleanup EXIT INT TERM HUP138154 155+if $caller_supplied_binaries; then156+ [[ -f "$host_binary" && -x "$host_binary" ]] \157+ || die "host binary is not executable: $host_binary"158+ [[ -f "$probe_binary" && -x "$probe_binary" ]] \159+ || die "probe binary is not executable: $probe_binary"160+ binary_provenance="caller-supplied-unverified"161+ binary_provenance_verified=false162+else163+ command -v cargo >/dev/null 2>&1 || die "cargo is required to build product binaries"164+ command -v cargo-zigbuild >/dev/null 2>&1 \165+ || die "cargo-zigbuild is required to build product binaries"166+167+ build_source="$temp_root/source"168+ build_target="$temp_root/cargo-target"169+ install -d -m 0700 "$build_source" "$build_target"170+ git -C "$repo_dir" archive --format=tar "$git_commit" | tar -xf - -C "$build_source"171+ (172+ cd "$build_source"173+ CARGO_TARGET_DIR="$build_target" cargo zigbuild --locked -p sigil-host --bins \174+ --target "$linux_target" --release175+ )176+ host_binary="$build_target/$linux_output_target/release/sigil-host"177+ probe_binary="$build_target/$linux_output_target/release/sigil-probe"178+ [[ -f "$host_binary" && -x "$host_binary" ]] \179+ || die "product build did not produce sigil-host"180+ [[ -f "$probe_binary" && -x "$probe_binary" ]] \181+ || die "product build did not produce sigil-probe"182+ binary_provenance="self-built-clean-head"183+ binary_provenance_verified=true184+fi185+139186 payload="$temp_root/payload"140187 release_tree="$payload/release"141188 install -d -m 0700 "$payload" "$release_tree" "$release_tree/assets" "$release_tree/tools"@@ -155,20 +202,32 @@ product_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_dir/src-tauri/C
155202 [[ -n "$product_version" ]] || die "could not read the product version"156203 lock_sha256="$(sha256_file "$repo_dir/Cargo.lock")"157204 toolchain_sha256="$(sha256_file "$repo_dir/rust-toolchain.toml")"158-zigbuild_version="$(cargo zigbuild --version 2>/dev/null | head -n 1 || true)"205+zigbuild_version="$(cargo-zigbuild --version 2>/dev/null | head -n 1 || true)"159206 [[ -n "$zigbuild_version" ]] || zigbuild_version="unavailable"160207 python3 - "$release_tree/release-manifest.json" "$product_version" "$git_commit" \161- "$git_dirty" "$lock_sha256" "$toolchain_sha256" "$zigbuild_version" <<'PY'208+ "$git_dirty" "$lock_sha256" "$toolchain_sha256" "$zigbuild_version" \209+ "$binary_provenance" "$binary_provenance_verified" "$linux_target" <<'PY'162210 import json163211 import pathlib164212 import sys165213 166-path, version, commit, dirty, lock_sha, toolchain_sha, zigbuild = sys.argv[1:]214+(215+ path,216+ version,217+ commit,218+ dirty,219+ lock_sha,220+ toolchain_sha,221+ zigbuild,222+ binary_provenance,223+ binary_provenance_verified,224+ target,225+) = sys.argv[1:]167226 manifest = {168227 "format": 2,169228 "product": "sigil-spark-host",170229 "version": version,171- "target": "x86_64-unknown-linux-gnu.2.17",230+ "target": target,172231 "profile": "release",173232 "features": ["default"],174233 "demo_direct_node": False,@@ -177,6 +236,8 @@ manifest = {
177236 "cargo_lock_sha256": lock_sha,178237 "rust_toolchain_sha256": toolchain_sha,179238 "cargo_zigbuild": zigbuild,239+ "binary_provenance": binary_provenance,240+ "binary_provenance_verified": binary_provenance_verified == "true",180241 }181242 pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")182243 PY@@ -0,0 +1,122 @@
1+2+# shellcheck disable=SC23293+4+set -euo pipefail5+6+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"7+export SIGIL_INVENTORY_SOURCE_ONLY=18+# shellcheck source=../bazzite-inventory.sh9+# shellcheck disable=SC109110+source "$script_dir/../bazzite-inventory.sh"11+12+fail() {13+ printf 'FAIL: %s\n' "$1" >&214+ exit 115+}16+17+assert_contains() {18+ local output="$1"19+ local expected="$2"20+ grep -qxF "$expected" <<<"$output" || \21+ fail "expected output line: $expected"22+}23+24+assert_not_contains() {25+ local output="$1"26+ local unexpected="$2"27+ if grep -qF "$unexpected" <<<"$output"; then28+ fail "unexpected output text: $unexpected"29+ fi30+}31+32+session_user_id="$(id -u)"33+loginctl() {34+ if [[ "$1" == list-sessions ]]; then35+ printf '2 %s test seat0 tty1\n' "$session_user_id"36+ else37+ printf '%s\n' \38+ 'Id=2' \39+ "User=$session_user_id" \40+ 'Remote=no' \41+ 'Service=sddm-autologin' \42+ 'Type=wayland' \43+ 'Class=user' \44+ 'State=active'45+ fi46+}47+session_output="$(cold_boot_session_evidence)"48+assert_contains "$session_output" 'gaming_autologin_session=ok'49+assert_not_contains "$session_output" 'cold_boot_failure='50+51+loginctl() {52+ if [[ "$1" == list-sessions ]]; then53+ printf '2 65534 somebody seat0 tty1\n'54+ else55+ printf '%s\n' \56+ 'Id=2' \57+ 'User=65534' \58+ 'Remote=no' \59+ 'Service=sddm-autologin' \60+ 'Type=wayland' \61+ 'Class=user' \62+ 'State=active'63+ fi64+}65+wrong_user_session_output="$(cold_boot_session_evidence)"66+assert_contains "$wrong_user_session_output" \67+ 'cold_boot_failure=no_active_local_sddm_autologin_session'68+69+pass_journal='[0.500000] host systemd[1]: Reached target Basic System70+[1.000000] host gamescope-session-plus[100]: starting Gamescope71+[2.000000] host systemd[1000]: Started sigil-host.service - Sigil Spark streaming host72+[3.000000] host sigil-host[200]: INFO sigil host ready73+[4.000000] host sshd-session[300]: Accepted publickey for tank'74+journalctl() {75+ printf '%s\n' "$pass_journal"76+}77+pass_output="$(cold_boot_journal_evidence)"78+assert_contains "$pass_output" 'gamescope_before_first_ssh=ok'79+assert_contains "$pass_output" 'sigil_unit_before_first_ssh=ok'80+assert_contains "$pass_output" 'sigil_ready_before_first_ssh=ok'81+assert_not_contains "$pass_output" 'cold_boot_failure='82+assert_not_contains "$pass_output" 'cold_boot_evidence_insufficient='83+84+late_journal='[301.000000] host gamescope-session-plus[100]: starting Gamescope85+[302.000000] host systemd[1000]: Started sigil-host.service - Sigil Spark streaming host86+[303.000000] host sigil-host[200]: INFO sigil host ready87+[304.000000] host sshd-session[300]: Accepted publickey for tank'88+journalctl() {89+ printf '%s\n' "$late_journal"90+}91+late_output="$(cold_boot_journal_evidence)"92+assert_contains "$late_output" \93+ 'cold_boot_evidence_insufficient=boot_journal_does_not_reach_startup'94+95+bad_order_journal='[0.500000] host systemd[1]: Reached target Basic System96+[1.000000] host sshd-session[300]: Accepted publickey for tank97+[2.000000] host gamescope-session-plus[100]: starting Gamescope98+[3.000000] host systemd[1000]: Started sigil-host.service - Sigil Spark streaming host99+[4.000000] host sigil-host[200]: INFO sigil host ready'100+journalctl() {101+ printf '%s\n' "$bad_order_journal"102+}103+bad_order_output="$(cold_boot_journal_evidence)"104+assert_contains "$bad_order_output" \105+ 'cold_boot_failure=gamescope_did_not_start_before_first_ssh'106+assert_contains "$bad_order_output" \107+ 'cold_boot_failure=sigil_unit_did_not_start_before_first_ssh'108+assert_contains "$bad_order_output" \109+ 'cold_boot_failure=sigil_was_not_ready_before_first_ssh'110+111+missing_ssh_journal='[0.500000] host systemd[1]: Reached target Basic System112+[1.000000] host gamescope-session-plus[100]: starting Gamescope113+[2.000000] host systemd[1000]: Started sigil-host.service - Sigil Spark streaming host114+[3.000000] host sigil-host[200]: INFO sigil host ready'115+journalctl() {116+ printf '%s\n' "$missing_ssh_journal"117+}118+missing_ssh_output="$(cold_boot_journal_evidence)"119+assert_contains "$missing_ssh_output" \120+ 'cold_boot_evidence_insufficient=first_ssh_acceptance_not_observable'121+122+echo 'bazzite_inventory_cold_boot_tests=ok'@@ -0,0 +1,86 @@
1+2+3+set -euo pipefail4+5+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7+package_script="$repo_dir/scripts/package-bazzite-release.sh"8+temp_parent="${TMPDIR:-/tmp}"9+temp_parent="${temp_parent%/}"10+temp_root="$(mktemp -d "$temp_parent/sigil-package-provenance.XXXXXX")"11+12+cleanup() {13+ local status=$?14+ trap - EXIT INT TERM HUP15+ case "$temp_root" in16+ "$temp_parent"/sigil-package-provenance.??????) rm -rf -- "$temp_root" ;;17+ esac18+ exit "$status"19+}20+trap cleanup EXIT INT TERM HUP21+22+host_binary="$temp_root/sigil-host"23+probe_binary="$temp_root/sigil-probe"24+printf '#!/usr/bin/env bash\nexit 0\n' >"$host_binary"25+printf '#!/usr/bin/env bash\nexit 0\n' >"$probe_binary"26+chmod 0755 "$host_binary" "$probe_binary"27+28+assert_rejected() {29+ local case_name="$1"30+ local expected="$2"31+ local output_path="$temp_root/$case_name.tar.gz"32+ local log_path="$temp_root/$case_name.log"33+ shift 234+35+ if "$package_script" --output "$output_path" "$@" >"$log_path" 2>&1; then36+ printf 'FAIL: %s unexpectedly succeeded\n' "$case_name" >&237+ exit 138+ fi39+ grep -Fq -- "$expected" "$log_path" || {40+ printf 'FAIL: %s did not report %s\n' "$case_name" "$expected" >&241+ sed -n '1,120p' "$log_path" >&242+ exit 143+ }44+ [[ ! -e "$output_path" && ! -e "$output_path.sha256" && ! -e "$output_path.minisig" ]] || {45+ printf 'FAIL: %s created package output before rejecting supplied binaries\n' "$case_name" >&246+ exit 147+ }48+}49+50+both_flags_message='caller-supplied binaries require both --allow-dirty and --allow-unsigned'51+assert_rejected product-supplied "$both_flags_message" \52+ --host-binary "$host_binary" --probe-binary "$probe_binary"53+assert_rejected dirty-only-supplied "$both_flags_message" \54+ --allow-dirty --host-binary "$host_binary" --probe-binary "$probe_binary"55+assert_rejected unsigned-only-supplied "$both_flags_message" \56+ --allow-unsigned --host-binary "$host_binary" --probe-binary "$probe_binary"57+assert_rejected partial-development-pair \58+ '--host-binary and --probe-binary must be supplied together' \59+ --allow-dirty --allow-unsigned --host-binary "$host_binary"60+61+development_output="$temp_root/development-supplied.tar.gz"62+development_log="$temp_root/development-supplied.log"63+"$package_script" \64+ --output "$development_output" \65+ --allow-dirty --allow-unsigned \66+ --host-binary "$host_binary" --probe-binary "$probe_binary" \67+ >"$development_log"68+grep -Fq 'publisher_signature=absent-development' "$development_log"69+python3 - "$development_output" <<'PY'70+import json71+import sys72+import tarfile73+74+with tarfile.open(sys.argv[1], "r:gz") as archive:75+ manifest_file = archive.extractfile("payload/release/release-manifest.json")76+ if manifest_file is None:77+ raise SystemExit("release manifest is missing")78+ manifest = json.load(manifest_file)79+80+if manifest.get("binary_provenance") != "caller-supplied-unverified":81+ raise SystemExit("caller-supplied binary provenance is not marked unverified")82+if manifest.get("binary_provenance_verified") is not False:83+ raise SystemExit("caller-supplied binary provenance must not be verified")84+PY85+86+printf 'package_bazzite_provenance_tests=ok\n'@@ -31,21 +31,10 @@ if command -v cargo-zigbuild >/dev/null 2>&1 && command -v zig >/dev/null 2>&1;
3131 else3232 echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'3333 fi34-node --check src/main.js35-node --check src/audio-envelope.mjs36-node --check src/audio-ring.mjs37-node --check src/audio-worklet.js38-node --check src/controller-state.mjs39-node --check src/frame-envelope.mjs40-node --check src/input-state.mjs41-node --check src/stream-metrics.mjs42-node --test \43- src/audio-envelope.test.mjs \44- src/audio-ring.test.mjs \45- src/controller-state.test.mjs \46- src/frame-envelope.test.mjs \47- src/input-state.test.mjs \48- src/stream-metrics.test.mjs34+while IFS= read -r frontend_source; do35+ node --check "$frontend_source"36+done < <(find src -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)37+node --test src/*.test.mjs4938 5039 if command -v shellcheck >/dev/null 2>&1; then5140 find scripts -type f -name '*.sh' -exec shellcheck {} +@@ -53,6 +42,9 @@ else
5342 echo 'shellcheck is required for the demo preflight' >&25443 exit 15544 fi45+while IFS= read -r script_test; do46+ "$script_test"47+done < <(find scripts/tests -maxdepth 1 -type f -name '*.sh' -perm -u+x -print | sort)5648 5749 host_dependencies="$(cargo tree --locked -p sigil-host --edges normal)"5850 if grep -Eiq '(^|[[:space:]├└│─])(tauri|wry|webkit)([[:space:]-]|$)' <<<"$host_dependencies"; then@@ -87,6 +87,7 @@ import {
8787 fitInitialStreamWindow,8888 fitStreamSurface,8989 } from './window-geometry.mjs';90+import { buildVideoDecoderConfig } from './video-decoder-config.mjs';9091 9192 let controlMode = false;9293 let connected = false;@@ -958,6 +959,10 @@ let lastVideoDrawCompletedAtMs = null;
958959 const framePresenter = new LatestFramePresenter({959960 requestFrame: (callback) => requestAnimationFrame(callback),960961 cancelFrame: (handle) => cancelAnimationFrame(handle),962+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),963+ cancelTimer: (handle) => clearTimeout(handle),964+ now: () => performance.now(),965+ fallbackDelayMs: 25,961966 draw: (frame) => {962967 const startedAt = performance.now();963968 ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);@@ -1066,13 +1071,12 @@ function initWebCodecsDecoder(width, height, desc, codecStr) {
10661071 teardownDecoderPipeline();10671072 }10681073 });1069- videoDecoder.configure({1074+ videoDecoder.configure(buildVideoDecoderConfig({10701075 codec: codecStr,1071- codedWidth: width,1072- codedHeight: height,1076+ width,1077+ height,10731078 description: desc,1074- optimizeForRealtimeUse: true,1075- });1079+ }));10761080 decoderConfigured = true;10771081 waitingForDecoderKeyframe = false;10781082 }@@ -221,9 +221,36 @@ export class BoundedCadenceWindow {
221221 }222222 223223 export class LatestFramePresenter {224- constructor({ requestFrame, cancelFrame, draw, onPresent = () => {}, onDrop = () => {} }) {224+ constructor({225+ requestFrame,226+ cancelFrame,227+ setTimer,228+ cancelTimer,229+ now,230+ fallbackDelayMs = 25,231+ draw,232+ onPresent = () => {},233+ onDrop = () => {},234+ }) {235+ for (const [name, value] of Object.entries({236+ requestFrame,237+ cancelFrame,238+ setTimer,239+ cancelTimer,240+ now,241+ draw,242+ })) {243+ if (typeof value !== 'function') throw new TypeError(`${name} must be a function`);244+ }245+ if (!Number.isFinite(fallbackDelayMs) || fallbackDelayMs < 1 || fallbackDelayMs > 1000) {246+ throw new RangeError('fallbackDelayMs must be within 1..1000 ms');247+ }225248 this.requestFrame = requestFrame;226249 this.cancelFrame = cancelFrame;250+ this.setTimer = setTimer;251+ this.cancelTimer = cancelTimer;252+ this.now = now;253+ this.fallbackDelayMs = fallbackDelayMs;227254 this.draw = draw;228255 this.onPresent = onPresent;229256 this.onDrop = onDrop;@@ -234,7 +261,7 @@ export class LatestFramePresenter {
234261 // two-frame ceiling still bounds latency, and overload evicts the oldest235262 // stale frame first.236263 this.pending = [];237- this.frameRequest = null;264+ this.activeSchedule = null;238265 }239266 240267 enqueue(frame, metadata = null) {@@ -249,25 +276,55 @@ export class LatestFramePresenter {
249276 }250277 251278 #schedule() {252- if (this.frameRequest !== null || this.pending.length === 0) return;253- this.frameRequest = this.requestFrame((nowMs) => {254- this.frameRequest = null;255- const pending = this.pending.shift();256- if (!pending) return;257- try {258- this.draw(pending.frame);259- this.onPresent(pending.metadata, nowMs);260- } finally {261- pending.frame.close();262- this.#schedule();263- }279+ if (this.activeSchedule !== null || this.pending.length === 0) return;280+ const schedule = {281+ settled: false,282+ frameHandle: null,283+ timerHandle: null,284+ };285+ this.activeSchedule = schedule;286+ const frameHandle = this.requestFrame((nowMs) => {287+ this.#presentScheduled(schedule, 'frame', nowMs);264288 });289+ if (this.activeSchedule !== schedule || schedule.settled) return;290+ schedule.frameHandle = frameHandle;291+ const timerHandle = this.setTimer(() => {292+ this.#presentScheduled(schedule, 'timer', this.now());293+ }, this.fallbackDelayMs);294+ if (this.activeSchedule === schedule && !schedule.settled) {295+ schedule.timerHandle = timerHandle;296+ } else {297+ this.cancelTimer(timerHandle);298+ }299+ }300+301+ #presentScheduled(schedule, source, nowMs) {302+ if (this.activeSchedule !== schedule || schedule.settled) return;303+ schedule.settled = true;304+ this.activeSchedule = null;305+ if (source === 'frame') {306+ if (schedule.timerHandle !== null) this.cancelTimer(schedule.timerHandle);307+ } else if (schedule.frameHandle !== null) {308+ this.cancelFrame(schedule.frameHandle);309+ }310+ const pending = this.pending.shift();311+ if (!pending) return;312+ try {313+ this.draw(pending.frame);314+ this.onPresent(pending.metadata, nowMs);315+ } finally {316+ pending.frame.close();317+ this.#schedule();318+ }265319 }266320 267321 clear() {268- if (this.frameRequest !== null) {269- this.cancelFrame(this.frameRequest);270- this.frameRequest = null;322+ const schedule = this.activeSchedule;323+ this.activeSchedule = null;324+ if (schedule !== null) {325+ schedule.settled = true;326+ if (schedule.frameHandle !== null) this.cancelFrame(schedule.frameHandle);327+ if (schedule.timerHandle !== null) this.cancelTimer(schedule.timerHandle);271328 }272329 for (const pending of this.pending.splice(0)) pending.frame.close();273330 }@@ -181,16 +181,25 @@ test('latency percentiles are bounded, deterministic, and expire', () => {
181181 test('latest-frame presenter bounds jitter buffering at two and drops the oldest frame', () => {182182 const callbacks = [];183183 const cancelled = [];184+ const timerCallbacks = [];185+ const cancelledTimers = [];184186 const drawn = [];185187 const presented = [];186188 const dropped = [];187189 let nextHandle = 1;190+ let nextTimerHandle = 1;188191 const presenter = new LatestFramePresenter({189192 requestFrame: (callback) => {190193 callbacks.push(callback);191194 return nextHandle++;192195 },193196 cancelFrame: (handle) => cancelled.push(handle),197+ setTimer: (callback) => {198+ timerCallbacks.push(callback);199+ return nextTimerHandle++;200+ },201+ cancelTimer: (handle) => cancelledTimers.push(handle),202+ now: () => 999,194203 draw: (frame) => drawn.push(frame.id),195204 onPresent: (metadata) => presented.push(metadata),196205 onDrop: (metadata) => dropped.push(metadata),@@ -204,6 +213,7 @@ test('latest-frame presenter bounds jitter buffering at two and drops the oldest
204213 presenter.enqueue(second, 'second');205214 presenter.enqueue(third, 'third');206215 assert.equal(callbacks.length, 1);216+ assert.equal(timerCallbacks.length, 1);207217 assert.equal(presenter.depth, 2);208218 assert.equal(first.closed, 1);209219 assert.deepEqual(dropped, ['first']);@@ -213,6 +223,7 @@ test('latest-frame presenter bounds jitter buffering at two and drops the oldest
213223 assert.deepEqual(presented, ['second']);214224 assert.equal(second.closed, 1);215225 assert.equal(callbacks.length, 1);226+ assert.deepEqual(cancelledTimers, [1]);216227 assert.equal(presenter.depth, 1);217228 218229 callbacks.shift()(140);@@ -220,6 +231,7 @@ test('latest-frame presenter bounds jitter buffering at two and drops the oldest
220231 assert.deepEqual(presented, ['second', 'third']);221232 assert.equal(third.closed, 1);222233 assert.equal(presenter.depth, 0);234+ assert.deepEqual(cancelledTimers, [1, 2]);223235 224236 const fourth = frame(4);225237 const fifth = frame(5);@@ -227,6 +239,7 @@ test('latest-frame presenter bounds jitter buffering at two and drops the oldest
227239 presenter.enqueue(fifth);228240 presenter.clear();229241 assert.deepEqual(cancelled, [3]);242+ assert.deepEqual(cancelledTimers, [1, 2, 3]);230243 assert.equal(fourth.closed, 1);231244 assert.equal(fifth.closed, 1);232245 assert.equal(presenter.depth, 0);@@ -235,6 +248,7 @@ test('latest-frame presenter bounds jitter buffering at two and drops the oldest
235248 test('two-frame presenter smooths pair-bursty 60 fps input at display cadence', () => {236249 let scheduledCallback = null;237250 let nextHandle = 1;251+ let nextTimerHandle = 1;238252 const presentedAt = [];239253 const frames = [];240254 let dropped = 0;@@ -245,6 +259,9 @@ test('two-frame presenter smooths pair-bursty 60 fps input at display cadence',
245259 return nextHandle++;246260 },247261 cancelFrame: () => { scheduledCallback = null; },262+ setTimer: () => nextTimerHandle++,263+ cancelTimer: () => {},264+ now: () => 0,248265 draw: () => {},249266 onPresent: (_metadata, nowMs) => presentedAt.push(nowMs),250267 onDrop: () => dropped++,@@ -280,3 +297,154 @@ test('two-frame presenter smooths pair-bursty 60 fps input at display cadence',
280297 )));281298 assert.ok(frames.every((value) => value.closed === 1));282299 });300+301+test('presenter watchdog drains one frame when requestAnimationFrame stalls', () => {302+ let frameCallback = null;303+ let timerCallback = null;304+ const cancelledFrames = [];305+ const drawn = [];306+ const presented = [];307+ const frame = { closed: 0, close() { this.closed++; } };308+ const presenter = new LatestFramePresenter({309+ requestFrame: (callback) => {310+ frameCallback = callback;311+ return 11;312+ },313+ cancelFrame: (handle) => cancelledFrames.push(handle),314+ setTimer: (callback, delayMs) => {315+ assert.equal(delayMs, 25);316+ timerCallback = callback;317+ return 22;318+ },319+ cancelTimer: () => assert.fail('winning watchdog must not cancel itself'),320+ now: () => 125,321+ draw: (value) => drawn.push(value),322+ onPresent: (metadata, nowMs) => presented.push({ metadata, nowMs }),323+ });324+325+ presenter.enqueue(frame, 'stalled');326+ timerCallback();327+328+ assert.deepEqual(cancelledFrames, [11]);329+ assert.deepEqual(drawn, [frame]);330+ assert.deepEqual(presented, [{ metadata: 'stalled', nowMs: 125 }]);331+ assert.equal(frame.closed, 1);332+ assert.equal(presenter.depth, 0);333+334+ frameCallback(126);335+ assert.equal(drawn.length, 1, 'a cancelled stale rAF must not present twice');336+ assert.equal(frame.closed, 1);337+});338+339+test('normal requestAnimationFrame presentation cancels its watchdog', () => {340+ let frameCallback = null;341+ let timerCallback = null;342+ const cancelledTimers = [];343+ const presented = [];344+ const frame = { closed: 0, close() { this.closed++; } };345+ const presenter = new LatestFramePresenter({346+ requestFrame: (callback) => {347+ frameCallback = callback;348+ return 1;349+ },350+ cancelFrame: () => assert.fail('winning rAF must not cancel itself'),351+ setTimer: (callback) => {352+ timerCallback = callback;353+ return 2;354+ },355+ cancelTimer: (handle) => cancelledTimers.push(handle),356+ now: () => 999,357+ draw: () => {},358+ onPresent: (metadata, nowMs) => presented.push({ metadata, nowMs }),359+ });360+361+ presenter.enqueue(frame, 'normal');362+ frameCallback(16);363+ assert.deepEqual(cancelledTimers, [2]);364+ assert.deepEqual(presented, [{ metadata: 'normal', nowMs: 16 }]);365+ assert.equal(frame.closed, 1);366+367+ timerCallback();368+ assert.equal(presented.length, 1, 'a cancelled stale timer must not present twice');369+});370+371+test('stale callbacks cannot consume a successor frame after a watchdog race', () => {372+ const frameCallbacks = [];373+ const timerCallbacks = [];374+ const drawn = [];375+ let nextFrameHandle = 1;376+ let nextTimerHandle = 10;377+ const frame = (id) => ({ id, closed: 0, close() { this.closed++; } });378+ const first = frame(1);379+ const second = frame(2);380+ const presenter = new LatestFramePresenter({381+ requestFrame: (callback) => {382+ frameCallbacks.push(callback);383+ return nextFrameHandle++;384+ },385+ cancelFrame: () => {},386+ setTimer: (callback) => {387+ timerCallbacks.push(callback);388+ return nextTimerHandle++;389+ },390+ cancelTimer: () => {},391+ now: () => 25,392+ draw: (value) => drawn.push(value.id),393+ });394+395+ presenter.enqueue(first);396+ presenter.enqueue(second);397+ timerCallbacks[0]();398+ assert.deepEqual(drawn, [1]);399+ assert.equal(frameCallbacks.length, 2);400+401+ frameCallbacks[0](26);402+ timerCallbacks[0]();403+ assert.deepEqual(drawn, [1]);404+ assert.equal(second.closed, 0);405+406+ frameCallbacks[1](32);407+ assert.deepEqual(drawn, [1, 2]);408+ assert.equal(first.closed, 1);409+ assert.equal(second.closed, 1);410+});411+412+test('clear cancels both schedulers and makes their callbacks harmless', () => {413+ let frameCallback = null;414+ let timerCallback = null;415+ const cancelledFrames = [];416+ const cancelledTimers = [];417+ const drawn = [];418+ const first = { closed: 0, close() { this.closed++; } };419+ const second = { closed: 0, close() { this.closed++; } };420+ const presenter = new LatestFramePresenter({421+ requestFrame: (callback) => {422+ frameCallback = callback;423+ return 7;424+ },425+ cancelFrame: (handle) => cancelledFrames.push(handle),426+ setTimer: (callback) => {427+ timerCallback = callback;428+ return 8;429+ },430+ cancelTimer: (handle) => cancelledTimers.push(handle),431+ now: () => 25,432+ draw: (frame) => drawn.push(frame),433+ });434+435+ presenter.enqueue(first);436+ presenter.enqueue(second);437+ presenter.clear();438+439+ assert.deepEqual(cancelledFrames, [7]);440+ assert.deepEqual(cancelledTimers, [8]);441+ assert.equal(first.closed, 1);442+ assert.equal(second.closed, 1);443+ assert.equal(presenter.depth, 0);444+445+ frameCallback(30);446+ timerCallback();447+ assert.deepEqual(drawn, []);448+ assert.equal(first.closed, 1);449+ assert.equal(second.closed, 1);450+});@@ -0,0 +1,33 @@
1+import {2+ MAX_FRAME_DIMENSION,3+ MAX_FRAME_PIXELS,4+} from './frame-envelope.mjs';5+6+/** Build the exact low-latency WebCodecs configuration used by every codec. */7+export function buildVideoDecoderConfig({ codec, width, height, description }) {8+ if (typeof codec !== 'string' || codec.trim().length === 0) {9+ throw new TypeError('video decoder codec must be a non-empty string');10+ }11+ if (12+ !Number.isSafeInteger(width) ||13+ !Number.isSafeInteger(height) ||14+ width <= 0 ||15+ height <= 0 ||16+ width > MAX_FRAME_DIMENSION ||17+ height > MAX_FRAME_DIMENSION ||18+ width * height > MAX_FRAME_PIXELS19+ ) {20+ throw new RangeError(`invalid video decoder dimensions: ${width}x${height}`);21+ }22+ if (!(description instanceof ArrayBuffer) && !ArrayBuffer.isView(description)) {23+ throw new TypeError('video decoder description must be a BufferSource');24+ }25+26+ return {27+ codec,28+ codedWidth: width,29+ codedHeight: height,30+ description,31+ optimizeForLatency: true,32+ };33+}@@ -0,0 +1,38 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import { buildVideoDecoderConfig } from './video-decoder-config.mjs';5+6+test('requests the standardized WebCodecs low-latency decoder mode', () => {7+ const description = new Uint8Array([1, 100, 0, 31]);8+ const config = buildVideoDecoderConfig({9+ codec: 'avc1.64001f',10+ width: 1280,11+ height: 800,12+ description,13+ });14+15+ assert.deepEqual(config, {16+ codec: 'avc1.64001f',17+ codedWidth: 1280,18+ codedHeight: 800,19+ description,20+ optimizeForLatency: true,21+ });22+ assert.equal(config.description, description);23+ assert.equal(Object.hasOwn(config, 'optimizeForRealtimeUse'), false);24+});25+26+test('decoder config rejects invalid codecs, dimensions, and descriptions', () => {27+ const valid = {28+ codec: 'avc1.64001f',29+ width: 1280,30+ height: 800,31+ description: new Uint8Array([1]),32+ };33+34+ assert.throws(() => buildVideoDecoderConfig({ ...valid, codec: ' ' }), TypeError);35+ assert.throws(() => buildVideoDecoderConfig({ ...valid, width: 0 }), RangeError);36+ assert.throws(() => buildVideoDecoderConfig({ ...valid, width: 8192, height: 8192 }), RangeError);37+ assert.throws(() => buildVideoDecoderConfig({ ...valid, description: [1] }), TypeError);38+});