wai: the developer's path (hello, create, dev, verify), the floor with no system library, nine examples, and the quickstart archive #504

Merged
dcharlot merged 6 commits from wai/dx-a-examples-2 into main 2026-10-27 04:14:23 -04:00
97 changed files with 6766 additions and 24 deletions

View file

@ -35,6 +35,9 @@ on:
pull_request:
paths:
- 'wai/wai-rs/**'
# `wai create` holds the examples' files (include_bytes!), and tests/cli_dx.rs holds them
# to wai/examples/ byte for byte.
- 'wai/examples/**'
# wai-rs path-depends on wai-quantum, and the quantum corpora below are
# verified through it: a change there changes what this workflow checks.
- 'wai/wai-quantum/**'
@ -576,6 +579,19 @@ jobs:
- name: Carriage conformance, the wai CLI (wrap, registry export) and corpus provenance
working-directory: wai/wai-rs
run: cargo test --no-default-features --test carriage --test manifest_builders --test cli_wrap_intent --test cli_registry --test corpus_provenance
# tests/floor.rs: the mandatory floor (SPEC 5: PNG, FLAC, zstd) in a build with the
# feature floor alone, no system codec library: decoded by the library and by `wai decode`,
# PNG and zstd written by `wai encode`, a codec outside the floor refused. tests/cli_dx.rs
# (feature quickstart): `wai hello` and `wai verify` as a person runs them, a changed byte
# of the object or of the group's receipt refused; `wai create`, every template held to
# wai/examples/ file for file and byte for byte. The binary's own tests: `wai dev`'s origin
# (a service's prefix stripped, the relay's upgrade carried both ways, nothing served from
# outside its roots) and the project file's reader.
- name: The floor build, and wai hello, create, dev and verify
working-directory: wai/wai-rs
run: |
cargo test --no-default-features --features floor --test floor
cargo test --no-default-features --features quickstart --bin wai --test floor --test cli_dx
# tests/cli_http.rs runs `wai http publish`, `codecs`, `origin` and `bench` as a user
# does: objects named by their content hash with their head proofs, a sequence's union
# and floor, the origin's headers, ranges and If-Range on loopback, and a fetch by

196
.github/workflows/wai-examples.yml vendored Normal file
View file

@ -0,0 +1,196 @@
name: WAI examples
# Every example in wai/examples/ run as a person runs it: `wai create` makes the project from
# the template the binary holds, and `wai dev` runs it (wai/quickstart/test/run-examples.sh).
# Each fails the job if its example stops working.
#
# - portable (linux): remote-desktop (a drawn screen shared into a room, the session accepted
# by the independent verifier), package-and-deliver (packed and verified), verify-receipts
# (the Rust verifier on this checkout's wai crate and the Python one, each refusing a changed
# byte), encoder-ingest (SRT into a room, every group verified against its receipt).
# - browser (macos): the room client built and packaged as the release packages it
# (wai/quickstart/build-web.sh, build.sh); the quickstart's readiness check, `wai dev
# --check`, from the unpacked archive in an empty directory; then video-room, watch-together,
# messaging, remote-desktop and package-and-deliver in the host's browser; and apple-app
# (the native sink's macOS slice, the SwiftUI app built, a recorded session replayed).
# - android (linux-arm64): android-app, the native sink's Kotlin API on the host JVM.
#
# Push filters match the push's delta, so every path an example builds on is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
paths:
- 'wai/examples/**'
- 'wai/quickstart/**'
- 'wai/wai-rs/**'
- 'wai/wai-pack/**'
- 'wai/wai-live-web/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/wai-sink-swift/**'
- 'wai/wai-sink-android/**'
- 'wai/claim-conformance/_ed25519.py'
- 'wai/claim-conformance/_blake3.py'
- 'wai/live-conformance/remote_desktop_verify.py'
- 'wai/live-conformance/live_rooms_verify.py'
- 'wai/live-conformance/telemetry_aggregate_verify.py'
- 'wai/pack-conformance/packs/video-base-01/**'
- 'joule-code/joule-code-rs/**'
- '.github/workflows/wai-examples.yml'
schedule:
- cron: '17 5 * * *'
jobs:
# The path filters gate the trigger; this job gates the work from the pull request's own
# diff (scripts/pr-touches-paths.py, reading the list above), as wai-pack.yml explains.
relevant:
name: WAI examples · does this change touch them
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-examples.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches the examples: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
portable:
name: WAI examples · portable · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
command -v python3 >/dev/null && command -v curl >/dev/null || { $SUDO apt-get update -qq; $SUDO apt-get install -y -qq python3 curl; }
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The binaries the archive holds, from this checkout. The portable examples load no page,
# so the room client's files are the source tree's (WAI_WEB), without its WebAssembly.
- name: Build wai, the relay, the room service, the remote host and the packager
shell: bash
run: |
set -euo pipefail
b="$RUNNER_TEMP/bin"; mkdir -p "$b"
out() { (cd "$1" && cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])'); }
(cd wai/wai-rs && cargo build --locked --release --no-default-features --features quickstart --bin wai) && cp "$(out wai/wai-rs)/release/wai" "$b/"
(cd joule-code/joule-code-rs && cargo build --locked --release -p joule-moq-relay --bin joule-moq-relay) && cp "$(out joule-code/joule-code-rs)/release/joule-moq-relay" "$b/"
(cd joule-code/joule-code-rs/crates/joule-moq-room && cargo build --locked --release --bin joule-moq-room) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-room)/release/joule-moq-room" "$b/"
(cd joule-code/joule-code-rs/crates/joule-moq-remote && cargo build --locked --release --bin wai-remote-host) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-remote)/release/wai-remote-host" "$b/"
(cd wai/wai-pack && cargo build --locked --release --bin wai-pack) && cp "$(out wai/wai-pack)/release/wai-pack" "$b/"
echo "$b" >> "$GITHUB_PATH"
- name: The portable examples
shell: bash
env:
WAI_WEB: ${{ github.workspace }}/joule-code/joule-code-rs/crates/joule-moq-web/web
run: bash wai/quickstart/test/run-examples.sh portable
browser:
name: WAI examples · the archive, the readiness check, the browser and Apple · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 120
steps:
- uses: actions/checkout@v5
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: wasm32 target and wasm-bindgen CLI 0.2.127
shell: bash
run: |
rustup target add wasm32-unknown-unknown
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q ' 0\.2\.127$' || cargo install --locked wasm-bindgen-cli --version 0.2.127
- name: The room client and the archive, as the release builds them
shell: bash
run: |
set -euo pipefail
nice -n 10 bash wai/quickstart/build-web.sh "$RUNNER_TEMP/web"
nice -n 10 bash wai/quickstart/build.sh "$RUNNER_TEMP/dist" --web "$RUNNER_TEMP/web"
mkdir -p "$RUNNER_TEMP/unpacked"
tar -C "$RUNNER_TEMP/unpacked" -xzf "$RUNNER_TEMP"/dist/wai-*.tar.gz
echo "WAI_DIST=$(ls -d "$RUNNER_TEMP"/unpacked/wai-*)" >> "$GITHUB_ENV"
# The quickstart's readiness check: from the archive alone, in an empty directory, the
# relay, the room service and the page on one origin, each piece checked through it.
- name: wai hello, wai verify and wai dev --check from the archive
shell: bash
run: |
set -euo pipefail
d="$RUNNER_TEMP/clean"; rm -rf "$d"; mkdir -p "$d"; cd "$d"
"$WAI_DIST/bin/wai" hello h
"$WAI_DIST/bin/wai" verify h
mkdir p && cd p && "$WAI_DIST/bin/wai" dev --check --port 0
- name: The examples in the browser
shell: bash
env:
BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
run: |
set -euo pipefail
test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; }
nice -n 10 bash wai/quickstart/test/run-examples.sh browser
- name: apple-app
shell: bash
run: nice -n 10 bash wai/quickstart/test/run-examples.sh apple
android:
name: WAI examples · android-app · linux-arm64
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux-arm64
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: JDK 17
shell: bash
run: |
set -euo pipefail
j="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
if [ ! -x "$j/bin/javac" ]; then
sudo=$(command -v sudo || true)
$sudo apt-get update -qq
$sudo apt-get install -y -qq --no-install-recommends openjdk-17-jdk-headless >/dev/null
fi
echo "JAVA_HOME=$j" >> "$GITHUB_ENV"
- name: wai
shell: bash
run: |
set -euo pipefail
(cd wai/wai-rs && cargo build --locked --release --no-default-features --features quickstart --bin wai)
t=$(cd wai/wai-rs && cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
echo "$t/release" >> "$GITHUB_PATH"
- name: android-app
shell: bash
run: |
set -euo pipefail
cache=/ci-cache/wai-sink-android
mkdir -p "$cache" 2>/dev/null || cache="$RUNNER_TEMP/wai-sink-android"
export WAI_SINK_GRADLE_DISTS="$cache/dists" GRADLE_USER_HOME="$cache/gradle-home"
bash wai/quickstart/test/run-examples.sh android

245
.github/workflows/wai-release.yml vendored Normal file
View file

@ -0,0 +1,245 @@
name: wai command line release
# On a tag wai-v<version> (the version wai/wai-rs/Cargo.toml states), builds the quickstart
# archive (wai/quickstart/build.sh) on each runner and attaches it to the forge release of that
# tag, each archive with its SHA-256. An archive holds bin/ (wai with the feature quickstart,
# joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack) and share/wai/web/ (the room
# client, built once, on the macOS host, which has the WebAssembly toolchain):
# macOS aarch64-apple-darwin and x86_64-apple-darwin;
# Linux x86_64- and aarch64-unknown-linux-musl, static, so any distribution runs it;
# Windows x86_64-pc-windows-msvc, as a zip.
# Each archive's own `wai` runs before it is attached (create --list, hello, verify, and on
# the hosts that run it, dev --check). A last job writes the Homebrew formula and the Scoop
# manifest for the archives attached and attaches them too; they go into the tap and the bucket
# by hand.
#
# Uploads go to the forge directly over the private network (FORGE_API), never through the
# public tunnel. A runner that is down leaves its platform's archive missing: the release lists
# what each build attached.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ['wai-v*']
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
FORGE_API: http://100.64.224.0:3400/api/v1
FORGE_REPO: Transaction-Science/open-standards
PUBLIC: https://git.transaction.science/Transaction-Science/open-standards
jobs:
version:
name: the tag is the crate's version
runs-on: linux
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- uses: actions/checkout@v5
- id: v
shell: bash
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#wai-v}"
crate="$(sed -n 's/^version = "\(.*\)"$/\1/p' wai/wai-rs/Cargo.toml | head -1)"
[ "$v" = "$crate" ] || { echo "::error::the tag says $v, wai/wai-rs/Cargo.toml says $crate"; exit 1; }
grep -q "WAI_VERSION:-$v}" wai/quickstart/install.sh || { echo "::error::wai/quickstart/install.sh does not install $v"; exit 1; }
echo "version=$v" >> "$GITHUB_OUTPUT"
# The room client, once: the same files on every platform.
web:
name: the room client
needs: version
runs-on: macos
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Build and attach it
shell: bash
env:
FORGE_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
rustup target add wasm32-unknown-unknown
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q ' 0\.2\.127$' || cargo install --locked wasm-bindgen-cli --version 0.2.127
v="${{ needs.version.outputs.version }}"
bash wai/quickstart/build-web.sh "$RUNNER_TEMP/web"
mkdir -p "$RUNNER_TEMP/dist"
tar -C "$RUNNER_TEMP" -czf "$RUNNER_TEMP/dist/wai-web-$v.tar.gz" web
( cd "$RUNNER_TEMP/dist" && shasum -a 256 "wai-web-$v.tar.gz" > "wai-web-$v.tar.gz.sha256" )
bash wai/quickstart/publish.sh "$GITHUB_REF_NAME" "wai $v" "$RUNNER_TEMP"/dist/*
macos:
name: macOS · ${{ matrix.triple }}
needs: [version, web]
runs-on: macos
timeout-minutes: 120
strategy:
fail-fast: false
max-parallel: 1
matrix:
triple: [aarch64-apple-darwin, x86_64-apple-darwin]
steps:
- uses: actions/checkout@v5
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Build, check and package
shell: bash
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
rustup target add "${{ matrix.triple }}"
curl -fsS -o "$RUNNER_TEMP/web.tar.gz" "${FORGE_API%/api/v1}/$FORGE_REPO/releases/download/$GITHUB_REF_NAME/wai-web-$v.tar.gz"
tar -C "$RUNNER_TEMP" -xzf "$RUNNER_TEMP/web.tar.gz"
nice -n 10 bash wai/quickstart/build.sh "$RUNNER_TEMP/dist" --web "$RUNNER_TEMP/web" --target "${{ matrix.triple }}" --version "$v"
if [ "${{ matrix.triple }}" = "$(rustc -vV | sed -n 's/^host: //p')" ]; then
t="$RUNNER_TEMP/try"; mkdir -p "$t"; tar -C "$t" -xzf "$RUNNER_TEMP/dist/wai-$v-${{ matrix.triple }}.tar.gz"
b="$t/wai-$v-${{ matrix.triple }}/bin"
(cd "$t" && "$b/wai" hello h >/dev/null && "$b/wai" verify h >/dev/null && mkdir p && cd p && "$b/wai" dev --check --port 0)
fi
- name: Attach to the release
shell: bash
env:
FORGE_TOKEN: ${{ github.token }}
run: bash wai/quickstart/publish.sh "$GITHUB_REF_NAME" "wai ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/*
linux:
name: Linux · ${{ matrix.triple }}
needs: [version, web]
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- runner: linux
triple: x86_64-unknown-linux-musl
- runner: linux-arm64
triple: aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0, the musl target and musl-gcc
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
rustup target add "${{ matrix.triple }}"
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq musl-tools python3 curl
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Build, check and package
shell: bash
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
t="${{ matrix.triple }}"
T="$(echo "$t" | tr 'a-z-' 'A-Z_')"
# Static, as the quantum command line is built (wai-quantum-cli-release.yml).
export "CC_${t//-/_}=musl-gcc" "CARGO_TARGET_${T}_LINKER=musl-gcc"
export RUSTFLAGS="-C target-feature=+crt-static -C relocation-model=static"
curl -fsS -o "$RUNNER_TEMP/web.tar.gz" "${FORGE_API%/api/v1}/$FORGE_REPO/releases/download/$GITHUB_REF_NAME/wai-web-$v.tar.gz"
tar -C "$RUNNER_TEMP" -xzf "$RUNNER_TEMP/web.tar.gz"
bash wai/quickstart/build.sh "$RUNNER_TEMP/dist" --web "$RUNNER_TEMP/web" --target "$t" --version "$v"
d="$RUNNER_TEMP/try"; mkdir -p "$d"; tar -C "$d" -xzf "$RUNNER_TEMP/dist/wai-$v-$t.tar.gz"
b="$d/wai-$v-$t/bin"
(cd "$d" && "$b/wai" hello h >/dev/null && "$b/wai" verify h >/dev/null && mkdir p && cd p && "$b/wai" dev --check --port 0)
- name: Attach to the release
shell: bash
env:
FORGE_TOKEN: ${{ github.token }}
run: bash wai/quickstart/publish.sh "$GITHUB_REF_NAME" "wai ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/*
windows:
name: Windows · x86_64-pc-windows-msvc
needs: [version, web]
runs-on: windows
timeout-minutes: 120
env:
CI: ""
steps:
- uses: actions/checkout@v5
- name: Build, check and package
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Remove-Item Env:CI -ErrorAction SilentlyContinue
$v = "${{ needs.version.outputs.version }}"
$t = "x86_64-pc-windows-msvc"
$web = Join-Path $env:RUNNER_TEMP 'web.tar.gz'
curl.exe -fsS -o $web "$($env:FORGE_API -replace '/api/v1$','')/$env:FORGE_REPO/releases/download/$env:GITHUB_REF_NAME/wai-web-$v.tar.gz"
tar -xzf $web -C $env:RUNNER_TEMP
$name = "wai-$v-$t"
$stage = Join-Path $env:RUNNER_TEMP $name
$dist = Join-Path $env:RUNNER_TEMP 'dist'
Remove-Item $stage, $dist -Recurse -Force -ErrorAction Ignore
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'bin'), (Join-Path $stage 'share\wai'), $dist | Out-Null
$builds = @(
@{ dir = 'wai\wai-rs'; bin = 'wai'; args = @('--no-default-features', '--features', 'quickstart', '--bin', 'wai') },
@{ dir = 'joule-code\joule-code-rs'; bin = 'joule-moq-relay'; args = @('-p', 'joule-moq-relay', '--bin', 'joule-moq-relay') },
@{ dir = 'joule-code\joule-code-rs\crates\joule-moq-room'; bin = 'joule-moq-room'; args = @('--bin', 'joule-moq-room') },
@{ dir = 'joule-code\joule-code-rs\crates\joule-moq-remote'; bin = 'wai-remote-host'; args = @('--bin', 'wai-remote-host') },
@{ dir = 'wai\wai-pack'; bin = 'wai-pack'; args = @('--bin', 'wai-pack') }
)
foreach ($b in $builds) {
Push-Location $b.dir
cargo build --locked --release --target $t @($b.args)
if ($LASTEXITCODE -ne 0) { throw "the build of $($b.bin) failed" }
$target = (cargo metadata --format-version 1 --no-deps --locked | ConvertFrom-Json).target_directory
Copy-Item (Join-Path $target "$t\release\$($b.bin).exe") (Join-Path $stage 'bin')
Pop-Location
}
Copy-Item -Recurse (Join-Path $env:RUNNER_TEMP 'web') (Join-Path $stage 'share\wai\web')
Copy-Item wai\quickstart\README.md, wai\LICENSE $stage
& (Join-Path $stage 'bin\wai.exe') create --list
if ($LASTEXITCODE -ne 0) { throw "the packaged wai does not run" }
Compress-Archive -Path $stage -DestinationPath (Join-Path $dist "$name.zip") -Force
$h = (Get-FileHash (Join-Path $dist "$name.zip") -Algorithm SHA256).Hash.ToLower()
Set-Content -NoNewline -Path (Join-Path $dist "$name.zip.sha256") -Value "$h $name.zip`n"
- name: Attach to the release
shell: pwsh
env:
FORGE_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$H = @{ Authorization = "token $env:FORGE_TOKEN" }
$base = "$env:FORGE_API/repos/$env:FORGE_REPO/releases"
$rel = Invoke-RestMethod -Headers $H -Uri "$base/tags/$env:GITHUB_REF_NAME"
foreach ($f in Get-ChildItem (Join-Path $env:RUNNER_TEMP 'dist')) {
foreach ($a in (Invoke-RestMethod -Headers $H -Uri "$base/$($rel.id)/assets" | Where-Object { $_.name -eq $f.Name })) {
Invoke-RestMethod -Headers $H -Method Delete -Uri "$base/$($rel.id)/assets/$($a.id)" | Out-Null
}
curl.exe -fsS -H "Authorization: token $env:FORGE_TOKEN" -X POST "$base/$($rel.id)/assets?name=$($f.Name)" -F "attachment=@$($f.FullName);type=application/octet-stream" -o NUL -w "uploaded $($f.Name): http %{http_code}`n"
if ($LASTEXITCODE -ne 0) { throw "the upload of $($f.Name) failed" }
}
formulae:
name: Homebrew formula and Scoop manifest
needs: [version, macos, linux, windows]
if: always() && needs.version.result == 'success'
runs-on: linux
steps:
- uses: actions/checkout@v5
- name: Write and attach them
shell: bash
env:
FORGE_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
out="$RUNNER_TEMP/formulae"; mkdir -p "$out"
python3 wai/quickstart/formulae.py "$v" "$GITHUB_REF_NAME" "$FORGE_API" "$FORGE_REPO" "$PUBLIC" "$out"
bash wai/quickstart/publish.sh "$GITHUB_REF_NAME" "wai $v" "$out"/*

View file

@ -6,6 +6,25 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### The developer's path: `wai hello`, `create`, `dev` and `verify`; the floor with no system library; the examples
- **`floor`** (wai-rs feature): the mandatory floor of SPEC §5 (PNG, FLAC, zstd) decoded with no
system codec library; `wai encode` writes PNG and zstd, and a codec outside the floor is
refused naming the build that has it. **`quickstart`**: the floor, and the receipt checks
`hello` and `verify` run; the prebuilt `wai` is built with it.
- **`wai hello`** names, dispatches, rebuilds and receipts a picture made in place, the receipt
laid out as http-delivery §8 lays receipts beside an object; **`wai verify <dir>`** checks
every receipt index under a directory offline; **`wai create <dir> --template <name>`** writes
a project from one of the examples, whose files the binary holds; **`wai dev`** runs the relay,
the room service and the room page on one loopback origin, and `--check` checks each piece
through it. The project file a template writes is `wai.json`.
- **`examples/`**: video-room, watch-together, messaging, encoder-ingest, package-and-deliver,
verify-receipts, apple-app, android-app and remote-desktop, each with a test that runs it
through `wai create` and `wai dev` (`.github/workflows/wai-examples.yml`).
- **`quickstart/`**: the archive of the command line and what `wai dev` runs, its build, its
release workflow (`.github/workflows/wai-release.yml`, on a tag `wai-v<version>`) and its
installer.
### Contribution links: an SRT sender probes a stalled tail
- **[live-contribution](extensions/live-contribution.md) §4.1:** a receiver finds a gap only behind a

View file

@ -40,12 +40,22 @@ engine do not need it, which is also how the wasm32 target is built:
cargo check -p wai --no-default-features
```
With the feature `floor` alone, `wai-rs` decodes the mandatory floor (PNG, FLAC, zstd) with
no system library: PNG and FLAC in pure Rust, zstd from its reference source compiled in. The
`quickstart` feature adds `wai hello` and `wai verify`; it is how the prebuilt `wai` is built:
```sh
cargo install --locked --path wai-rs --no-default-features --features quickstart --bin wai
```
## What's here
| Path | Contents |
|------|----------|
| [`SPEC.md`](SPEC.md) | WAI v1.0 spec — envelope, manifest, capability menu |
| [`wai-rs/`](wai-rs/) | Rust reference implementation (lib + cdylib + staticlib + C FFI) |
| [`examples/`](examples/README.md) | Small runnable projects, one per template of `wai create`: a video room, watching together, messaging and a call, an encoder into a room, packaging, receipts in Rust and Python, native sinks on Apple and Android platforms, remote desktop |
| [`quickstart/`](quickstart/README.md) | The prebuilt command line's archive: what it holds, how it is built and released, and its installer |
| [`REGISTRY.md`](REGISTRY.md), [`registry/capabilities.json`](registry/capabilities.json) | The registry: every registered string with its kind, status, classes and edition, generated from the reference implementation (`wai registry export`), with its JSON Schema and the policy for registering a string |
| [`vocab/`](vocab/) | RDF vocabulary: v2 generated from the registry, v1 frozen |
| [`extensions/`](extensions/) | Sub-specifications (confidentiality, receipts, worlds, film, quantum operations, integer operators, vector graphics, …) |
@ -56,6 +66,19 @@ cargo check -p wai --no-default-features
## Quickstart
The command line, with the relay and the room service it runs locally: the prebuilt archive
([`quickstart/`](quickstart/README.md), built by `quickstart/build.sh`), or from source.
```bash
wai hello # name, dispatch, rebuild and receipt, on a sample
wai create my-room --template video-room # a project from one of the examples (examples/)
cd my-room && wai dev # the relay, the room service and your page, on 127.0.0.1:8080
wai verify ../wai-hello # the receipts hello wrote, checked offline
```
[`examples/`](examples/README.md) holds each template, with a test that runs it. The library's
own checks, from a clone:
```bash
cd wai-rs
cargo test --no-default-features --lib --test carriage # registry, envelope, every capability round-trips

26
wai/examples/README.md Normal file
View file

@ -0,0 +1,26 @@
# WAI examples
Small projects that each do one thing with WAI, and each run on your own machine with the open
reference software alone. Every one is a template of `wai create`:
```sh
wai create my-room --template video-room
cd my-room
wai dev
```
| Template | What it shows | Runs with |
|---|---|---|
| [`video-room`](video-room/README.md) | Your own page with a live room in it: camera, microphone, every object checked against its receipt | `wai dev`, a browser |
| [`watch-together`](watch-together/README.md) | Everyone in a room watching one file in sync: play, pause and seek at one instant for all | `wai dev`, a browser |
| [`messaging`](messaging/README.md) | An end-to-end encrypted conversation, and a call placed, rung and answered | `wai dev`, a browser |
| [`encoder-ingest`](encoder-ingest/README.md) | An SRT encoder's stream published into a room, each group signed at the first hop and checked at the relay and the subscriber | `cargo` |
| [`package-and-deliver`](package-and-deliver/README.md) | A title packed by policy, its package verified, its standard floor played in a page | `wai-pack`, `wai dev`, a browser |
| [`verify-receipts`](verify-receipts/README.md) | The receipts beside an object checked offline by the reference implementation (Rust) and by a second one (Python, standard library only) | `wai hello`, `cargo`, `python3` |
| [`apple-app`](apple-app/README.md) | The native sink in a SwiftUI app, and a recorded playback session replayed through it | Xcode, the `wai-sink-swift` package |
| [`android-app`](android-app/README.md) | The native sink's Kotlin API, the one an Android app's player session calls | a JDK 17, the `wai-sink-android` project |
| [`remote-desktop`](remote-desktop/README.md) | One machine's screen shared into a room, with control handed over and every input signed | `wai dev`, `wai-remote-host`, a browser |
Each example's `test/` directory holds the check that it works; it is not part of the project
`wai create` writes. `bash ../quickstart/test/run-examples.sh portable|browser|apple|android`
runs them, as `.github/workflows/wai-examples.yml` does.

3
wai/examples/android-app/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.gradle/
build/
replay/build/

View file

@ -0,0 +1,29 @@
# The native sink in Kotlin, for Android
**What it shows.** The WAI native sink's Kotlin API, the one an Android app calls. A recorded
playback session goes through the sink call by call: each playlist verified against its
receipt, each object against the hash its name states, the player's observations, its end.
The sink signs an acknowledgement of what was received and, at the end, a bundle; the program
verifies the bundle as the stream's owner does.
**Run it.** The sink is the `wai-sink-android` project of the WAI repository; build its host
library once (JDK 17 and Rust), then point this project at it:
```sh
wai create my-android --template android-app
cd <your clone>/wai/wai-sink-android && scripts/build-native.sh host
cd my-android
export WAI_SINK_ANDROID=<your clone>/wai/wai-sink-android
gradle :replay:run # Gradle 8.13, as the SDK pins it
```
**What you see.** How many playlists and objects went through the sink, and that the bundle
verifies.
**In an Android app.** Build the SDK's AAR (`scripts/gradle.sh :sink:assembleRelease`, with the
Android SDK and NDK) and give the player a `WaiPlayerSession(context, url, config,
WaiViewAttention(view))`: the same calls, on the path of the player's requests. The SDK's
README covers it.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,32 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.jvm")
application
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(project(":core"))
}
application {
mainClass.set("ReplayKt")
}
// The JNI layer over libwai, which the SDK's `scripts/build-native.sh host` builds.
tasks.named<JavaExec>("run") {
systemProperty("java.library.path", project(":core").layout.buildDirectory.dir("native").get().asFile.path)
workingDir = rootDir
args("sample-session.json")
}

View file

@ -0,0 +1,40 @@
// A recorded playback session, replayed through the WAI native sink call by call: the playlists
// with their receipts, the objects the player received, its observations, its end. The bundle
// the sink signs at the end must verify as the stream's owner verifies it.
import java.io.File
import kotlin.system.exitProcess
import wai.sink.Json
import wai.sink.WaiSink
fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
fun main(args: Array<String>) {
@Suppress("UNCHECKED_CAST")
val doc = Json.obj(File(args.firstOrNull() ?: "sample-session.json").readBytes())
@Suppress("UNCHECKED_CAST")
val objects = doc["objects"] as Map<String, String>
fun bytes(name: Any?): ByteArray? = (name as? String)?.let { hex(objects.getValue(it)) }
@Suppress("UNCHECKED_CAST")
val sink = WaiSink.open(doc["config"] as Map<String, Any?>, hex(doc["seed"] as String), (doc["start_us"] as Number).toLong())
var bundle = ByteArray(0)
var verified = 0
@Suppress("UNCHECKED_CAST")
for (d in doc["calls"] as List<Map<String, Any?>>) {
val t = { (d["t_us"] as Number).toLong() }
when (d["call"]) {
"playlist" -> { sink.playlist(d["url"] as String, bytes(d["body"])!!, bytes(d["receipt"])); verified++ }
"object" -> { @Suppress("UNCHECKED_CAST") sink.verifyObject(d["meta"] as Map<String, Any?>, bytes(d["body"])!!); verified++ }
"event" -> sink.event(d["name"] as String, t())
"observe" -> { @Suppress("UNCHECKED_CAST") sink.observe(d["obs"] as Map<String, Any?>) }
"end" -> bundle = sink.end(WaiSink.Ended.of(d["ended"] as String)!!, t())
}
}
sink.close()
@Suppress("UNCHECKED_CAST")
val out = WaiSink.verifyBundle(bundle, hex(doc["owner"] as String), doc["channels"] as List<String>)
@Suppress("UNCHECKED_CAST")
val want = doc["outcome"] as Map<String, Any?>
val ok = out["outcome"] == "accept" && out["digest"] == want["digest"]
println("replay: $verified playlists and objects through the sink; the bundle ${if (ok) "verifies" else "does NOT verify"} as its owner verifies it (${out["outcome"]}, ${out["presented_ms"]} ms presented)")
exitProcess(if (ok) 0 else 1)
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,27 @@
// The WAI native sink's Kotlin API in a program: a recorded playback session replayed through
// the sink. The sink is the wai-sink-android project of the open-standards repository; its
// platform-free half (`:core`) is built here from WAI_SINK_ANDROID, by default where it sits
// beside this example in a clone of the repository. An Android app takes the same calls from
// the project's AAR (`:sink`), whose WaiPlayerSession puts them on the player's path.
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
plugins {
id("org.jetbrains.kotlin.jvm") version "2.3.21"
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "wai-android-app"
val sdk = file(System.getenv("WAI_SINK_ANDROID") ?: "../../wai-sink-android")
include(":core")
project(":core").projectDir = sdk.resolve("core")
include(":replay")

View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# The android-app example on a JDK 17 host: `wai create` makes the project, the SDK's host
# library is built if it is not, and the replay must put the recorded session through the
# sink's Kotlin API and verify its bundle.
#
# bash wai/examples/android-app/test/android-app.test.sh
# Needs wai on PATH, a JDK 17, Rust, and network access for Gradle and its plugins the first time.
set -uo pipefail
REPO=$(cd "$(dirname "$0")/../../../.." && pwd)
SDK="$REPO/wai/wai-sink-android"
T=$(mktemp -d)
wai create "$T/app" --template android-app > /dev/null || { echo "FAIL wai create"; exit 1; }
. "$SDK/scripts/env.sh"
[ -n "${JAVA_HOME:-}" ] || { echo "FAIL no JDK 17"; exit 1; }
if ! ls "$SDK"/core/build/native/libwai_sink_jni.* > /dev/null 2>&1; then
bash "$SDK/scripts/build-native.sh" host > "$T/native.log" 2>&1 || { tail -30 "$T/native.log"; echo "FAIL the SDK's host library builds"; exit 1; }
fi
# The Gradle the SDK pins, fetched and checked by its own script.
bash "$SDK/scripts/gradle.sh" -Pwai.sink.jvmOnly=true --version > /dev/null || { echo "FAIL the pinned Gradle"; exit 1; }
G="${WAI_SINK_GRADLE_DISTS:-${GRADLE_USER_HOME:-$HOME/.gradle}/wai-sink-dists}/gradle-$WAI_SINK_GRADLE_VERSION/bin/gradle"
cd "$T/app"
s=0
WAI_SINK_ANDROID="$SDK" "$G" --no-daemon --console=plain -q :replay:run > run.log 2>&1 || s=$?
tail -3 run.log
if [ $s = 0 ] && grep -q 'the bundle verifies' run.log; then echo "ok the recorded session goes through the sink's Kotlin API, and its bundle verifies"; else echo "FAIL the replay (exit $s)"; s=1; fi
exit $s

View file

@ -0,0 +1,5 @@
{
"template": "android-app",
"title": "The native sink in Kotlin, for Android",
"summary": "The WAI native sink's Kotlin API, the one an Android app's player session calls, replaying a recorded session and verifying its bundle."
}

2
wai/examples/apple-app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.build/
.swiftpm/

View file

@ -0,0 +1,20 @@
// swift-tools-version:5.9
// A WAI native sink in an Apple app: a SwiftUI player whose requests go through the sink, and a
// headless replay of a recorded playback session through the same sink.
//
// The sink is the wai-sink-swift package of the open-standards repository, built once with its
// scripts/build-xcframework.sh. WAI_SINK_SWIFT names its directory; by default it is where it
// sits beside this example in a clone of the repository.
import Foundation
import PackageDescription
let sdk = ProcessInfo.processInfo.environment["WAI_SINK_SWIFT"] ?? "../../wai-sink-swift"
let package = Package(
name: "WaiApp",
platforms: [.macOS(.v13), .iOS(.v16)],
dependencies: [.package(path: sdk)],
targets: [
.executableTarget(name: "WaiApp", dependencies: [.product(name: "WaiSink", package: URL(fileURLWithPath: sdk).lastPathComponent)], path: "Sources/WaiApp"),
]
)

View file

@ -0,0 +1,31 @@
# A native app on Apple platforms
**What it shows.** The WAI native sink in a SwiftUI app. The platform's player plays as it
always does; the sink sits on the path of its requests, verifies each playlist against its
receipt and each object against the hash its name states, withholds what does not verify,
signs an acknowledgement of what was received, and at the end signs a bundle the stream's
owner verifies. `--replay` runs a recorded playback session through the same sink with no
window, and checks the bundle.
**Run it.** The sink is the `wai-sink-swift` package of the WAI repository; build it once
(macOS with Xcode), then point this project at it:
```sh
wai create my-app --template apple-app
cd <your clone>/wai/wai-sink-swift && WAI_SINK_SLICES=macos scripts/build-xcframework.sh
cd my-app
export WAI_SINK_SWIFT=<your clone>/wai/wai-sink-swift
swift run WaiApp --replay sample-session.json
swift run WaiApp
```
**What you see.** The replay prints how many playlists and objects went through the sink and
that the bundle verifies. The player opens a window: give it an HLS address (the
`package-and-deliver` example's, under `wai dev`, by default) and press **Play**.
**Make it yours.** `PlayerApp.swift` is the whole integration: a `WaiSink.Config`, a
`WaiPlayerSession`, and its `player` in a `VideoPlayer`. For a stream its owner seals, set
`requireSigned: true`, and nothing the owner did not sign reaches the decoder.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,50 @@
// The player: an HLS address, played by the platform's player through the WAI native sink. The
// sink verifies each playlist against its receipt and each object against the hash its name
// states, withholds what does not verify, signs an acknowledgement of everything received, and,
// when the session ends, a bundle the stream's owner verifies.
import AVKit
import SwiftUI
import WaiSink
struct PlayerApp: App {
var body: some Scene {
WindowGroup("WAI player") { PlayerView() }
}
}
struct PlayerView: View {
@State private var address = "http://127.0.0.1:8080/package/hls/classic/floor-v.m3u8"
@State private var player: AVPlayer?
@State private var session: WaiPlayerSession?
@State private var status = "Give an HLS address and press Play."
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
TextField("HLS address", text: $address).textFieldStyle(.roundedBorder)
Button("Play", action: play)
}
VideoPlayer(player: player).frame(minWidth: 480, minHeight: 270)
Text(status).font(.callout).foregroundStyle(.secondary)
}
.padding()
}
private func play() {
guard let url = URL(string: address) else { status = "Not an address."; return }
do {
// The owner is the key of the collector the bundle is meant for; this player keeps it.
let config = WaiSink.Config(channel: "my-channel", owner: String(repeating: "0", count: 64))
let s = try WaiPlayerSession(url: url, config: config, attention: WaiFixedAttention())
s.onEnd = { bundle, ended in
status = "Ended (\(ended.rawValue)): a signed bundle of \(bundle.count) bytes."
}
s.start()
session = s
player = s.player
status = "Playing through the sink."
} catch {
status = "The sink refused: \(error)"
}
}
}

View file

@ -0,0 +1,47 @@
// A recorded playback session, replayed through the sink call by call: the playlists with their
// receipts, the objects the player received, its observations, its end. The bundle the sink
// signs at the end must verify as the stream's owner verifies it, and state what was recorded.
import Foundation
import WaiSink
enum Replay {
static func run(path: String) throws -> Bool {
let doc = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path))) as! [String: Any]
let objects = doc["objects"] as! [String: String]
func bytes(_ name: Any?) -> Data? { (name as? String).map { hex(objects[$0]!) } }
let sink = try WaiSink(configJSON: doc["config"] as! [String: Any], seed: hex(doc["seed"] as! String), startUs: (doc["start_us"] as! NSNumber).uint64Value)
var bundle = Data()
var verified = 0
for d in doc["calls"] as! [[String: Any]] {
let t = { (d["t_us"] as! NSNumber).uint64Value }
switch d["call"] as! String {
case "playlist":
_ = try sink.playlist(url: d["url"] as! String, bytes: bytes(d["body"])!, receipt: bytes(d["receipt"]))
verified += 1
case "object":
_ = try sink.object(meta: d["meta"] as! [String: Any], bytes: bytes(d["body"])!)
verified += 1
case "event": try sink.event(d["name"] as! String, atUs: t())
case "observe": _ = try sink.observe(d["obs"] as! [String: Any])
case "end": bundle = try sink.end(WaiSink.Ended(rawValue: d["ended"] as! String)!, atUs: t())
default: break
}
}
let out = try WaiSink.verifyBundle(bundle, owner: hex(doc["owner"] as! String), channels: doc["channels"] as! [String])
let want = doc["outcome"] as! [String: Any]
let ok = out["outcome"] as? String == "accept" && out["digest"] as? String == want["digest"] as? String
print("replay: \(verified) playlists and objects through the sink; the bundle \(ok ? "verifies" : "does NOT verify") as its owner verifies it (\(out["outcome"] ?? "?"), \(out["presented_ms"] ?? "?") ms presented)")
return ok
}
static func hex(_ s: String) -> Data {
var d = Data(capacity: s.count / 2)
var i = s.startIndex
while i < s.endIndex {
let j = s.index(i, offsetBy: 2)
d.append(UInt8(s[i..<j], radix: 16)!)
i = j
}
return d
}
}

View file

@ -0,0 +1,13 @@
// `swift run WaiApp` opens the player; `swift run WaiApp --replay sample-session.json` replays a
// recorded session through the sink, with no window, and checks its bundle.
import Foundation
if let i = CommandLine.arguments.firstIndex(of: "--replay"), i + 1 < CommandLine.arguments.count {
do {
exit(try Replay.run(path: CommandLine.arguments[i + 1]) ? 0 : 1)
} catch {
print("replay: \(error)")
exit(2)
}
}
PlayerApp.main()

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# The apple-app example on macOS: `wai create` makes the project, the SDK's macOS slice is built
# if it is not, the app builds (the SwiftUI player and the replay), and the replay must put the
# recorded session through the sink and verify its bundle.
#
# bash wai/examples/apple-app/test/apple-app.test.sh
# Needs wai on PATH, Xcode, and Rust (for the SDK's library).
set -uo pipefail
REPO=$(cd "$(dirname "$0")/../../../.." && pwd)
SDK="$REPO/wai/wai-sink-swift"
T=$(mktemp -d)
wai create "$T/app" --template apple-app > /dev/null || { echo "FAIL wai create"; exit 1; }
if [ ! -d "$SDK/build/WaiFFI.xcframework" ]; then
(cd "$SDK" && WAI_SINK_SLICES=macos bash scripts/build-xcframework.sh > "$T/sdk.log" 2>&1) || { tail -30 "$T/sdk.log"; echo "FAIL the SDK builds"; exit 1; }
fi
cd "$T/app"
export WAI_SINK_SWIFT="$SDK"
swift build > build.log 2>&1 || { tail -40 build.log; echo "FAIL the app builds"; exit 1; }
echo "ok the app builds against the SDK: the SwiftUI player and the replay"
s=0
out=$(swift run --skip-build WaiApp --replay sample-session.json 2>&1) || s=$?
echo " $out" | tail -2
if [ $s = 0 ]; then echo "ok the recorded session goes through the sink, and its bundle verifies"; else echo "FAIL the replay (exit $s)"; fi
exit $s

View file

@ -0,0 +1,5 @@
{
"template": "apple-app",
"title": "A native app on Apple platforms",
"summary": "A SwiftUI player whose stream goes through the WAI native sink, and a recorded session replayed through it."
}

View file

@ -0,0 +1 @@
target/

View file

@ -0,0 +1,25 @@
[package]
name = "encoder-ingest"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
# A project of its own, not a member of any workspace around it.
[workspace]
[dependencies]
# The relay, the ingest gateway and their wire, from the open-standards repository.
joule-moq-ingest = { git = "https://git.transaction.science/Transaction-Science/open-standards" }
joule-moq-relay = { git = "https://git.transaction.science/Transaction-Science/open-standards" }
joule-moq = { git = "https://git.transaction.science/Transaction-Science/open-standards" }
joule-contrib = { git = "https://git.transaction.science/Transaction-Science/open-standards" }
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "io-util"] }
serde_json = "1"
hex = "0.4"
# The relay's WebTransport crate, as the repository vendors it (vendor/wtransport/VENDORED.md).
[patch.crates-io]
wtransport = { git = "https://git.transaction.science/Transaction-Science/open-standards" }

View file

@ -0,0 +1,30 @@
# Stream from an encoder into a room
**What it shows.** An encoder's stream entering a room over SRT. The room's authority grants
the encoder's key a publisher's place; an ingest gateway opens an SRT session for that grant
once the encoder's side proves it holds the key, and publishes what arrives as the room's
video and audio tracks. The gateway signs each group's receipt as the first hop; the relay
checks every object against it at its door, and a subscriber checks each one again.
**Run it.**
```sh
wai create my-ingest --template encoder-ingest
cd my-ingest
cargo run --release
```
It prints an `srt://127.0.0.1:…` address. Point an SRT encoder at it (MPEG-TS with H.264 or
HEVC video and AAC audio, caller mode, the stream ID and passphrase as given).
**What you see.** A line for the first groups of each track as they arrive, each verified
against the receipt the gateway signed. `cargo run --release -- --synthetic 5` sends five
seconds of a synthetic program itself (shaped like H.264 and AAC, not watchable) and exits 0
once every group verified.
**Make it yours.** The gateway also takes RTMP and WHIP, and serves WHEP to players; its
configuration file is `gateway.example.toml` in the repository's `joule-moq-ingest` crate. The
first build fetches the repository and compiles the relay and the gateway.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,252 @@
//! Stream from an encoder into a room over SRT, and check every object the room carries.
//!
//! cargo run --release # prints the SRT address for your encoder, then waits
//! cargo run --release -- --synthetic 5 # sends five seconds itself, checks them, and exits
//!
//! On this machine, in one process: a relay, an ingest gateway that publishes into the relay a
//! room's tracks from what an encoder sends, and a subscriber. The room's authority grants the
//! encoder's key a publisher's place; the gateway opens an SRT session for that grant when the
//! encoder's side proves it holds the key (live-rooms §10.5.3). Every group the gateway
//! publishes carries a receipt it signs; the relay's door checks each at the hop, and the
//! subscriber checks each object against its receipt again before counting it.
use ed25519_dalek::SigningKey;
use joule_moq::carriage::ReceiptBundle;
use joule_moq::draft22::{Parameters, StreamHeader, UniEvent};
use joule_moq::grant_proof;
use joule_moq::receipt::ReceiptVerifier;
use joule_moq::{GroupId, ObjectId};
use joule_moq_ingest::catalog;
use joule_moq_ingest::room::{build, media};
use joule_moq_ingest::{GatewayConfig, GatewayServer};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::transport::quic;
use joule_moq_relay::{RelayConfig, RelayServer};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
type Objects = Arc<Mutex<BTreeMap<(u64, u64, u64), Vec<u8>>>>;
fn now_s() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}
fn new_key() -> SigningKey {
SigningKey::generate(&mut rand_core_06::OsRng)
}
/// One HTTP/1.1 request on a connection of its own: the status, the headers (lowercase
/// names) and the body.
async fn http(addr: std::net::SocketAddr, path: &str, headers: &[(&str, String)]) -> Result<(u16, Vec<(String, String)>, Vec<u8>), String> {
let mut s = tokio::net::TcpStream::connect(addr).await.map_err(|e| e.to_string())?;
let mut req = format!("POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n");
for (k, v) in headers {
req += &format!("{k}: {v}\r\n");
}
s.write_all(format!("{req}\r\n{{}}").as_bytes()).await.map_err(|e| e.to_string())?;
let mut b = Vec::new();
s.read_to_end(&mut b).await.map_err(|e| e.to_string())?;
let end = b.windows(4).position(|w| w == b"\r\n\r\n").ok_or("no response")?;
let head = String::from_utf8_lossy(&b[..end]).to_string();
let status = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).ok_or("no status")?;
let hs = head.lines().skip(1).filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))).collect();
Ok((status, hs, b[end + 4..].to_vec()))
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let synthetic: Option<u64> = args.iter().position(|a| a == "--synthetic").map(|i| args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(5));
match run(synthetic).await {
Ok(true) => std::process::ExitCode::SUCCESS,
Ok(false) => std::process::ExitCode::from(1),
Err(e) => {
eprintln!("encoder-ingest: {e}");
std::process::ExitCode::from(2)
}
}
}
async fn run(synthetic: Option<u64>) -> Result<bool, String> {
// The room: its authority, the encoder's key, and the gateway's signing key.
let (authority, encoder, gateway_key) = (new_key(), new_key(), new_key());
let descriptor = build::room(&authority, now_s() * 1_000_000);
let room = descriptor.room_id();
let owner = hex::encode(encoder.verifying_key().to_bytes());
// The relay, on loopback: its door checks every object of the room against the receipts
// the gateway signs.
let mut rc = RelayConfig::loopback();
rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![hex::encode(gateway_key.verifying_key().to_bytes())], trust_field: None, hold_ms: 10_000 }];
let relay = RelayServer::start(rc).await.map_err(|e| e.to_string())?;
let relay_addr = relay.quic_addr.ok_or("the relay has no QUIC address")?;
// The gateway: it publishes into the relay what an encoder sends it over SRT.
let mut gc = GatewayConfig::loopback(relay_addr);
gc.relay.trust_der = vec![relay.identity.chain[0].to_vec()];
gc.signing.key_seed = Some(gateway_key.to_bytes());
gc.rooms = vec![joule_moq_ingest::config::RoomConfig { descriptor_path: None, revoked: Vec::new(), descriptor: Some(descriptor.to_bytes()) }];
gc.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
let gateway = GatewayServer::start(gc).await.map_err(|e| e.to_string())?;
// The encoder's grant, and an SRT session for it: the request carries the grant, and a
// proof by the encoder's key over the gateway's challenge.
let grant = build::grant(&descriptor, &authority, &encoder.verifying_key().to_bytes(), 0, now_s() - 60, now_s() + 3600);
let bearer = ("authorization", format!("Bearer {}", grant_proof::base64url(&grant.to_bytes())));
let (status, hs, _) = http(gateway.addr, "/srt", std::slice::from_ref(&bearer)).await?;
let challenge = hs.iter().find(|(k, _)| k == "wai-room-challenge").and_then(|(_, v)| grant_proof::from_base64url(v)).and_then(|b| <[u8; 32]>::try_from(b).ok());
let Some(challenge) = challenge else { return Err(format!("the gateway gave no challenge ({status})")) };
let context = grant_proof::context("POST", &gateway.addr.to_string(), "/srt");
let proof = grant_proof::GrantProof::sign(grant.digest(), &encoder, challenge, &context).ok_or("no proof")?;
let (status, _, body) = http(gateway.addr, "/srt", &[bearer, ("wai-room-proof", grant_proof::base64url(&proof.to_bytes()))]).await?;
if status != 201 {
return Err(format!("POST /srt: {status} {}", String::from_utf8_lossy(&body)));
}
let s: serde_json::Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
let srt_addr = gateway.srt_addr.ok_or("the gateway has no SRT listener")?;
let url = format!(
"srt://{srt_addr}?streamid={}&passphrase={}&pbkeylen={}&latency={}",
s["stream_id"].as_str().unwrap_or(""),
s["passphrase"].as_str().unwrap_or(""),
s["pbkeylen"].as_u64().unwrap_or(16),
s["latency_ms"].as_u64().unwrap_or(120)
);
println!("room {}…, encoder key {}…", &hex::encode(room)[..16], &owner[..16]);
println!("send MPEG-TS (H.264 or HEVC video, AAC audio) over SRT to:\n {url}");
// The synthetic encoder calls first: the gateway publishes the room's tracks once the call
// is admitted. A real encoder calls in its own time, and the subscriber waits for it.
let link = match synthetic {
Some(_) => {
let u = joule_contrib::cli::Url::parse(&url)?;
Some(joule_contrib::cli::srt_link(&u, joule_contrib::srt::Role::Send).await?)
}
None => {
println!("waiting for the encoder…");
None
}
};
// The subscriber: the encoder's video and audio tracks and their receipts tracks, once the
// gateway publishes them.
let ep = quic::client_endpoint("127.0.0.1:0".parse().unwrap(), vec![relay.identity.chain[0].clone()], vec![joule_moq::draft22::ALPN.to_vec()], None).map_err(|e| e.to_string())?;
let conn = quic::connect(&ep, relay_addr, "localhost").await.map_err(|e| e.to_string())?;
let client = Arc::new(Client::connect(Arc::new(conn), ClientOptions::default()).await?);
let mut tracks = Vec::new();
let mut held = Vec::new();
let wait_until = tokio::time::Instant::now() + Duration::from_secs(30);
// A track the gateway does not publish yet is refused DOES_NOT_EXIST: ask again until it is.
let subscribe = |ns: joule_moq::TrackNamespace, track: joule_moq::TrackName| {
let client = client.clone();
async move {
loop {
match client.subscribe(ns.clone(), track.clone(), Parameters::new()).await {
Ok(x) => return Ok(x),
Err(e) if e.contains("DOES_NOT_EXIST") && (synthetic.is_none() || tokio::time::Instant::now() < wait_until) => tokio::time::sleep(Duration::from_millis(250)).await,
Err(e) => return Err(e),
}
}
}
};
for (kind, name) in [(media::VIDEO, "video"), (media::AUDIO, "audio")] {
let ns = catalog::media_namespace(&room, &owner, kind);
let (m, mtx, mrx) = subscribe(ns.clone(), joule_moq::TrackName::from_str("0")).await?;
let (r, rtx, rrx) = subscribe(ns, catalog::receipts_name("0")).await?;
tracks.push((name, m.track_alias, r.track_alias));
held.push((mtx, rtx, mrx, rrx));
}
let objects: Objects = Arc::default();
{
let (c, o) = (client.clone(), objects.clone());
tokio::spawn(async move {
loop {
let Some(mut r) = c.next_stream(Duration::from_secs(1)).await else { continue };
let o = o.clone();
tokio::spawn(async move {
let Ok(StreamHeader::Subgroup(sh)) = r.start().await else { return };
while let Ok(Some(e)) = r.next().await {
if let UniEvent::SubgroupObject(x) = e
&& !x.payload.is_empty()
{
o.lock().unwrap().insert((sh.track_alias, sh.group_id, x.object_id), x.payload.to_vec());
}
}
});
}
});
}
// What the synthetic encoder sends: a program shaped like H.264 and AAC (not decodable
// media), paced as a live encoder sends it.
let sender = link.map(|link| {
let secs = synthetic.unwrap_or(5);
tokio::spawn(async move {
let r = joule_contrib::cli::pace(&link, &joule_contrib::cli::synthetic(secs, 2000), 1500).await;
link.close();
r
})
});
// Check each group as its object and its receipts arrive.
let v = ReceiptVerifier::with_anchors(vec![gateway_key.verifying_key()]);
let mut done: BTreeSet<(u64, u64)> = BTreeSet::new();
let mut counts: BTreeMap<&str, (u64, u64)> = BTreeMap::new();
let deadline = synthetic.map(|s| tokio::time::Instant::now() + Duration::from_secs(s + 15));
let mut sent = false;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
let objs = objects.lock().unwrap().clone();
for (name, media_alias, receipts_alias) in &tracks {
for ((alias, g, o), payload) in objs.iter().filter(|((a, _, _), _)| a == media_alias) {
if done.contains(&(*alias, *g)) {
continue;
}
let Some(bundle) = objs.iter().find(|((a, rg, _), _)| a == receipts_alias && rg == g).map(|(_, b)| b) else { continue };
done.insert((*alias, *g));
let c = counts.entry(name).or_default();
let ok = ReceiptBundle::from_payload(bundle).ok().and_then(|b| {
let vg = v.verify_group(&b.group).ok()?;
let r = b.objects.get(*o as usize)?;
r.check_moqt(b.group.track_alias, GroupId(*g), ObjectId(*o)).ok()?;
v.verify_payload(payload, r, &vg).ok()
});
if ok.is_some() {
c.0 += 1;
if c.0 <= 3 || c.0.is_multiple_of(30) {
println!("{name} group {g}: {} bytes, verified against the receipt the gateway signed", payload.len());
}
} else {
c.1 += 1;
println!("{name} group {g}: REFUSED, it does not verify against its receipt");
}
}
}
if let Some(s) = &sender
&& s.is_finished()
&& !sent
{
sent = true;
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
if sent || deadline.is_some_and(|d| tokio::time::Instant::now() > d) {
break;
}
}
if let Some(s) = sender {
match s.await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(format!("the synthetic encoder: {e}")),
Err(e) => return Err(e.to_string()),
}
}
let (v_ok, v_bad) = counts.get("video").copied().unwrap_or_default();
let (a_ok, a_bad) = counts.get("audio").copied().unwrap_or_default();
println!("verified {v_ok} video and {a_ok} audio groups; refused {}", v_bad + a_bad);
drop(held);
gateway.shutdown().await;
relay.shutdown().await;
Ok(v_ok > 0 && a_ok > 0 && v_bad + a_bad == 0)
}

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# The encoder-ingest example: `wai create` makes the project, its git dependencies are pointed
# at this checkout, and `--synthetic 4` sends four seconds over SRT into the room; every group
# of both tracks must verify against the gateway's receipts at the subscriber (exit 0).
#
# bash wai/examples/encoder-ingest/test/encoder-ingest.test.sh
# Needs wai on PATH and cargo.
set -uo pipefail
REPO=$(cd "$(dirname "$0")/../../../.." && pwd)
T=$(mktemp -d)
wai create "$T/ingest" --template encoder-ingest > /dev/null || { echo "FAIL wai create"; exit 1; }
cd "$T/ingest"
python3 - "$REPO" Cargo.toml <<'PY'
import sys
repo, p = sys.argv[1], sys.argv[2]
crates = {"joule-moq-ingest": "joule-code/joule-code-rs/crates/joule-moq-ingest", "joule-moq-relay": "joule-code/joule-code-rs/crates/joule-moq-relay",
"joule-moq": "joule-code/joule-code-rs/crates/joule-moq", "joule-contrib": "joule-code/joule-code-rs/crates/joule-contrib",
"wtransport": "joule-code/joule-code-rs/vendor/wtransport"}
git = 'git = "https://git.transaction.science/Transaction-Science/open-standards"'
out = []
for line in open(p).read().splitlines():
name = line.split("=")[0].strip()
if name in crates and git in line:
line = line.replace(git, f'path = "{repo}/{crates[name]}"')
out.append(line)
open(p, "w").write("\n".join(out) + "\n")
assert git not in open(p).read(), "every git dependency is pointed at the checkout"
PY
cargo build --release --quiet 2> build.log || { tail -30 build.log; echo "FAIL the example builds"; exit 1; }
s=0
timeout 120 "${CARGO_TARGET_DIR:-$PWD/target}/release/encoder-ingest" --synthetic 4 > run.log 2>&1 || s=$?
cat run.log
if [ $s = 0 ]; then echo "ok every group sent over SRT verified against the gateway's receipts at the subscriber"; else echo "FAIL the synthetic encoder's groups (exit $s)"; fi
exit $s

View file

@ -0,0 +1,5 @@
{
"template": "encoder-ingest",
"title": "Stream from an encoder into a room",
"summary": "An SRT encoder's stream published into a room by an ingest gateway, every group signed at the first hop and checked at the relay and the subscriber."
}

1
wai/examples/messaging/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.wai/

View file

@ -0,0 +1,31 @@
# Messaging and a call
**What it shows.** A conversation and a call, side by side. In the conversation each member
makes its own key in its tab, keys into one end-to-end encrypted group through the service's
log, seals each message before it leaves the tab, and opens and verifies each one it reads:
the service stores ciphertext and signed records, never the text. A call is placed with a
proof of the caller's key; the invitation rings in the callee's inbox, checked before it
rings; answered, both enter a room with the grants the call came with. One that rings out is
signed as missed by the room's clock.
**Run it.**
```sh
wai create my-chat --template messaging
cd my-chat
wai dev
```
Open `http://127.0.0.1:8080/` in two browsers (two tabs of one browser share nothing, so
that works too).
**What you see.** On the left, join the conversation in each and write: each side reads the
other's messages, with threads, reactions, edits and retractions. On the right, paste one
tab's key (under **Your key**) into the other's **Keys to call** and press **Call**: the
first tab's inbox rings, and answering opens the call, camera and microphone on both sides.
**Make it yours.** The conversation's API is under `/msg-api/` and the call's under
`/room-api/`; both pages take `?service=` to point at them.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Messages and calls</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; background: #0f1115; color: #e8eaf0; }
h1 { font-size: 20px; margin: 0 0 12px; }
.panes { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
iframe { width: 100%; height: 75vh; border: 1px solid #2a2f3a; border-radius: 8px; background: #000; }
</style>
</head>
<body>
<h1>Messages and calls</h1>
<div class="panes">
<!-- The conversation (live-messaging): each member keys into one MLS group through the
service's log, and every message is sealed in the tab before it is sent. -->
<iframe title="Messages" src="/room/messages.html?service=/msg-api"></iframe>
<!-- A call: placed with a proof of this tab's key, rung in the other's inbox, answered in a room. -->
<iframe title="Calls" src="/room/call.html?service=/room-api" allow="camera; microphone; autoplay"></iframe>
</div>
</body>
</html>

View file

@ -0,0 +1,62 @@
// The messaging example, as a person runs it: `wai create` makes the project, `wai dev` runs a
// conversation beside a call room, and two browsers open its page. Both join the conversation
// and each opens what the other sealed; then one calls the other, the callee's inbox rings,
// it answers, and each receives and decodes the other's camera and microphone.
//
// node wai/examples/messaging/test/messaging.test.mjs
import { check, create, dev, dump, finish, frame, launch, open, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
const out = tmpdir("messaging");
const dir = await create("messaging", out);
const d = await dev(dir);
const browser = await launch();
const pages = [];
try {
const people = [];
for (const name of ["alice", "bob"]) {
const p = await open(browser, `${d.url}/`, name);
pages.push(p);
const m = await frame(p, "/room/messages.html");
await until(`${name}: the messages page ready`, STEP, () => m.evaluate(() => !!(window.msgTest && window.msgTest.join)), 200);
const phase = await m.evaluate(() => window.msgTest.join());
if (phase !== "member") throw new Error(`${name} did not join: ${phase}`);
const c = await frame(p, "/room/call.html");
await until(`${name}: the call page ready`, STEP, () => c.evaluate(() => window.callTest && window.callTest.state.phase === "ready"), 200);
people.push({ p, m, c, key: await c.evaluate(() => window.callTest.state.me) });
}
const [A, B] = people;
await until("both keyed into one group of two", STEP, async () => {
const s = await Promise.all([A, B].map((x) => x.m.evaluate(() => window.msgTest.state)));
return s.every((x) => x.members.length === 2 && x.epoch && x.epoch.epoch === s[0].epoch.epoch);
}, 300);
await A.m.evaluate(() => window.msgTest.send("hello from alice"));
await B.m.evaluate(() => window.msgTest.send("hello from bob"));
const texts = (v) => v.messages.filter((m) => m.outcome === "accepted").map((m) => m.text).sort().join("|");
for (const x of [A, B]) {
const got = await until(`${x.p.name} opens both messages`, STEP, async () => { const t = texts(await x.m.evaluate(() => window.msgTest.view())); return t === "hello from alice|hello from bob" && t; }, 300);
check(`${x.p.name}'s page opens and verifies what each member sealed`, true, got);
}
// Alice calls Bob; his inbox rings; he answers.
await A.c.evaluate((k) => { window.callTest.place([k]).catch((e) => { window.callTest.state.error = String(e); }); }, B.key);
const ringing = await until("bob's inbox rings", STEP, async () => { const s = await B.c.evaluate(() => window.callTest.state); return s.inbox.find((x) => x.state === "ringing") && s; }, 300);
check("the callee's inbox holds the invitation, checked with its call before it rings", ringing.inbox[0].check.ok && ringing.inbox[0].check.caller === A.key);
await B.c.evaluate(() => { window.callTest.accept().catch((e) => { window.callTest.state.error = String(e); }); });
const room = (x) => frame(x.p, "/room/index.html");
const principal = async (x) => (await room(x)).evaluate(() => window.roomTest.snapshot().me.principal);
for (const x of [A, B]) {
await until(`${x.p.name}'s call publishes`, STEP * 2, async () => (await (await room(x)).evaluate(() => window.roomTest && window.roomTest.state.phase)) === "publishing", 500);
}
const [pa, pb] = [await principal(A), await principal(B)];
for (const [x, other] of [[A, pb], [B, pa]]) {
const t = await until(`${x.p.name} decodes the other's call media`, STEP * 2, async () => { const s = await (await room(x)).evaluate(() => window.roomTest.snapshot()); const t = s.subs[other]; return t && t.video.decoded >= 20 && t.audio.decoded >= 30 && t; }, 500);
check(`${x.p.name}'s call carries the other's camera and microphone, decoded`, true, `${t.video.decoded} video frames, ${t.audio.decoded} audio frames`);
}
check("caller and callee publish under the grants their call came with", pa === A.key && pb === B.key);
} catch (e) {
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
} finally {
dump(pages, out);
await browser.close().catch(() => {});
await d.stop();
}
finish("messaging");

View file

@ -0,0 +1,6 @@
{
"template": "messaging",
"title": "Messaging and a call",
"summary": "An end-to-end encrypted conversation, and a call placed, rung and answered in a room.",
"dev": { "site": ".", "mode": "call", "messaging": true }
}

View file

@ -0,0 +1,3 @@
.wai/
video-base-01.y4m
package/

View file

@ -0,0 +1,29 @@
# Package a title and play it
**What it shows.** A title packed by policy: a WAI base of `wai.video.int_motion` objects and,
beside it, a floor in a standard codec (AV1 in CMAF) that any player plays, with HLS and DASH
playlists and a MoQ catalog. The whole package is sealed in a signed receipt, and verified:
every object, every playlist, the receipt. Then the page plays the floor with the browser's
own decoder.
**Run it.**
```sh
wai create my-title --template package-and-deliver
cd my-title
sh pack.sh
wai dev
```
Open `http://127.0.0.1:8080/`.
**What you see.** `pack.sh` prints what it packed and `wai-pack verify` checks it. The page
plays the small test clip from `package/hls/classic/floor-v.m3u8`, segment by segment.
**Make it yours.** `policy.toml` names the source, the ladder and the classes of sink. Point
`source` at your own clip (Y4M video, WAV audio) and supply its AV1 floor in `floor/`; sign
with a key of your own, not `test-key.hex`, which is a published test key. `wai-pack` alone
prints its commands, live packaging among them.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

Binary file not shown.

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A packed title</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; background: #0f1115; color: #e8eaf0; }
h1 { font-size: 20px; margin: 0 0 8px; }
video { width: 100%; max-width: 640px; image-rendering: pixelated; background: #000; border-radius: 8px; }
#state { color: #a8afc0; }
</style>
</head>
<body>
<h1>A packed title</h1>
<p id="state" aria-live="polite">Run <code>sh pack.sh</code> first, then reload.</p>
<video id="v" controls muted loop playsinline></video>
<script type="module">
import { play } from "./player.js";
const v = document.getElementById("v");
const state = document.getElementById("state");
play(v, "package/hls/classic/floor-v.m3u8", (s) => (state.textContent = s))
.then((r) => { state.textContent = `The floor (${r.codecs}), ${r.segments} segments, from package/hls/classic/.`; window.played = r; return v.play(); })
.catch((e) => { state.textContent = `${e.message}. Run sh pack.sh first, then reload.`; window.playError = String(e); });
</script>
</body>
</html>

View file

@ -0,0 +1,16 @@
#!/bin/sh
# Package the sample title and check the package:
# 1. the title: a 64x36 test clip, made by wai-pack itself (deterministic);
# 2. pack it by policy.toml: a WAI base (wai.video.int_motion objects) and the AV1 floor
# supplied in floor/, as CMAF with HLS and DASH playlists and a MoQ catalog, the whole
# sealed in package/package.receipt.json with the key in test-key.hex;
# 3. verify the package: every object, every playlist and the receipt.
# test-key.hex is a published test key (32 bytes of 0x5a), so the receipt proves nothing about
# who packed it; for your own titles, use a key of your own (64 hexadecimal digits, kept secret).
set -eu
cd "$(dirname "$0")"
wai-pack fixture gen video-base-01 -o video-base-01.y4m
rm -rf package
wai-pack pack --policy policy.toml --out package --key test-key.hex
wai-pack verify package
echo "packed: open the page wai dev serves to play it"

View file

@ -0,0 +1,30 @@
// Play a packed title's floor in a <video> element with Media Source Extensions: read the HLS
// media playlist wai-pack wrote, then append its initialization segment and each CMAF segment
// to one SourceBuffer. No player library: a VOD playlist of one rendition needs no more.
export async function play(video, playlistUrl, onState = () => {}) {
const base = new URL(playlistUrl, location.href);
const text = await (await fetch(base, { cache: "no-store" })).text();
const lines = text.split("\n").map((l) => l.trim());
const map = lines.find((l) => l.startsWith("#EXT-X-MAP:"));
const init = map && map.match(/URI="([^"]+)"/)[1];
const segments = lines.filter((l) => l && !l.startsWith("#"));
if (!init || !segments.length) throw new Error("the playlist names no initialization segment or no segment");
// The codec the multivariant playlist states for this rendition.
const master = await (await fetch(new URL("master.m3u8", base), { cache: "no-store" })).text();
const codecs = (master.match(/CODECS="([^"]+)"/) || [])[1] || "av01.0.00M.08";
const type = `video/mp4; codecs="${codecs}"`;
if (!window.MediaSource || !MediaSource.isTypeSupported(type)) throw new Error(`this browser does not play ${type}`);
const ms = new MediaSource();
video.src = URL.createObjectURL(ms);
await new Promise((r) => ms.addEventListener("sourceopen", r, { once: true }));
const sb = ms.addSourceBuffer(type);
const append = (b) => new Promise((ok, no) => { sb.addEventListener("updateend", ok, { once: true }); sb.addEventListener("error", no, { once: true }); sb.appendBuffer(b); });
const get = async (u) => new Uint8Array(await (await fetch(new URL(u, base))).arrayBuffer());
await append(await get(init));
for (const [i, s] of segments.entries()) {
await append(await get(s));
onState(`segment ${i + 1} of ${segments.length} appended`);
}
ms.endOfStream();
return { codecs, segments: segments.length };
}

View file

@ -0,0 +1,47 @@
schema = "wai.pack.policy/1"
asset_id = "video-base-01"
royalty_free_only = true
lead = "v"
[[component]]
id = "v"
media = "video"
source = "video-base-01.y4m"
[component.timebase]
num = 25
den = 1
[segmentation]
target_ms = 2000
min_ms = 1000
max_ms = 4000
[[ladder]]
id = "b0"
component = "v"
capability = "wai.video.int_motion"
role = "base"
params = { block = 16, search = 4, mv_precision = 1, q_candidates = [16, 12, 8], max_sse_px_milli = 30000 }
[[ladder]]
id = "f0"
component = "v"
capability = "wai.video.av1"
role = "floor"
params = { source = "supplied" }
[floor]
supplied_listing = "6b5ed967f771b936390b4101743a6f6fec8bf0fa2abd1165add3e750b0b86dab"
[[class]]
id = "tv"
max_layer = 0
[[class]]
id = "mobile"
max_kbps = 100
[[class]]
id = "legacy"
floor_only = true

View file

@ -0,0 +1 @@
5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a

View file

@ -0,0 +1,32 @@
// The package-and-deliver example in a browser: `wai create` makes the project, pack.sh packs
// and verifies the sample title, `wai dev` serves the page, and the page must play the floor:
// every segment appended, and frames decoded by the browser's own decoder.
//
// node wai/examples/package-and-deliver/test/package-and-deliver.test.mjs
import { check, create, dev, dump, finish, launch, open, run, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
const out = tmpdir("package-and-deliver");
const dir = await create("package-and-deliver", out);
await run(["sh", "pack.sh"], { cwd: dir });
const d = await dev(dir);
const browser = await launch();
const pages = [];
try {
const p = await open(browser, `${d.url}/`, "viewer");
pages.push(p);
const r = await until("the page appends every segment", STEP, async () => {
const e = await p.evaluate(() => window.playError);
if (e) throw new Error(e);
return p.evaluate(() => window.played);
}, 200);
check("the page reads the floor playlist and appends every segment", r.segments > 0, `${r.segments} segments of ${r.codecs}`);
const q = await until("frames decoded", STEP, () => p.evaluate(() => { const v = document.getElementById("v"); const x = v.getVideoPlaybackQuality(); return x.totalVideoFrames >= 10 && v.currentTime > 0.2 && { frames: x.totalVideoFrames, t: v.currentTime, w: v.videoWidth, h: v.videoHeight }; }), 200);
check("the browser decodes and plays the floor", q.w === 64 && q.h === 36, `${q.frames} frames at ${q.w}×${q.h}, at ${q.t.toFixed(2)} s`);
} catch (e) {
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
} finally {
dump(pages, out);
await browser.close().catch(() => {});
await d.stop();
}
finish("package-and-deliver");

View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# The package-and-deliver example with no browser: `wai create` makes the project and pack.sh
# packs the sample title and verifies it. The package must hold the WAI base's objects, its
# receipt, and an HLS floor playlist every segment of which is in the package.
#
# bash wai/examples/package-and-deliver/test/package-and-deliver.test.sh
# Needs wai and wai-pack on PATH.
# No -e: each check records its own result, and the run goes on to the next.
set -uo pipefail
T=$(mktemp -d)
fails=0
check() { if [ "$2" = 0 ]; then echo "ok $1${3:+ ($3)}"; else echo "FAIL $1${3:+ ($3)}"; fails=$((fails + 1)); fi; }
wai create "$T/title" --template package-and-deliver > /dev/null || { echo "FAIL wai create"; exit 1; }
cd "$T/title"
ps=0; sh pack.sh > pack.log 2>&1 || ps=$?
check "pack.sh packs the title and wai-pack verify accepts the package" $ps "$(tail -2 pack.log | head -1)"
n=$(ls package/objects/*.wai 2>/dev/null | wc -l | tr -d ' ')
[ "$n" -gt 0 ]; check "the package holds the WAI base's objects" $? "$n objects"
python3 -c 'import json,sys; r=json.load(open("package/package.receipt.json")); sys.exit(0 if r else 1)'; check "the package carries its receipt" $?
missing=0
pl=package/hls/classic/floor-v.m3u8
for s in $(grep -v '^#' "$pl") $(sed -n 's/.*URI="\([^"]*\)".*/\1/p' "$pl"); do
[ -f "package/hls/classic/$s" ] || missing=$((missing + 1))
done
segs=$(grep -vc '^#' "$pl")
[ "$missing" = 0 ] && [ "$segs" -gt 0 ]; check "every segment the floor playlist names is in the package" $? "$segs segments"
echo "package-and-deliver: $([ $fails = 0 ] && echo all checks held || echo "$fails check(s) failed")"
exit $fails

View file

@ -0,0 +1,6 @@
{
"template": "package-and-deliver",
"title": "Package a title and play it",
"summary": "A title packed as WAI objects with a standard floor, its package receipt verified, and the floor played in a page.",
"dev": { "site": "." }
}

View file

@ -0,0 +1 @@
.wai/

View file

@ -0,0 +1,31 @@
# Remote desktop in a room
**What it shows.** One machine's screen shared into a room. The host enters with an
invitation the room's authority signed; viewers make their own keys in their browsers and
watch; one viewer at a time may be handed control. Every screen statement, every input and
every handover is a signed record in the room's log, and a verifier checks the session from
those records alone. Keys and text a controller types go sealed to the host: the room never
holds them in the clear.
**Run it.**
```sh
wai create my-desk --template remote-desktop
cd my-desk
wai dev # the room, in remote desktop mode
sh host.sh --pattern # in a second terminal: the host, sharing a test pattern
```
Open `http://127.0.0.1:8080/` and press **Join**. `sh host.sh` without `--pattern` shares the
display itself.
**What you see.** The host's screen in the page, each frame checked against its receipt
before it is drawn. Press **Ask for control**: this host script hands control to whoever
asks, and your pointer and keys then reach the host, the page showing what each did.
**Check a session.** `sh host.sh --pattern --seconds 20 --record session.json` writes the
session's records; the independent verifier in the WAI repository checks them:
`python3 -I wai/live-conformance/remote_desktop_verify.py --room session.json`.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,22 @@
#!/bin/sh
# Share this machine's screen into the room `wai dev` runs here, as its host.
#
# sh host.sh the display (the system asks once for permission to record it)
# sh host.sh --pattern a drawn test pattern instead: needs no permission and no display
# sh host.sh ... --seconds 30 --record session.json any other wai-remote-host option
#
# The host's key is made once, in host.key (mode 0600). The invitation is signed by the room's
# authority from `wai dev`'s keys, and binds the room to the first key that presents it. This
# host hands control to whoever asks for it (--give-on-request), as a demonstration does.
set -eu
ORIGIN=${WAI_DEV_ORIGIN:-http://127.0.0.1:8080}
[ -f host.key ] || wai-remote-host key host.key > /dev/null
INVITATION=$(joule-moq-room invite .wai/dev/room-keys.toml 3600)
SOURCE=display
if [ "${1:-}" = "--pattern" ]; then
SOURCE="pattern --encoder ipcm"
shift
fi
# shellcheck disable=SC2086
exec wai-remote-host run --room "$ORIGIN/room-api" --key host.key --invitation "$INVITATION" \
--relay "ws://${ORIGIN#http://}/moq" --give-on-request --source $SOURCE "$@"

View file

@ -0,0 +1,40 @@
// The remote-desktop example in a browser: `wai create` makes the project, `wai dev` runs the
// room in remote desktop mode, host.sh shares a drawn pattern, and a browser joins. Its page
// must decode the host's screen, every frame verified first, and hold a controller's grant;
// asking for control, it must be given the floor and see its pointer applied by the host.
//
// node wai/examples/remote-desktop/test/remote-desktop.test.mjs
import { spawn } from "node:child_process";
import { check, create, dev, dump, finish, launch, open, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
const out = tmpdir("remote-desktop");
const dir = await create("remote-desktop", out);
const d = await dev(dir);
const host = spawn("sh", ["host.sh", "--pattern", "--seconds", "240"], { cwd: dir, env: { ...process.env, WAI_DEV_ORIGIN: d.url }, stdio: "ignore" });
const browser = await launch();
const pages = [];
const remoteOf = (p) => p.evaluate(() => { const r = window.roomTest.remote; return r && { screen: r.screen && { width: r.screen.width, state: r.screen.state }, holder: r.holder, frames: r.frames.size, counts: r.counts, me: r.me, joined: r.joined, control: r.control }; });
try {
await until("the host's screen reaches the room", STEP, async () => { const v = await (await fetch(`${d.url}/room-api/room`)).json(); return v.remote && v.remote.screen; }, 250);
const p = await open(browser, `${d.url}/`, "alice");
pages.push(p);
await until("the room page ready", STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await p.click("#join");
const seen = await until("alice decodes the host's screen", STEP, async () => { const r = await remoteOf(p); return r && r.joined && r.screen && r.frames >= 5 && r; }, 250);
check("the page decodes the host's screen, each frame verified against its receipt first", seen.frames >= 5, `${seen.frames} frames, ${seen.screen.width} wide`);
check("the page made its key and holds a controller's grant the room service issued for it", seen.control > 0, `control ${seen.control}`);
await p.click("#remote-request");
await until("the host gives alice the floor", STEP, async () => { const r = await remoteOf(p); return r.holder === r.me; }, 200);
const box = await p.locator("#remote-canvas").boundingBox();
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
const done = await until("alice's click applied by the host", STEP, async () => { const r = await remoteOf(p); return r.counts.applied >= 1 && r; }, 250);
check("with the floor, the page's input reaches the host and is applied", done.counts.applied >= 1, `${done.counts.sent} sent, ${done.counts.applied} applied`);
} catch (e) {
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
} finally {
dump(pages, out);
await browser.close().catch(() => {});
host.kill("SIGINT");
await d.stop();
}
finish("remote-desktop");

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# The remote-desktop example, with no browser and no screen: `wai create` makes the project,
# `wai dev` runs the room in remote desktop mode, and host.sh shares a drawn pattern into it for
# a few seconds, recording the session. The room service must hold the host's screen, and the
# repository's independent verifier must accept the recorded session.
#
# bash wai/examples/remote-desktop/test/remote-desktop.test.sh
# Needs wai, joule-moq-relay, joule-moq-room and wai-remote-host on PATH (or WAI_RELAY_BIN,
# WAI_ROOM_BIN, WAI_WEB as `wai dev` takes them), and python3.
# No -e: each check records its own result, and the run goes on to the next.
set -uo pipefail
REPO=$(cd "$(dirname "$0")/../../../.." && pwd)
T=$(mktemp -d)
fails=0
check() { if [ "$2" = 0 ]; then echo "ok $1${3:+ ($3)}"; else echo "FAIL $1${3:+ ($3)}"; fails=$((fails + 1)); fi; }
wai create "$T/desk" --template remote-desktop > /dev/null || { echo "FAIL wai create"; exit 1; }
cd "$T/desk"
set -m
wai dev --port 0 --for 300 > dev.log 2>&1 &
DEV=$!
set +m
trap 'kill -INT -- -$DEV 2>/dev/null || true; sleep 2; kill -KILL -- -$DEV 2>/dev/null || true' EXIT
for _ in $(seq 1 300); do grep -q 'ready on' dev.log && break; sleep 0.2; done
ORIGIN=$(sed -n 's/^wai dev: ready on \(http:[^ ]*\).*/\1/p' dev.log)
[ -n "$ORIGIN" ] || { cat dev.log; echo "FAIL wai dev did not start"; exit 1; }
WAI_DEV_ORIGIN=$ORIGIN sh host.sh --pattern --seconds 8 --record session.json > host.log 2>&1 &
HOST=$!
screen=1
for _ in $(seq 1 60); do
if curl -fsS "$ORIGIN/room-api/room" | python3 -c 'import json,sys; v=json.load(sys.stdin); sys.exit(0 if (v.get("remote") or {}).get("screen") else 1)' 2>/dev/null; then screen=0; break; fi
sleep 0.25
done
check "the room service holds the host's screen statement" $screen
hs=0; wait $HOST || hs=$?
check "the host shared its screen for its seconds and left" $hs "$(tail -1 host.log)"
vs=0
python3 -I "$REPO/wai/live-conformance/remote_desktop_verify.py" --room session.json > verdict.json || vs=$?
python3 -c 'import json,sys; v=json.load(open(sys.argv[1])); print(" verifier:", json.dumps({k: v[k] for k in list(v)[:6]})[:300]); sys.exit(0 if v.get("outcome") == "accept" and v.get("screens") else 1)' verdict.json || vs=1
check "the independent verifier accepts the session the host recorded" $vs
echo "remote-desktop: $([ $fails = 0 ] && echo all checks held || echo "$fails check(s) failed")"
exit $fails

View file

@ -0,0 +1,6 @@
{
"template": "remote-desktop",
"title": "Remote desktop in a room",
"summary": "One machine's screen shared into a room; viewers watch, a controller drives it, every input signed.",
"dev": { "mode": "remote" }
}

View file

@ -0,0 +1,3 @@
.wai/
rust/target/
receipts/

View file

@ -0,0 +1,29 @@
# Verify receipts in Rust and in Python
**What it shows.** WAI receipts checked offline, twice: once by the reference implementation
(`rust/`, on the `wai` crate) and once by a second implementation in plain Python
(`python/`, standard library only, sharing no code with the first). A receipt index sits
beside each object; it names the object's group receipt, which is signed with Ed25519 and
holds the Merkle root the object's BLAKE3 content hash reaches.
**Run it.**
```sh
wai create my-check --template verify-receipts
cd my-check
wai hello receipts # writes an object and its receipts in receipts/
python3 python/verify_receipts.py receipts
cd rust && cargo run --release -- ../receipts # builds the wai crate from the repository
```
**What you see.** Each verifier prints `ok` for the object, the group that signed it and the
key that signed the group. Change one byte of `receipts/*.wai`, or of a file in
`receipts/rcpt/`, and each prints `FAIL` with the reason.
**Make it yours.** Point either verifier at any directory laid out the same way: an object
`<cid>.wai`, its index `<cid>.wai.receipts`, and the group receipts under `rcpt/`, as WAI's
HTTP delivery serves them beside an object. `wai verify <dir>` runs the Rust checks from the
command line.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,113 @@
"""BLAKE3 in hash mode, written from its specification in pure Python.
Shared by the claim-conformance verifier (`claim_verify.py`), the staged-delivery
verifier (`../staged-conformance/staged_verify.py`) and the HTTP delivery verifier
(`../http-conformance/http_verify.py`), which import from here. It shares no code with
wai-rs, which computes the same hashes with the `blake3` crate.
`subtree_cv` and `parent_cv` expose the tree (the BLAKE3 specification, §2.1): the
non-root chaining value of a subtree that starts at a given byte offset of the whole
input, and a parent node with or without the ROOT flag. The HTTP head proof is built of
these (http-delivery §6).
"""
_IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
_PERM = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]
_CHUNK_START, _CHUNK_END, _PARENT, _ROOT = 1, 2, 4, 8
_M32 = 0xFFFFFFFF
def _rotr(x, n):
return ((x >> n) | (x << (32 - n))) & _M32
def _g(s, a, b, c, d, x, y):
s[a] = (s[a] + s[b] + x) & _M32
s[d] = _rotr(s[d] ^ s[a], 16)
s[c] = (s[c] + s[d]) & _M32
s[b] = _rotr(s[b] ^ s[c], 12)
s[a] = (s[a] + s[b] + y) & _M32
s[d] = _rotr(s[d] ^ s[a], 8)
s[c] = (s[c] + s[d]) & _M32
s[b] = _rotr(s[b] ^ s[c], 7)
def _compress(cv, m, counter, blen, flags):
s = list(cv) + _IV[:4] + [counter & _M32, (counter >> 32) & _M32, blen, flags]
m = list(m)
for r in range(7):
_g(s, 0, 4, 8, 12, m[0], m[1]); _g(s, 1, 5, 9, 13, m[2], m[3])
_g(s, 2, 6, 10, 14, m[4], m[5]); _g(s, 3, 7, 11, 15, m[6], m[7])
_g(s, 0, 5, 10, 15, m[8], m[9]); _g(s, 1, 6, 11, 12, m[10], m[11])
_g(s, 2, 7, 8, 13, m[12], m[13]); _g(s, 3, 4, 9, 14, m[14], m[15])
if r < 6:
m = [m[i] for i in _PERM]
return [s[i] ^ s[i + 8] for i in range(8)] + [s[i + 8] ^ cv[i] for i in range(8)]
def _words(block):
block = block + b"\0" * (64 - len(block))
return [int.from_bytes(block[i:i + 4], "little") for i in range(0, 64, 4)]
def _chunk_output(chunk, counter):
blocks = [chunk[i:i + 64] for i in range(0, len(chunk), 64)] or [b""]
cv = _IV
for j, b in enumerate(blocks[:-1]):
cv = _compress(cv, _words(b), counter, 64, _CHUNK_START if j == 0 else 0)[:8]
last = blocks[-1]
return (cv, _words(last), counter, len(last), _CHUNK_END | (_CHUNK_START if len(blocks) == 1 else 0))
def _cv(out):
cv, w, c, bl, f = out
return _compress(cv, w, c, bl, f)[:8]
def blake3(data: bytes) -> bytes:
chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""]
stack = []
for idx, ch in enumerate(chunks[:-1]):
cv = _cv(_chunk_output(ch, idx))
total = idx + 1
while total & 1 == 0:
cv = _cv((_IV, stack.pop() + cv, 0, 64, _PARENT))
total >>= 1
stack.append(cv)
out = _chunk_output(chunks[-1], len(chunks) - 1)
while stack:
out = (_IV, stack.pop() + _cv(out), 0, 64, _PARENT)
cv, w, _, bl, f = out
return b"".join(x.to_bytes(4, "little") for x in _compress(cv, w, 0, bl, f | _ROOT)[:8])
def _bytes(words):
return b"".join(x.to_bytes(4, "little") for x in words)
def _left_chunks(n):
"""The left subtree of `n` chunks (n >= 2): the largest power of two below n."""
return 1 << ((n - 1).bit_length() - 1)
def subtree_cv(data: bytes, start: int) -> bytes:
"""The non-root chaining value of `data` as a whole subtree of the input whose first
chunk is at byte offset `start` (a multiple of 1024): chunk counters run from
start // 1024."""
assert start % 1024 == 0
chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""]
def node(cs, c0):
if len(cs) == 1:
return _cv(_chunk_output(cs[0], c0))
k = _left_chunks(len(cs))
return _cv((_IV, node(cs[:k], c0) + node(cs[k:], c0 + k), 0, 64, _PARENT))
return _bytes(node(chunks, start // 1024))
def parent_cv(left: bytes, right: bytes, root: bool = False) -> bytes:
"""A parent node over two chaining values: its chaining value, or with `root` the
hash it outputs as the root."""
m = [int.from_bytes((left + right)[i:i + 4], "little") for i in range(0, 64, 4)]
return _bytes(_compress(_IV, m, 0, 64, _PARENT | (_ROOT if root else 0))[:8])

View file

@ -0,0 +1,182 @@
"""Ed25519 verification as WAI verifies every signature (SPEC §7 *Signatures*), written
from RFC 8032's definitions with Python's integers and nothing beyond the standard
library: the one routine the Python verifiers of WAI's corpora share. It shares no code
with wai-rs (`wai::sig`).
The rule, in order:
1. the key A decodes, and is the one canonical encoding of its point: y < p, and x = 0
never carries the sign bit (RFC 8032 §5.1.3);
2. A is not of small order (its order divides 8): under such a key the equation can
hold for a signature made without any private key;
3. S < L (RFC 8032 §5.1.7);
4. R decodes, is not of small order, and [S]B = R + [k]A holds with [S]B - [k]A encoded
and compared with R byte for byte (the cofactorless equation), k = SHA-512(R || A ||
M) mod L over the bytes of R and A as given.
`verify` returns None when every step holds, "bad_key" when step 1 or 2 fails and
"bad_signature" otherwise, so a verifier names the two outcomes with its own reasons.
The OER/2 reference verifier does not use this: oer-receipts §5 settles its own rule,
which refuses the same keys and leaves R to RFC 8032.
"""
import hashlib
P = 2 ** 255 - 19
L = 2 ** 252 + 27742317777372353535851937790883648493
D = -121665 * pow(121666, P - 2, P) % P
_SQRT_M1 = pow(2, (P - 1) // 4, P)
# The checks a verifier's own self-test may leave out, by name, to show its corpus
# catches the defect; never left out otherwise.
SKIPPABLE = ("small-order-key", "small-order-r")
def _recover_x(y, sign):
if y >= P:
return None
x2 = (y * y - 1) * pow(D * y * y + 1, P - 2, P) % P
if x2 == 0:
return None if sign else 0
x = pow(x2, (P + 3) // 8, P)
if (x * x - x2) % P:
x = x * _SQRT_M1 % P
if (x * x - x2) % P:
return None
return P - x if (x & 1) != sign else x
def decode(b):
"""RFC 8032 §5.1.3: the point `b` encodes, in extended coordinates (X, Y, Z, T), or
None when it is not the canonical encoding of a point (y >= p, x = 0 with the sign
bit set, or no x)."""
if len(b) != 32:
return None
y = int.from_bytes(b, "little")
sign, y = y >> 255, y & ((1 << 255) - 1)
x = _recover_x(y, sign)
return None if x is None else (x, y, 1, x * y % P)
def _add(p, q):
"""Twisted Edwards addition (a = -1) in extended coordinates; complete on this curve."""
a = (p[1] - p[0]) * (q[1] - q[0]) % P
b = (p[1] + p[0]) * (q[1] + q[0]) % P
c = 2 * p[3] * q[3] * D % P
d = 2 * p[2] * q[2] % P
e, f, g, h = b - a, d - c, d + c, b + a
return (e * f % P, g * h % P, f * g % P, e * h % P)
def _neg(p):
return (P - p[0], p[1], p[2], P - p[3])
def _identity(p):
return p[0] % P == 0 and (p[1] - p[2]) % P == 0
def encode(p):
zi = pow(p[2], P - 2, P)
x, y = p[0] * zi % P, p[1] * zi % P
return (y | ((x & 1) << 255)).to_bytes(32, "little")
_BY = 4 * pow(5, P - 2, P) % P
B = (_recover_x(_BY, 0), _BY, 1, _recover_x(_BY, 0) * _BY % P)
IDENTITY = (0, 1, 1, 0)
def small_order(p):
"""Whether [8]p is the identity: p's order divides 8."""
for _ in range(3):
p = _add(p, p)
return _identity(p)
def strict_key(pk):
"""Steps 1 and 2: whether `pk` is the canonical encoding of a point of large order."""
a = decode(pk)
return a is not None and not small_order(a)
def _double_mul(s, p, k, q):
"""[s]p + [k]q, one doubling per bit (Shamir's trick)."""
pq = _add(p, q)
acc = IDENTITY
for i in range(max(s.bit_length(), k.bit_length()) - 1, -1, -1):
acc = _add(acc, acc)
bs, bk = (s >> i) & 1, (k >> i) & 1
if bs and bk:
acc = _add(acc, pq)
elif bs:
acc = _add(acc, p)
elif bk:
acc = _add(acc, q)
return acc
def verify(pk, msg, sig, skip=()):
"""The whole rule over `msg`: None when every step holds, else "bad_key" (steps 1
and 2) or "bad_signature" (steps 3 and 4). `skip` names checks of SKIPPABLE to leave
out, for a verifier's self-test only."""
if len(pk) != 32:
return "bad_key"
a = decode(pk)
if a is None or (small_order(a) and "small-order-key" not in skip):
return "bad_key"
if len(sig) != 64:
return "bad_signature"
s = int.from_bytes(sig[32:], "little")
if s >= L:
return "bad_signature"
r = decode(sig[:32])
if r is None or (small_order(r) and "small-order-r" not in skip):
return "bad_signature"
k = int.from_bytes(hashlib.sha512(sig[:32] + pk + msg).digest(), "little") % L
if encode(_double_mul(s, B, k, _neg(a))) != sig[:32]:
return "bad_signature"
return None
def verifies(pk, msg, sig, skip=()):
return verify(pk, msg, sig, skip) is None
def _self_test():
"""RFC 8032 §7.1 TEST 1 and TEST 2, and the three signatures a lenient verifier
accepts and this rule refuses; a list of the checks that fail."""
fails = []
t1_pk = bytes.fromhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
t1_sig = bytes.fromhex("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155"
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b")
t2_pk = bytes.fromhex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c")
t2_sig = bytes.fromhex("92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da"
"085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00")
if verify(t1_pk, b"", t1_sig) is not None or verify(t2_pk, b"\x72", t2_sig) is not None:
fails.append("rfc8032-vectors")
if verify(t2_pk, b"\x73", t2_sig) != "bad_signature":
fails.append("rfc8032-wrong-message")
one = (1).to_bytes(32, "little")
b_enc = encode(B)
long_identity = (P + 1).to_bytes(32, "little")
forged = b_enc + one # (R, S) = (B, 1): [1]B = B + [k]O under the identity
if verify(one, b"any", forged) != "bad_key" or verify(long_identity, b"any", forged) != "bad_key":
fails.append("identity-key")
if verify(one, b"any", forged, skip=("small-order-key",)) is not None:
fails.append("identity-key-is-a-forgery")
k = int.from_bytes(hashlib.sha512(one + b_enc + b"m").digest(), "little") % L
r_small = one + k.to_bytes(32, "little") # under B (scalar 1): [k]B = O + [k]B
if verify(b_enc, b"m", r_small) != "bad_signature" or verify(b_enc, b"m", r_small, skip=("small-order-r",)) is not None:
fails.append("small-order-r")
s_plus_l = t1_sig[:32] + (int.from_bytes(t1_sig[32:], "little") + L).to_bytes(32, "little")
if verify(t1_pk, b"", s_plus_l) != "bad_signature":
fails.append("s-not-reduced")
return fails
if __name__ == "__main__":
import sys
f = _self_test()
print(("PASS" if not f else "FAIL") + " Ed25519 strict rule self-test " + ",".join(f))
sys.exit(1 if f else 0)

View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Verify WAI delivery receipts in a directory, with nothing but Python's standard library.
python3 verify_receipts.py <dir>
For each receipt index `<cid>.wai.receipts` in <dir> (laid out as http-delivery §8 lays them
out beside an object, as `wai hello` writes them), each delivery item is checked:
1. the group receipt is read from `rcpt/<link hash>.json`, and its link hash, recomputed, is
its name;
2. its Ed25519 signature verifies over its signed payload (jwp-receipts §2.3, the labelled
form), under the strict rule every WAI verifier holds;
3. the object `<cid>.wai` is there and hashes to the receipt's content hash, which is its
name, and the object's leaf reaches the group's Merkle root by its proof (§3 steps 4, 5).
This example reads groups whose objects are explicitly unmetered (energy not measured), as
`wai hello` writes; a receipt with a measured energy class is reported, not read. It is a
second implementation: it shares no code with the Rust one in ../rust/. Exit 0 when every
item of every index verifies.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _blake3 import blake3 # noqa: E402
from _ed25519 import verify as ed25519_verify # noqa: E402
LEAF = b"moq-jwp:merkle-leaf\x02"
NODE = b"moq-jwp:merkle-node\x01"
SIGN = b"moq-jwp:group-sign\x02"
LINK = b"moq-jwp:receipt-link\x01"
OBJECT = b"moq-jwp:object\x01"
UNMETERED = b"\x00" # the signed encoding of "not measured" (energy-measurement §2.7)
def u32(n):
return n.to_bytes(4, "big")
def u64(n):
return n.to_bytes(8, "big")
def text(s):
b = s.encode()
return u32(len(b)) + b
def signed_payload(g):
"""The labelled group payload (jwp-receipts §2.3)."""
parent = g["parent_receipt_hash"]
return (SIGN + u64(g["track_alias"]) + u64(g["group_id"]) + bytes.fromhex(g["root_hash"])
+ u64(g["leaf_count"]) + u64(g["joules_total"]) + text(g["group_start"]) + text(g["group_end"])
+ bytes.fromhex(g["signer_pubkey"]) + text(g["signer_id"])
+ UNMETERED + b"\x00" # the class, and its coverage: whole
+ ((b"\x01" + bytes.fromhex(parent)) if parent else b"\x00" + bytes(32)))
def check_item(d, cid, item):
"""One delivery item; raises ValueError with the reason when it does not verify."""
o = item["object_receipt"]
gname = item["property"]["group_receipt"]
with open(os.path.join(d, "rcpt", gname + ".json"), "rb") as f:
g = json.load(f)
if g.get("kind") != "group" or g.get("sign_version") != 2:
raise ValueError("not a group receipt in the labelled form")
if any(k.startswith("energy_") and k != "energy_coverage" for k in g) or o.get("leaf_version") != 2 or any(k.startswith("energy_") for k in o):
raise ValueError("a measured energy class: this example reads unmetered receipts alone")
payload = signed_payload(g)
if blake3(LINK + payload + bytes.fromhex(g["sig"])).hex() != gname:
raise ValueError("receipt_name_mismatch: the group's link hash is not its file's name")
why = ed25519_verify(bytes.fromhex(g["signer_pubkey"]), payload, bytes.fromhex(g["sig"]))
if why is not None:
raise ValueError(f"{why}: the group's signature")
with open(os.path.join(d, cid + ".wai"), "rb") as f:
envelope = f.read()
content = blake3(OBJECT + envelope)
if content.hex() != o["content_hash"] or content.hex() != cid:
raise ValueError("content_hash_mismatch: the object is not the one the receipt names")
# The object's leaf, up its proof to the root.
p = o["merkle_proof"]
if o["object_id"] != p["leaf_index"] or p["direction_bits"] != p["leaf_index"] or o["group_id"] != g["group_id"]:
raise ValueError("proof_position_mismatch")
if o["joules_micro"] != 0:
raise ValueError("object_label_invalid: an unmetered object states no energy")
cur = blake3(LEAF + content + u64(0) + UNMETERED + text(o["origin"]))
for i, s in enumerate(p["siblings"]):
s = bytes.fromhex(s)
cur = blake3(NODE + s + cur) if (p["direction_bits"] >> i) & 1 else blake3(NODE + cur + s)
if cur.hex() != g["root_hash"]:
raise ValueError("inclusion_proof_failed")
return g
def main(d):
names = sorted(n for n in os.listdir(d) if n.endswith(".wai.receipts"))
if not names:
print(f"no receipt index (*.wai.receipts) in {d}")
return 2
ok = True
for n in names:
cid = n[: -len(".wai.receipts")]
with open(os.path.join(d, n), "rb") as f:
index = json.load(f)
if index.get("kind") != "wai-receipt-index" or index.get("object") != cid:
print(f"FAIL {n}: not this object's receipt index")
ok = False
continue
for item in index["delivery"]:
try:
g = check_item(d, cid, item)
print(f"ok {cid[:16]}: delivered and verified; group {item['property']['group_receipt'][:16]} signed by {g['signer_pubkey'][:16]}… ({g['signer_id'][:24]}…)")
except (ValueError, OSError, KeyError) as e:
print(f"FAIL {cid[:16]}: {e}")
ok = False
return 0 if ok else 1
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__.split("\n\n")[1], file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,14 @@
[package]
name = "verify-receipts"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
# A project of its own, not a member of any workspace around it.
[workspace]
[dependencies]
# The WAI reference implementation, from the open-standards repository. `quickstart` holds the
# receipt checks of http-delivery §8 and jwp-receipts §3, with no system library.
wai = { git = "https://git.transaction.science/Transaction-Science/open-standards", default-features = false, features = ["quickstart"] }

View file

@ -0,0 +1,70 @@
//! Verify the WAI delivery receipts in a directory with the reference implementation:
//!
//! cargo run --release -- <dir>
//!
//! Each receipt index `<cid>.wai.receipts` is checked as http-delivery §8.2 checks one: each
//! group receipt read from `rcpt/<link hash>.json` beside it, its parents walked by their
//! links, its signature and chain (jwp-receipts §3 steps 1–3), and the object `<cid>.wai`
//! against its receipt (steps 4–5). Nothing is fetched from anywhere.
use std::path::Path;
use wai::http_binding::receipts::{Limits, ObjectHeld, RECEIPT_DIR, check_index};
use wai::jwp_delivery::ReceiptChains;
use wai::jwp_hash::EstablishedHash;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn main() -> std::process::ExitCode {
let Some(dir) = std::env::args().nth(1) else {
eprintln!("usage: verify-receipts <dir>");
return std::process::ExitCode::from(2);
};
let dir = Path::new(&dir);
let mut indexes: Vec<_> = std::fs::read_dir(dir).expect("a directory").flatten().map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(".wai.receipts")).collect();
indexes.sort();
if indexes.is_empty() {
eprintln!("no receipt index (*.wai.receipts) in {}", dir.display());
return std::process::ExitCode::from(2);
}
let mut all = true;
for ix in indexes {
let name = ix.file_name().unwrap().to_string_lossy().into_owned();
let cid_hex = name.trim_end_matches(".wai.receipts");
let Some(cid) = (0..32).map(|i| u8::from_str_radix(cid_hex.get(2 * i..2 * i + 2)?, 16).ok()).collect::<Option<Vec<u8>>>().and_then(|v| <[u8; 32]>::try_from(v).ok()) else {
println!("FAIL {name}: not named by a content hash");
all = false;
continue;
};
// What this verifier holds of the object: the envelope, if it is here.
let held = match std::fs::read(dir.join(format!("{cid_hex}.wai"))) {
Ok(b) => ObjectHeld::Object(EstablishedHash::of_envelope(&b)),
Err(_) => ObjectHeld::Nothing,
};
let mut fetch = |h: &[u8; 32], limit: usize| -> Option<Vec<u8>> {
let mut b = std::fs::read(dir.join(format!("{RECEIPT_DIR}{}.json", hex(h)))).ok()?;
b.truncate(limit);
Some(b)
};
let index = std::fs::read(&ix).expect("the index reads");
match check_index(&cid, &index, &held, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None) {
Err(e) => {
println!("FAIL {}: the index: {}", &cid_hex[..16], e.code());
all = false;
}
Ok(report) => {
for d in report.delivery {
match d {
Ok(v) => println!("ok {}: {}; group {} signed by {}… ({})", &cid_hex[..16], v.evidence.label(), &hex(&v.group.link_hash())[..16], &hex(&v.group.signer_pubkey)[..16], v.group.signer_id),
Err(e) => {
println!("FAIL {}: {}", &cid_hex[..16], e.code());
all = false;
}
}
}
}
}
}
if all { std::process::ExitCode::SUCCESS } else { std::process::ExitCode::from(1) }
}

View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# The verify-receipts example: `wai create` makes the project and `wai hello` writes an object
# and its receipts. Both verifiers, the Rust one on the repository's wai crate (the template's
# git dependency patched to this checkout) and the Python one, must accept them, and each must
# refuse them when one byte of the object, or of the group's signature, is changed.
#
# bash wai/examples/verify-receipts/test/verify-receipts.test.sh
# Needs wai on PATH, cargo and python3.
# No -e: each check records its own result, and the run goes on to the next.
set -uo pipefail
REPO=$(cd "$(dirname "$0")/../../../.." && pwd)
T=$(mktemp -d)
fails=0
check() { if [ "$2" = 0 ]; then echo "ok $1${3:+ ($3)}"; else echo "FAIL $1${3:+ ($3)}"; fails=$((fails + 1)); fi; }
wai create "$T/check" --template verify-receipts > /dev/null || { echo "FAIL wai create"; exit 1; }
cd "$T/check"
wai hello receipts > hello.log || { cat hello.log; echo "FAIL wai hello"; exit 1; }
# The template's git dependency, pointed at this checkout.
sed -i.bak "s#git = \"https://git.transaction.science/Transaction-Science/open-standards\"#path = \"$REPO/wai/wai-rs\"#" rust/Cargo.toml
grep -q "path = \"$REPO/wai/wai-rs\"" rust/Cargo.toml || { echo "FAIL the dependency was not pointed at the checkout"; exit 1; }
(cd rust && cargo build --release --quiet 2> ../build.log) || { cat build.log; echo "FAIL the Rust verifier builds"; exit 1; }
BIN="${CARGO_TARGET_DIR:-$PWD/rust/target}/release/verify-receipts"
rust() { "$BIN" receipts; }
py() { python3 -I python/verify_receipts.py receipts; }
for v in rust py; do
s=0; out=$($v) || s=$?
check "$v: accepts the receipts wai hello wrote" $s "$(echo "$out" | head -1)"
done
obj=$(ls receipts/*.wai)
cp "$obj" obj.bak
printf 'x' >> "$obj"
for v in rust py; do
s=0; out=$($v) || s=$?
[ "$s" = 1 ]; check "$v: refuses an object one byte longer" $? "$(echo "$out" | head -1)"
done
cp obj.bak "$obj"
g=$(ls receipts/rcpt/*.json)
cp "$g" g.bak
python3 - "$g" <<'PY'
import sys
p = sys.argv[1]
t = open(p).read()
i = t.index('"sig":"') + 7
open(p, "w").write(t[:i] + ("1" if t[i] == "0" else "0") + t[i + 1:])
PY
for v in rust py; do
s=0; out=$($v) || s=$?
[ "$s" = 1 ]; check "$v: refuses a group whose signature changed" $? "$(echo "$out" | head -1)"
done
cp g.bak "$g"
for v in rust py; do
s=0; out=$($v) || s=$?
check "$v: accepts them again once restored" $s
done
# The Python verifier's Ed25519 and BLAKE3 are the corpus verifiers' own, copied unchanged.
for f in _ed25519.py _blake3.py; do
cmp -s "$REPO/wai/examples/verify-receipts/python/$f" "$REPO/wai/claim-conformance/$f"; check "python/$f is claim-conformance/$f, unchanged" $?
done
echo "verify-receipts: $([ $fails = 0 ] && echo all checks held || echo "$fails check(s) failed")"
exit $fails

View file

@ -0,0 +1,5 @@
{
"template": "verify-receipts",
"title": "Verify receipts in Rust and in Python",
"summary": "The receipts WAI writes beside an object, checked offline by the reference implementation and by a second one in plain Python."
}

1
wai/examples/video-room/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.wai/

View file

@ -0,0 +1,25 @@
# A video room in one page
**What it shows.** Your own page (`index.html`) with a live room in it. The room client in the
frame makes a key in the tab, asks the room service for a grant for that key, and joins the
relay with it. Every object it receives is checked against its signed receipt before it is
decoded. The page's own script calls the room service's HTTP API to say who is here.
**Run it.**
```sh
wai create my-room --template video-room
cd my-room
wai dev
```
Open `http://127.0.0.1:8080/` in two tabs, or in two browsers, and press **Join** in each.
**What you see.** Each tab shows the other's camera and plays its sound. The line above the
frame counts the people present. Leave in one tab, and the other lays the room out again.
**Make it yours.** Edit `index.html`. The room is at `/room/?service=/room-api` on the same
origin; the API is described at https://wai.transaction.science/docs/api/.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My room</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; background: #0f1115; color: #e8eaf0; }
h1 { font-size: 20px; margin: 0 0 8px; }
#who { color: #a8afc0; margin: 0 0 12px; }
iframe { width: 100%; height: 80vh; border: 1px solid #2a2f3a; border-radius: 8px; background: #000; }
</style>
</head>
<body>
<h1>My room</h1>
<p id="who" aria-live="polite">Asking the room service who is here…</p>
<!-- The reference room client, served by `wai dev` on this page's own origin. It makes a key
in this tab, asks the room service for a grant for it, and joins the relay with it. -->
<iframe id="room" title="The room" src="/room/?service=/room-api" allow="camera; microphone; autoplay"></iframe>
<script type="module">
// Your own code, calling the room service's HTTP API: GET /room-api/room is the signed
// room, with how many people are present and how many hold a publisher's place.
const who = document.getElementById("who");
async function poll() {
try {
const r = await fetch("/room-api/room", { cache: "no-store" });
const room = await r.json();
who.textContent = `Room ${room.room.slice(0, 12)}…: ${room.present} present, ${room.publishers} of ${room.max_publishers} places taken.`;
who.dataset.present = String(room.present);
} catch (e) {
who.textContent = `The room service did not answer (${e.message}). Is \`wai dev\` running?`;
}
}
poll();
setInterval(poll, 2000);
</script>
</body>
</html>

View file

@ -0,0 +1,46 @@
// The video-room example, as a person runs it: `wai create` makes the project, `wai dev` runs
// it, and two browsers open its page and press Join. Each must receive, verify and decode the
// other's video and audio, and the page's own line must count both present.
//
// node wai/examples/video-room/test/video-room.test.mjs
import { check, create, dev, dump, finish, frame, launch, open, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
const out = tmpdir("video-room");
const dir = await create("video-room", out);
const d = await dev(dir);
const browser = await launch();
const pages = [];
try {
const enter = async (name) => {
const p = await open(browser, `${d.url}/`, name);
pages.push(p);
const f = await frame(p, "/room/");
await until(`${name}: the room page ready`, STEP, () => f.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await f.click("#join");
const s = await until(`${name}: publishing`, STEP, async () => { const x = await f.evaluate(() => window.roomTest.snapshot()); if (x.errors.length) throw new Error(x.errors[0]); return x.phase === "publishing" && x; }, 250);
return { p, f, me: s.me.principal };
};
const A = await enter("alice");
const B = await enter("bob");
for (const [x, other] of [[A, B.me], [B, A.me]]) {
const t = await until(`${x.p.name} decodes the other`, STEP * 2, async () => {
const s = await x.f.evaluate(() => window.roomTest.snapshot());
if (s.errors.length) throw new Error(s.errors[0]);
const t = s.subs[other];
return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && t;
}, 500);
check(`${x.p.name}'s page receives, verifies and decodes the other's camera and microphone`, t.video.refused === 0, `${t.video.verified} video objects verified, ${t.video.decoded} decoded; ${t.audio.decoded} audio frames`);
}
const line = await until("the page's own line counts two present", STEP, async () => {
const v = await A.p.evaluate(() => ({ n: document.getElementById("who").dataset.present, text: document.getElementById("who").textContent }));
return v.n === "2" && v;
}, 500);
check("the page's own script reads the room service's API and counts two present", true, line.text);
} catch (e) {
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
} finally {
dump(pages, out);
await browser.close().catch(() => {});
await d.stop();
}
finish("video-room");

View file

@ -0,0 +1,6 @@
{
"template": "video-room",
"title": "A video room in one page",
"summary": "Your own page with a live room in it: camera, microphone, every object checked against its receipt.",
"dev": { "site": "." }
}

View file

@ -0,0 +1 @@
.wai/

View file

@ -0,0 +1,28 @@
# Watch together
**What it shows.** A room in which everyone watches one file in sync. The room service names
the file by its content hash in the room's signed policy; each page fetches it, checks it,
and keeps it at the room's position. Play, pause, seek and speed are controls each page
signs; each applies at one instant on the room's clock, for everyone. Every few seconds the
room's clock states how far each viewer is from the room.
**Run it.**
```sh
wai create my-watch --template watch-together
cd my-watch
wai dev
```
Open `http://127.0.0.1:8080/` in two tabs, press **Watch** in each, then **Play** in one.
**What you see.** Both tabs play the sample tone (`tone.wav`, made by `wai create`, a pitch
that steps up each second), pause together and jump together. Below the player, each page
shows its own offset from the room and whether everyone is in sync.
**Make it yours.** Put a `.webm`, `.mp4`, `.ogg`, `.mp3` or `.wav` file in the project and name
it in `wai.json` (`"watch"`), with its length in milliseconds (`"watch_ms"`) for anything
but a WAV file. Or run `wai dev --watch film.webm --watch-ms 90000`.
**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
instructions an assistant reads and connects it to WAI's tools.

View file

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Watch together</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; background: #0f1115; color: #e8eaf0; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { color: #a8afc0; margin: 0 0 12px; }
iframe { width: 100%; height: 80vh; border: 1px solid #2a2f3a; border-radius: 8px; background: #000; }
</style>
</head>
<body>
<h1>Watch together</h1>
<p>Press <strong>Watch</strong>, then Play. Everyone in the room plays, pauses and seeks at the same instant.</p>
<!-- The reference room client, served by `wai dev` on this page's origin. In a watch room it
fetches the source, checks it by the content hash the room's policy names, and keeps it at
the room's position. -->
<iframe title="The room" src="/room/?service=/room-api" allow="autoplay"></iframe>
</body>
</html>

View file

@ -0,0 +1,52 @@
// The watch-together example, as a person runs it: `wai create` makes the project (and its
// sample tone), `wai dev` runs the watch room, and two browsers press Watch. One plays, then
// the other pauses: both must apply each control at its instant, within a quarter second of
// the room, and stop at one position.
//
// node wai/examples/watch-together/test/watch-together.test.mjs
import { check, create, dev, dump, finish, frame, launch, open, sleep, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
const out = tmpdir("watch-together");
const dir = await create("watch-together", out);
const d = await dev(dir);
const browser = await launch();
const pages = [];
const BOUND_US = 250000;
try {
const enter = async (name) => {
const p = await open(browser, `${d.url}/`, name);
pages.push(p);
const f = await frame(p, "/room/");
await until(`${name}: the room page ready`, STEP, () => f.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200);
await f.click("#watch");
const s = await until(`${name}: in the watch room`, STEP, async () => {
const x = await f.evaluate(() => window.roomTest.snapshot());
if (x.errors.length) throw new Error(x.errors[0]);
return x.cowatch && x.cowatch.joined != null && x;
}, 200);
return { p, f, s };
};
const A = await enter("ada");
const B = await enter("ben");
check("each page fetched the room's source and checked it by the content hash its policy names", A.s.cowatch.verified_source && B.s.cowatch.verified_source);
const cws = async () => Promise.all([A, B].map(async (x) => (await x.f.evaluate(() => window.roomTest.snapshot())).cowatch));
const together = (what, n, playing, after) => until(what, STEP, async () => {
const all = await cws();
return all.every((c) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US && !c.stopped) && all;
}, 250);
const playAt = await A.f.evaluate(() => window.roomTest.watchControl("play"));
const t1 = await together("both play on Ada's control", 1, true, playAt);
check("a play starts both players together, each within a quarter second of the room", true, t1.map((c) => `${(c.error_us / 1000).toFixed(1)} ms`).join(", "));
await sleep(3000);
const pauseAt = await B.f.evaluate(() => window.roomTest.watchControl("pause"));
const t2 = await together("both pause on Ben's control", 2, false, pauseAt);
const pos = t2.map((c) => c.current_time);
check("a pause stops both players at one position", Math.abs(pos[0] - pos[1]) * 1e6 <= BOUND_US && pos[0] > 1, pos.map((x) => x.toFixed(3)).join(" s, ") + " s");
} catch (e) {
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
} finally {
dump(pages, out);
await browser.close().catch(() => {});
await d.stop();
}
finish("watch-together");

View file

@ -0,0 +1,6 @@
{
"template": "watch-together",
"title": "Watch together",
"summary": "Everyone in the room watches one file, in sync: play, pause and seek apply for all at one instant.",
"dev": { "site": ".", "mode": "watch", "watch": "tone.wav" }
}

35
wai/quickstart/README.md Normal file
View file

@ -0,0 +1,35 @@
# WAI
The `wai` command line, and the reference software `wai dev` runs on your own machine. All
of it is open source under Apache-2.0, built from the open-standards repository
(`wai/quickstart/build.sh`).
| File | What it is |
|---|---|
| `bin/wai` | The command line: `hello`, `create`, `dev`, `verify`, and the envelope tools (`inspect`, `wrap`, `unwrap`, `encode`, `decode` for the mandatory floor, `capabilities`) |
| `bin/joule-moq-relay` | The relay a room's media goes through: MOQT over QUIC, WebTransport and WebSocket |
| `bin/joule-moq-room` | The room service: a room's keys, its grants, its clock |
| `bin/wai-remote-host` | A remote desktop room's host agent |
| `bin/wai-pack` | The packager: a title as WAI objects, HTTP playlists and a MoQ catalog |
| `share/wai/web/` | The room client a browser loads: its pages and their WebAssembly |
## Start
```sh
wai hello # WAI's four steps on a sample: name, dispatch, rebuild, receipt
wai create my-room --template video-room # a video room in one page
cd my-room
wai dev # the relay, the room service and your page, on 127.0.0.1:8080
wai verify ../wai-hello # the receipts hello wrote, checked offline
```
`wai create --list` names every template. `wai dev --help` gives its options. Nothing here
needs an account, and nothing is sent off your machine.
Keep `bin/` and `share/` side by side: `wai dev` finds the relay, the room service and the
room client beside itself.
## More
- The standard and its documentation: https://wai.transaction.science/
- The source: https://git.transaction.science/Transaction-Science/open-standards

44
wai/quickstart/build-web.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# The room client's files, as `wai dev` serves them, built into one directory:
#
# bash wai/quickstart/build-web.sh <out-dir>
#
# <out-dir>/room/ and <out-dir>/moq/ are joule-moq-web's page code (its tests left out), and
# <out-dir>/pkg/ the three WebAssembly builds the pages load: joule_moq_web (MOQT and
# receipts), joule_moq_web_e2ee (the same, and the member of an end-to-end encrypted room)
# and wai_live_web (the room's records, layout and mixer), each through wasm-bindgen's web
# target. The same files on every platform: an archive for any of them carries this
# directory as share/wai/web/.
#
# Needs the wasm32-unknown-unknown target and the wasm-bindgen CLI at 0.2.127, the version
# both crates pin. Run from anywhere; CARGO_TARGET_DIR is honoured.
set -euo pipefail
[ $# -eq 1 ] || { echo "usage: bash wai/quickstart/build-web.sh <out-dir>" >&2; exit 2; }
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
OUT=$(mkdir -p "$1" && cd "$1" && pwd)
JC="$ROOT/joule-code/joule-code-rs"
WEB="$JC/crates/joule-moq-web/web"
WLW="$ROOT/wai/wai-live-web"
TARGET=${CARGO_TARGET_DIR:-$JC/target}
export CARGO_TARGET_DIR="$TARGET"
command -v wasm-bindgen >/dev/null || { echo "wasm-bindgen CLI 0.2.127 is needed (cargo install --locked wasm-bindgen-cli --version 0.2.127)" >&2; exit 2; }
wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version both crates pin" >&2; exit 2; }
rm -rf "$OUT/room" "$OUT/moq" "$OUT/pkg"
mkdir -p "$OUT/room" "$OUT/moq" "$OUT/pkg"
(cd "$JC" && cargo build --locked --release -p joule-moq-web --lib --target wasm32-unknown-unknown)
wasm-bindgen --target web --out-dir "$OUT/pkg" --out-name joule_moq_web "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm"
(cd "$JC" && cargo build --locked --profile wasm -p joule-moq-web --lib --target wasm32-unknown-unknown --features e2ee)
wasm-bindgen --target web --remove-name-section --remove-producers-section --out-dir "$OUT/pkg" --out-name joule_moq_web_e2ee "$TARGET/wasm32-unknown-unknown/wasm/joule_moq_web.wasm"
(cd "$WLW" && cargo build --locked --release --lib --target wasm32-unknown-unknown)
wasm-bindgen --target web --out-dir "$OUT/pkg" --out-name wai_live_web "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm"
# The pages need the modules and the binaries alone, not the type declarations.
rm -f "$OUT"/pkg/*.d.ts
for d in room moq; do
for f in "$WEB/$d"/*; do
case "$f" in *.test.mjs) continue ;; esac
cp "$f" "$OUT/$d/"
done
done
ls -l "$OUT/pkg"
echo "the room client is in $OUT"

98
wai/quickstart/build.sh Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# The quickstart archive: the `wai` command line and what `wai dev` runs, for one target.
#
# bash wai/quickstart/build.sh <out-dir> --web <dir> [--target <triple>] [--version <v>]
#
# Builds, each release and --locked, each from its own workspace:
# wai wai/wai-rs, --no-default-features --features quickstart: the mandatory
# floor, hello, create, dev and verify; no system codec library
# joule-moq-relay the relay (joule-code/joule-code-rs)
# joule-moq-room the room service
# wai-remote-host a remote desktop room's host agent
# wai-pack the packager
# and writes <out-dir>/wai-<version>-<triple>.tar.gz holding bin/ (those five), share/wai/web/
# (the room client, from --web: bash wai/quickstart/build-web.sh makes it), README.md and
# LICENSE, with <archive>.sha256 beside it in the form `shasum -a 256 -c` reads. The version
# is wai/wai-rs's unless given; the triple is the host's unless given. Extra cargo flags for
# the target (a linker, static linking) come from the environment, as cargo reads them.
set -euo pipefail
usage() { echo "usage: bash wai/quickstart/build.sh <out-dir> --web <dir> [--target <triple>] [--version <v>]" >&2; exit 2; }
[ $# -ge 3 ] || usage
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
OUT=$(mkdir -p "$1" && cd "$1" && pwd); shift
WEBDIR="" TRIPLE="" VERSION=""
while [ $# -gt 0 ]; do
case "$1" in
--web) WEBDIR="$2"; shift 2 ;;
--target) TRIPLE="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$WEBDIR" ] && [ -f "$WEBDIR/room/index.html" ] && [ -f "$WEBDIR/pkg/wai_live_web_bg.wasm" ] && [ -f "$WEBDIR/pkg/joule_moq_web_bg.wasm" ] \
|| { echo "--web: a directory build-web.sh made (room/, moq/, pkg/)" >&2; exit 2; }
HOST=$(rustc -vV | sed -n 's/^host: //p')
TRIPLE=${TRIPLE:-$HOST}
VERSION=${VERSION:-$(sed -n 's/^version = "\(.*\)"$/\1/p' "$ROOT/wai/wai-rs/Cargo.toml" | head -1)}
EXE=""; case "$TRIPLE" in *windows*) EXE=".exe" ;; esac
TGT=(--target "$TRIPLE")
build() { # <dir> <binary> <cargo args...>: the binary's path, or a failure
local dir="$1" bin="$2"; shift 2
(
# Under a target directory shared by every workspace (CARGO_TARGET_DIR), each build gets its
# own: wai also builds as a cdylib, so its rlib's file name carries no hash, and a second
# feature set of it (the room service's) would overwrite the first in one directory.
if [ -n "${CARGO_TARGET_DIR:-}" ]; then export CARGO_TARGET_DIR="$CARGO_TARGET_DIR/quickstart-$bin"; fi
cd "$dir"
cargo build --locked --release "${TGT[@]}" "$@" >&2 || exit 1
t=$(cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])') || exit 1
echo "$t/$TRIPLE/release/$bin$EXE"
)
}
need() { # <binary> <cargo args...>: copied into the stage, or the script stops
local b
b=$(build "$@") || { echo "build.sh: the build of $2 failed" >&2; exit 1; }
cp "$b" "$STAGE/$NAME/bin/"
}
NAME="wai-$VERSION-$TRIPLE"
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/$NAME/bin" "$STAGE/$NAME/share/wai"
need "$ROOT/wai/wai-rs" wai --no-default-features --features quickstart --bin wai
need "$ROOT/joule-code/joule-code-rs" joule-moq-relay -p joule-moq-relay --bin joule-moq-relay
need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-room" joule-moq-room --bin joule-moq-room
need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-remote" wai-remote-host --bin wai-remote-host
need "$ROOT/wai/wai-pack" wai-pack --bin wai-pack
cp -R "$WEBDIR" "$STAGE/$NAME/share/wai/web"
cp "$ROOT/wai/quickstart/README.md" "$STAGE/$NAME/README.md"
cp "$ROOT/wai/LICENSE" "$STAGE/$NAME/LICENSE"
chmod 755 "$STAGE/$NAME"/bin/*
# Each binary runs before it is packaged, where it can run here.
# The wai packaged must hold this checkout's examples, file for file and byte for byte: a stale
# binary would ship templates from another commit.
if [ "$TRIPLE" = "$HOST" ]; then
"$STAGE/$NAME/bin/wai-pack$EXE" fixture list > /dev/null
python3 - "$STAGE/$NAME/bin/wai$EXE" "$ROOT/wai/examples" <<'PY' || { echo "build.sh: the packaged wai does not hold this checkout's examples" >&2; exit 1; }
import json, os, subprocess, sys, tempfile
wai, ex = sys.argv[1], sys.argv[2]
with tempfile.TemporaryDirectory() as d:
for t in json.loads(subprocess.check_output([wai, "create", "--list", "--json"])):
out = os.path.join(d, t["template"])
subprocess.check_output([wai, "create", out, "--template", t["template"]])
for f in t["files"]:
src = os.path.join(ex, t["template"], f)
if os.path.exists(src) and open(src, "rb").read() != open(os.path.join(out, f), "rb").read():
sys.exit(f"{t['template']}/{f} differs")
PY
fi
mkdir -p "$OUT"
# The same bytes for the same inputs: sorted names, fixed owner and times.
if tar --version 2>/dev/null | grep -q GNU; then
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime='2026-01-01 00:00Z' -C "$STAGE" -cf - "$NAME" | gzip -n -9 > "$OUT/$NAME.tar.gz"
else
tar -C "$STAGE" -cf - "$NAME" | gzip -n -9 > "$OUT/$NAME.tar.gz"
fi
( cd "$OUT" && { command -v sha256sum >/dev/null && sha256sum "$NAME.tar.gz" || shasum -a 256 "$NAME.tar.gz"; } > "$NAME.tar.gz.sha256" )
echo "packaged $OUT/$NAME.tar.gz ($(cut -d' ' -f1 "$OUT/$NAME.tar.gz.sha256"), $(du -h "$OUT/$NAME.tar.gz" | cut -f1))"

101
wai/quickstart/formulae.py Normal file
View file

@ -0,0 +1,101 @@
"""The package managers' entries for a release of the WAI command line, from the SHA-256 files
attached to it:
python3 wai/quickstart/formulae.py <version> <tag> <forge-api> <owner/repo> <public-url> <out-dir>
Writes <out-dir>/wai.rb (a Homebrew formula for macOS and Linux) and <out-dir>/wai.json (a
Scoop manifest for Windows). A platform whose archive is not attached is left out, and stderr
says so; with none at all, it fails. Each archive holds bin/ and share/wai/web/, kept side by
side as `wai dev` expects them.
"""
import json
import os
import pathlib
import sys
import urllib.request
version, tag, api, repo, public, out = sys.argv[1:7]
token = os.environ.get("FORGE_TOKEN", "")
headers = {"User-Agent": "wai-release"}
if token:
headers["Authorization"] = f"token {token}"
web = api.rsplit("/api/v1", 1)[0]
def get(url):
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r:
return r.read()
release = json.loads(get(f"{api}/repos/{repo}/releases/tags/{tag}"))
assets = {a["name"]: a for a in release.get("assets", [])}
def digest(archive):
if archive not in assets or archive + ".sha256" not in assets:
print(f"note: {archive} is not attached; left out", file=sys.stderr)
return None
return get(f"{web}/{repo}/releases/download/{tag}/{archive}.sha256").decode().split()[0]
def url(archive):
return f"{public}/releases/download/{tag}/{archive}"
DESC = "WAI, an open media standard: its command line, and a local relay and room service"
HOME = "https://wai.transaction.science/docs/quickstart/"
targets = {
("macos", "arm"): "aarch64-apple-darwin",
("macos", "intel"): "x86_64-apple-darwin",
("linux", "arm"): "aarch64-unknown-linux-musl",
("linux", "intel"): "x86_64-unknown-linux-musl",
}
blocks = {}
for (os_name, cpu), triple in targets.items():
archive = f"wai-{version}-{triple}.tar.gz"
sha = digest(archive)
if sha:
blocks.setdefault(os_name, []).append(f' on_{cpu} do\n url "{url(archive)}"\n sha256 "{sha}"\n end\n')
win = f"wai-{version}-x86_64-pc-windows-msvc"
win_sha = digest(win + ".zip")
if not blocks and not win_sha:
sys.exit("no archive is attached to the release")
formula = f'''class Wai < Formula
desc "{DESC}"
homepage "{HOME}"
version "{version}"
license "Apache-2.0"
'''
for os_name in ("macos", "linux"):
if os_name in blocks:
formula += f" on_{os_name} do\n" + "".join(blocks[os_name]) + " end\n"
formula += '''
def install
# bin/ and share/wai/web/ side by side: `wai dev` finds the relay, the room service and the
# room client beside its own binary.
bin.install Dir["bin/*"]
(share/"wai").install "share/wai/web"
end
test do
assert_match "video-room", shell_output("#{bin}/wai create --list")
assert_match "delivered-verified", shell_output("#{bin}/wai hello #{testpath}/hello")
assert_match "verified", shell_output("#{bin}/wai verify #{testpath}/hello")
end
end
'''
pathlib.Path(out, "wai.rb").write_text(formula)
if win_sha:
manifest = {
"version": version,
"description": DESC,
"homepage": HOME,
"license": "Apache-2.0",
"architecture": {"64bit": {"url": url(win + ".zip"), "hash": win_sha, "extract_dir": win}},
"bin": ["bin\\wai.exe", "bin\\joule-moq-relay.exe", "bin\\joule-moq-room.exe", "bin\\wai-remote-host.exe", "bin\\wai-pack.exe"],
}
pathlib.Path(out, "wai.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(f"wrote the formula{' and the manifest' if win_sha else ''} for wai {version}")

43
wai/quickstart/install.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/sh
# Install the WAI command line, and what `wai dev` runs, from the forge's releases:
#
# curl -fsSL https://wai.transaction.science/install.sh | sh
#
# It picks the archive for this system (macOS or Linux, arm64 or x86_64), checks it against the
# SHA-256 the release states, and unpacks it into $WAI_HOME (default ~/.wai): bin/ and
# share/wai/web/ side by side. Then it says which line puts ~/.wai/bin on your PATH. Nothing
# else is changed. WAI_VERSION picks a release (default: the one below).
set -eu
VERSION="${WAI_VERSION:-0.3.0}"
DEST="${WAI_HOME:-$HOME/.wai}"
BASE="https://git.transaction.science/Transaction-Science/open-standards/releases/download/wai-v$VERSION"
case "$(uname -s)" in
Darwin) os=apple-darwin ;;
Linux) os=unknown-linux-musl ;;
*) echo "wai: no archive for $(uname -s); build from source: https://wai.transaction.science/docs/quickstart/" >&2; exit 1 ;;
esac
case "$(uname -m)" in
arm64 | aarch64) cpu=aarch64 ;;
x86_64 | amd64) cpu=x86_64 ;;
*) echo "wai: no archive for $(uname -m)" >&2; exit 1 ;;
esac
NAME="wai-$VERSION-$cpu-$os"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
echo "wai: fetching $NAME"
curl -fsSL -o "$TMP/$NAME.tar.gz" "$BASE/$NAME.tar.gz"
curl -fsSL -o "$TMP/$NAME.tar.gz.sha256" "$BASE/$NAME.tar.gz.sha256"
want="$(cut -d' ' -f1 "$TMP/$NAME.tar.gz.sha256")"
if command -v sha256sum > /dev/null 2>&1; then got="$(sha256sum "$TMP/$NAME.tar.gz" | cut -d' ' -f1)"; else got="$(shasum -a 256 "$TMP/$NAME.tar.gz" | cut -d' ' -f1)"; fi
[ "$got" = "$want" ] || { echo "wai: the archive's SHA-256 is $got, not the release's $want; nothing installed" >&2; exit 1; }
tar -xzf "$TMP/$NAME.tar.gz" -C "$TMP"
mkdir -p "$DEST"
rm -rf "$DEST/bin" "$DEST/share/wai"
mkdir -p "$DEST/share"
mv "$TMP/$NAME/bin" "$DEST/bin"
mv "$TMP/$NAME/share/wai" "$DEST/share/wai"
cp "$TMP/$NAME/README.md" "$TMP/$NAME/LICENSE" "$DEST/"
"$DEST/bin/wai" create --list > /dev/null
echo "wai: installed in $DEST. Put it on your PATH:"
echo " export PATH=\"$DEST/bin:\$PATH\""
echo "then: wai hello"

34
wai/quickstart/publish.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Attach files to the forge release for a tag, creating the release if there is
# none, and replacing an asset of the same name:
#
# FORGE_API=http://host:3400/api/v1 FORGE_REPO=owner/repo FORGE_TOKEN=... \
# bash wai/quickstart/publish.sh <tag> <title> <file>...
#
# Uploads go to FORGE_API directly (over the private network), never through the
# public tunnel, whose request size cap a large asset would hit.
set -euo pipefail
if [ $# -lt 3 ]; then
echo "usage: bash wai/quickstart/publish.sh <tag> <title> <file>..." >&2
exit 2
fi
TAG="$1"; TITLE="$2"; shift 2
: "${FORGE_API:?}" "${FORGE_REPO:?}" "${FORGE_TOKEN:?}"
AUTH="Authorization: token $FORGE_TOKEN"
BASE="$FORGE_API/repos/$FORGE_REPO/releases"
release_id() { curl -fsS -H "$AUTH" "$BASE/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id", ""))' 2>/dev/null || true; }
ID="$(release_id)"
if [ -z "$ID" ]; then
BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The WAI command line and what `wai dev` runs, for macOS, Linux and Windows: each archive holds bin/ (wai, joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack), share/wai/web/ (the room client), a README and the licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")"
curl -fsS -H "$AUTH" -H 'content-type: application/json' -X POST "$BASE" -d "$BODY" > /dev/null 2>&1 || true
# Builds run side by side; one of them created it if this did not.
for _ in 1 2 3 4 5 6; do ID="$(release_id)"; [ -n "$ID" ] && break; sleep 3; done
fi
[ -n "$ID" ] || { echo "::error::no release for $TAG"; exit 1; }
for f in "$@"; do
name="$(basename "$f")"
for old in $(curl -fsS -H "$AUTH" "$BASE/$ID/assets" | NAME="$name" python3 -c 'import json,os,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin) if a["name"] == os.environ["NAME"]))'); do
curl -fsS -H "$AUTH" -X DELETE "$BASE/$ID/assets/$old" > /dev/null
done
curl -fsS -H "$AUTH" -X POST "$BASE/$ID/assets?name=$name" -F "attachment=@$f;type=application/octet-stream" -o /dev/null -w "uploaded $name: http %{http_code}\n"
done

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# The quickstart as a new user runs it, from the archive alone, timed step by step:
#
# bash wai/quickstart/test/clean-machine.sh <archive.tar.gz> [--browser]
#
# A machine with nothing of WAI on it: a fresh HOME and working directory, a PATH of the system's
# own directories, no cargo and no repository. The steps are the quickstart's:
# 1. check the archive against its .sha256 beside it, and unpack it;
# 2. wai hello; 3. wai verify; 4. wai create my-room --template video-room;
# 5. wai dev --check in the project: the relay, the room service and the page on one origin,
# each checked through it;
# 6. with --browser (BROWSER_BIN and PLAYWRIGHT_HOST set): the project's page joined in two
# browser pages, each decoding the other (the video-room example's test).
# It prints each step's time, the total, and a JSON line; it exits non-zero when a step fails,
# naming it. Downloading the archive is not timed: the archive is given.
set -uo pipefail
[ $# -ge 1 ] || { sed -n '2,16p' "$0"; exit 2; }
ARCHIVE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
BROWSER=0; [ "${2:-}" = --browser ] && BROWSER=1
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(mktemp -d)
export HOME="$ROOT/home" TMPDIR="$ROOT/tmp"
mkdir -p "$HOME" "$TMPDIR" "$ROOT/work"
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
[ "$(uname -s)" = Darwin ] && [ "$BROWSER" = 1 ] && export PATH="/opt/homebrew/bin:$PATH" # node, for the browser step alone
cd "$ROOT/work"
now() { python3 -c 'import time; print(time.monotonic())'; }
T0=$(now)
steps=() failed=""
step() { # <name> <command...>
local name="$1"; shift
local s; s=$(now)
if "$@" > "$ROOT/$name.log" 2>&1; then
steps+=("$name $(python3 -c "print(round($(now) - $s, 2))")")
else
steps+=("$name FAILED")
failed="$name"
echo "FAIL $name:"; tail -15 "$ROOT/$name.log"
fi
}
unpack() {
local want; want=$(cut -d' ' -f1 "$ARCHIVE.sha256")
local got; got=$( (command -v sha256sum > /dev/null && sha256sum "$ARCHIVE" || shasum -a 256 "$ARCHIVE") | cut -d' ' -f1)
[ "$got" = "$want" ] || { echo "SHA-256 $got, not $want"; return 1; }
tar -xzf "$ARCHIVE"
}
step unpack unpack
BIN=$(ls -d "$ROOT"/work/wai-*/bin 2>/dev/null | head -1)
export PATH="$BIN:$PATH"
[ -z "$failed" ] && step hello wai hello
[ -z "$failed" ] && step verify wai verify wai-hello
[ -z "$failed" ] && step create wai create my-room --template video-room
[ -z "$failed" ] && step dev-check bash -c 'cd my-room && wai dev --check --port 0'
if [ -z "$failed" ] && [ "$BROWSER" = 1 ]; then
step two-tabs env WAI_BIN="$BIN/wai" node "$HERE/../../examples/video-room/test/video-room.test.mjs"
fi
TOTAL=$(python3 -c "print(round($(now) - $T0, 2))")
echo "the quickstart from $(basename "$ARCHIVE") on $(uname -sm), a fresh HOME and directory:"
for s in "${steps[@]}"; do set -- $s; if [ "$2" = FAILED ]; then printf ' %-10s FAILED\n' "$1"; else printf ' %-10s %s s\n' "$1" "$2"; fi; done
grep -h 'checks held' "$ROOT/dev-check.log" 2>/dev/null | sed 's/^/ /'
echo " total $TOTAL s${failed:+ (stopped at $failed)}"
python3 -c 'import json,sys; s=sys.argv[3:]; print(json.dumps({"archive": sys.argv[1], "total_s": float(sys.argv[2]), "steps": dict((x.split()[0], x.split()[1]) for x in s), "ok": all("FAILED" not in x for x in s)}))' "$(basename "$ARCHIVE")" "$TOTAL" "${steps[@]}"
rm -rf "$ROOT"
[ -z "$failed" ]

130
wai/quickstart/test/lib.mjs Normal file
View file

@ -0,0 +1,130 @@
// What the examples' browser tests share: `wai dev` started in a project directory on a port
// of its own, a headless browser with a fake camera and microphone, and the checks each test
// prints and counts.
//
// Environment:
// WAI_BIN the `wai` binary (default: `wai` on PATH)
// WAI_RELAY_BIN, WAI_ROOM_BIN, WAI_WEB passed to `wai dev` (it also finds them beside `wai`)
// BROWSER_BIN the browser's executable (default: the automation package's own)
// PLAYWRIGHT_HOST the automation package's node_modules
// Every address is loopback, and every wait has a bound.
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(path.dirname(new URL(import.meta.url).pathname), "node_modules"), "x"));
const { chromium } = require("playwright");
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const STEP = 60000;
export async function until(what, ms, f, every = 250) {
const end = Date.now() + ms;
let last;
for (;;) {
try {
last = await f();
if (last) return last;
} catch (e) {
last = e;
}
if (Date.now() > end) throw new Error(`${what}: not within ${ms} ms (last: ${last instanceof Error ? last.message : JSON.stringify(last)?.slice(0, 400)})`);
await sleep(every);
}
}
const results = [];
export function check(what, ok, detail = "") {
results.push({ what, ok: !!ok, detail });
console.log(`${ok ? "ok " : "FAIL"} ${what}${detail ? ` (${detail})` : ""}`);
}
/// Print the tally and exit: 0 when every check held and there was at least one.
export function finish(name) {
const bad = results.filter((r) => !r.ok).length;
console.log(`${name}: ${results.length - bad} of ${results.length} checks held`);
process.exit(bad || !results.length ? 1 : 0);
}
export function tmpdir(label) {
return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `wai-example-${label}-`));
}
/// A copy of an example made by `wai create`, as a person makes one.
export async function create(template, parent) {
const dir = path.join(parent, template);
await run([process.env.WAI_BIN || "wai", "create", dir, "--template", template]);
return dir;
}
export function run(argv, opts = {}) {
return new Promise((ok, no) => {
const p = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], ...opts });
let out = "";
p.stdout.on("data", (b) => (out += b));
p.stderr.on("data", (b) => (out += b));
p.on("exit", (code) => (code === 0 ? ok(out) : no(new Error(`${argv.join(" ")}: exit ${code}: ${out.slice(-600)}`))));
});
}
/// `wai dev` in `cwd` with `args`, on a free port, in a process group of its own so that
/// stopping it stops the relay and the service it started. Resolves once it says it is ready.
export async function dev(cwd, args = []) {
const p = spawn(process.env.WAI_BIN || "wai", ["dev", "--port", "0", "--for", "900", ...args], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
p.stdout.on("data", (b) => (out += b));
p.stderr.on("data", (b) => (out += b));
const exited = new Promise((r) => p.on("exit", (code) => r(code)));
const url = await until("wai dev ready", 60000, () => {
if (p.exitCode != null) throw new Error(`wai dev exited ${p.exitCode}: ${out.slice(-800)}`);
const m = out.match(/wai dev: ready on (http:\/\/127\.0\.0\.1:\d+)/);
return m && m[1];
}, 100);
return {
url,
output: () => out,
async stop() {
if (p.exitCode == null) {
try { process.kill(-p.pid, "SIGINT"); } catch { /* gone */ }
const e = await Promise.race([exited, sleep(15000).then(() => null)]);
if (e === null) try { process.kill(-p.pid, "SIGKILL"); } catch { /* gone */ }
}
},
};
}
export async function launch() {
const opts = {
headless: true,
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling", "--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows"],
};
if (process.env.BROWSER_BIN) opts.executablePath = process.env.BROWSER_BIN;
return chromium.launch(opts);
}
/// A page of its own (its own context, so its own key), with the camera and microphone granted.
export async function open(browser, url, name) {
const ctx = await browser.newContext({ permissions: ["camera", "microphone"] });
const p = await ctx.newPage();
p.name = name;
p.logs = [];
p.on("console", (m) => p.logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => p.logs.push(`[pageerror] ${e.stack || e}`));
await p.goto(url, { waitUntil: "domcontentloaded" });
return p;
}
/// The frame of `p` whose URL holds `part`.
export async function frame(p, part) {
return until(`${p.name}: the frame ${part}`, STEP, () => p.frames().find((f) => f.url().includes(part)), 100);
}
/// Each page's console, written beside the test's other output when a check failed.
export function dump(pages, dir) {
for (const p of pages) {
try { fs.writeFileSync(path.join(dir, `${p.name}.console.log`), p.logs.join("\n")); } catch { /* none */ }
}
}

58
wai/quickstart/test/package-lock.json generated Normal file
View file

@ -0,0 +1,58 @@
{
"name": "wai-examples-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wai-examples-tests",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View file

@ -0,0 +1,10 @@
{
"name": "wai-examples-tests",
"private": true,
"description": "The WAI examples' browser tests: the automation package drives the host's browser against `wai dev` on loopback.",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"playwright": "1.60.0"
}
}

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Run the examples' tests, each as a person runs the example: through `wai create` and `wai dev`.
#
# bash wai/quickstart/test/run-examples.sh portable|browser|apple|android [<example>...]
#
# portable no browser, no macOS: remote-desktop, package-and-deliver, verify-receipts,
# encoder-ingest (the last two build Rust projects against this checkout)
# browser in a headless browser: video-room, watch-together, messaging, remote-desktop,
# package-and-deliver
# apple apple-app (macOS, Xcode)
# android android-app (a JDK 17)
#
# The binaries come from WAI_DIST, an unpacked quickstart archive (its bin/ goes first on PATH,
# and `wai dev` finds the relay, the room service and the room client beside itself), or from
# PATH as it is. For `browser`: BROWSER_BIN (the host's browser; otherwise the automation
# package's own), and the automation package installed from package-lock.json outside the tree
# (npm ci --ignore-scripts) unless PLAYWRIGHT_HOST names one. Each test's own output is printed;
# the last line says which passed.
set -uo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
EX=$(cd "$HERE/../../examples" && pwd)
[ $# -ge 1 ] || { sed -n '2,20p' "$0"; exit 2; }
SET=$1; shift
if [ -n "${WAI_DIST:-}" ]; then export PATH="$WAI_DIST/bin:$PATH"; fi
command -v wai > /dev/null || { echo "no wai on PATH (give WAI_DIST)"; exit 2; }
case "$SET" in
portable) tests=(remote-desktop/test/remote-desktop.test.sh package-and-deliver/test/package-and-deliver.test.sh verify-receipts/test/verify-receipts.test.sh encoder-ingest/test/encoder-ingest.test.sh) ;;
browser) tests=(video-room/test/video-room.test.mjs watch-together/test/watch-together.test.mjs messaging/test/messaging.test.mjs remote-desktop/test/remote-desktop.test.mjs package-and-deliver/test/package-and-deliver.test.mjs) ;;
apple) tests=(apple-app/test/apple-app.test.sh) ;;
android) tests=(android-app/test/android-app.test.sh) ;;
*) echo "portable, browser, apple or android"; exit 2 ;;
esac
if [ $# -gt 0 ]; then
keep=()
for t in "${tests[@]}"; do for n in "$@"; do [ "${t%%/*}" = "$n" ] && keep+=("$t"); done; done
tests=("${keep[@]}")
fi
if [ "$SET" = browser ] && [ -z "${PLAYWRIGHT_HOST:-}" ]; then
PW=$(mktemp -d)
cp "$HERE/package.json" "$HERE/package-lock.json" "$PW/"
(cd "$PW" && npm ci --ignore-scripts --no-audit --no-fund > /dev/null) || { echo "npm ci failed"; exit 2; }
export PLAYWRIGHT_HOST="$PW/node_modules"
fi
passed=() failed=()
for t in "${tests[@]}"; do
echo "== $t"
start=$(date +%s)
case "$t" in
*.mjs) node "$EX/$t" ;;
*) bash "$EX/$t" ;;
esac
s=$?
echo "== $t: exit $s in $(( $(date +%s) - start )) s"
if [ $s = 0 ]; then passed+=("${t%%/*}"); else failed+=("${t%%/*}"); fi
done
echo "examples ($SET): ${#passed[@]} passed${passed[*]:+ (${passed[*]})}; ${#failed[@]} failed${failed[*]:+ (${failed[*]})}"
[ ${#failed[@]} = 0 ]

View file

@ -38,7 +38,9 @@ serde_json = { version = "1", features = ["preserve_order", "raw_value"] }
# --features world and none of the C codec chain. Registration (the
# capability strings in codecs/mod.rs) is NOT gated — only the decoders.
# Image
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "avif"], optional = true } # PNG, JPEG decode, AVIF encode (via ravif)
# PNG alone here; `codecs` adds JPEG, WebP and AVIF (encode via ravif), so the `floor` build
# links no codec but PNG's pure-Rust one.
image = { version = "0.25", default-features = false, features = ["png"], optional = true } # PNG, JPEG decode, AVIF encode (via ravif)
jpeg-encoder = { version = "0.7", optional = true } # pure-Rust JPEG with optimized Huffman tables
libavif-sys = { version = "0.17", optional = true, default-features = false, features = ["codec-dav1d"] } # AVIF decode through libavif itself, with the reader limits as its decoder limits
jpegxl-rs = { version = "0.14", optional = true } # libjxl bindings (links system libjxl; 0.11.x and 0.12.x both work)
@ -132,7 +134,8 @@ default = ["codecs"]
# Disable for targets where the C codec chain can't follow (wasm32) —
# the envelope, manifest, and `world` engine build without it.
codecs = [
"dep:image", "dep:jpeg-encoder", "dep:libavif-sys", "dep:jpegxl-rs", "dep:jpegxl-sys",
"dep:image", "image/jpeg", "image/webp", "image/avif",
"dep:jpeg-encoder", "dep:libavif-sys", "dep:jpegxl-rs", "dep:jpegxl-sys",
"dep:opus", "dep:ogg", "dep:claxon", "dep:flac-bound",
"dep:rav1e", "dep:dav1d",
"dep:zstd", "dep:xz2", "dep:byteorder",
@ -142,6 +145,16 @@ codecs = [
# selection), with no codec library: it builds and is tested where libjxl and the
# other C codec libraries are absent. `codecs` adds the codec wrappers to it.
ffi = []
# The mandatory floor of SPEC §5 (PNG, FLAC, zstd) with no system library: PNG through the
# `image` crate's pure-Rust PNG codec, FLAC decoded by claxon (pure Rust), and zstd from its
# reference source, which its crate compiles into the binary. `wai decode` opens the three, and
# `wai encode` writes PNG and zstd (FLAC encoding goes through libFLAC: a build with `codecs`).
# The quickstart's `wai` is built with it alone (`--no-default-features --features floor`).
floor = ["dep:image", "dep:claxon", "dep:zstd"]
# What the quickstart's `wai` holds: the floor, and `wai hello` and `wai verify` (a receipt
# sealed and checked as http-delivery §8 lays receipts out). Pure Rust and the floor's zstd:
# no system library. `wai create` and `wai dev` need no feature.
quickstart = ["floor", "http", "receipt_claims", "dep:getrandom"]
# Pulls in the ONNX runtime + zstd unpack so the native sink can
# decode `wai.neural.<...>` envelopes. Off by default — the bare
# container + zeroth codecs build with no ML dep chain.
@ -1444,6 +1457,16 @@ required-features = ["codecs"]
# A program, not a libtest suite: it lists its own executable's symbols, so the test
# harness's code must not be in it (see the file's doc).
[[test]]
name = "floor"
path = "tests/floor.rs"
required-features = ["floor"]
[[test]]
name = "cli_dx"
path = "tests/cli_dx.rs"
required-features = ["quickstart"]
[[test]]
name = "jpegai_decode_symbols"
path = "tests/jpegai_decode_symbols.rs"

View file

@ -6,7 +6,11 @@
//! verification; production paths go through the Rust API or the C FFI.
//!
//! Every command except `encode` and `decode` builds without the `codecs`
//! feature (no system codec libraries), which is how CI runs it. `generate` (make a
//! feature (no system codec libraries), which is how CI runs it. With the `floor`
//! feature alone, `encode` writes PNG and zstd and `decode` opens the mandatory floor
//! (PNG, FLAC, zstd), still with no system library. `up` (a local relay, room service
//! and room page on one origin) and `create` (a new project from an example) need no
//! feature. `generate` (make a
//! `wai.generate.spectral` picture natively and print its SHA-256) needs the feature
//! `generate`, and `score build|render` (write a `WSCR` from integer note fields,
//! render one and print its digests) the feature `score`; neither needs a codec library.
@ -15,10 +19,12 @@ use std::env;
use std::fs;
use std::process::ExitCode;
#[cfg(any(feature = "codecs", feature = "floor"))]
use wai::codecs::{audio, image, text};
#[cfg(feature = "codecs")]
use wai::codecs::{audio, image, text, video};
use wai::codecs::video;
use wai::{Conditioning, Manifest, ModelRequirement, Wai};
#[cfg(feature = "codecs")]
#[cfg(any(feature = "codecs", feature = "floor"))]
use wai::*;
#[cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int"))]
@ -48,6 +54,11 @@ mod score;
#[path = "wai_cli/encode.rs"]
mod platform_encode_cli;
// The developer's path: `hello`, `create`, `dev`, `verify`, one module a verb, named in one
// table (dx::VERBS).
#[path = "wai_cli/dx/mod.rs"]
mod dx;
#[cfg(feature = "http")]
#[path = "wai_cli/http.rs"]
mod http;
@ -91,7 +102,10 @@ fn usage() {
#[cfg(not(feature = "platform_encode"))]
eprintln!("wai encode avc|hevc …, wai encoders, wai encode-record verify … a build with `platform_encode`");
eprintln!();
eprintln!("encode and decode need a build with the `codecs` feature.");
dx::usage();
eprintln!();
eprintln!("encode and decode need a build with the `codecs` feature; a build with `floor` alone");
eprintln!("encodes png and zstd and decodes png, flac and zstd (the mandatory floor, no system library).");
eprintln!("codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz");
eprintln!("inputs:");
eprintln!(" image codecs: .rgb (with .meta containing 'H W' ASCII) or PNG/JPEG file");
@ -101,14 +115,19 @@ fn usage() {
}
/// The capabilities this binary decodes natively: the crate's sink set
/// (`wai::sink_capabilities`) in a build with the `codecs` feature, and none
/// without it, where `decode` is unavailable.
/// (`wai::sink_capabilities`) in a build with the `codecs` feature, the mandatory
/// floor (`wai::floor_capabilities`: PNG, FLAC, zstd) in a build with `floor` alone,
/// and none without either, where `decode` is unavailable.
fn native_capabilities() -> &'static [&'static str] {
#[cfg(feature = "codecs")]
{
wai::sink_capabilities()
}
#[cfg(not(feature = "codecs"))]
#[cfg(all(feature = "floor", not(feature = "codecs")))]
{
wai::floor_capabilities()
}
#[cfg(not(any(feature = "codecs", feature = "floor")))]
{
&[]
}
@ -137,7 +156,7 @@ fn companion_args(args: &[String]) -> Result<Vec<(String, String)>, String> {
Ok(pairs)
}
#[cfg(feature = "codecs")]
#[cfg(any(feature = "codecs", feature = "floor"))]
fn read_meta(path: &str) -> Result<Vec<u32>, String> {
let s = fs::read_to_string(format!("{path}.meta"))
.map_err(|e| format!("missing {path}.meta: {e}"))?;
@ -145,17 +164,24 @@ fn read_meta(path: &str) -> Result<Vec<u32>, String> {
.collect()
}
#[cfg(not(feature = "codecs"))]
#[cfg(not(any(feature = "codecs", feature = "floor")))]
fn encode(_codec: &str, _input: &str, _out: &str, _q: u8) -> Result<(), String> {
Err("this `wai` was built without the codecs feature: encode is unavailable".into())
}
#[cfg(not(feature = "codecs"))]
#[cfg(not(any(feature = "codecs", feature = "floor")))]
fn decode(_input: &str, _out: &str) -> Result<(), String> {
Err("this `wai` was built without the codecs feature: decode is unavailable".into())
}
#[cfg(feature = "codecs")]
/// A codec this build does not hold: a build with `floor` alone encodes PNG and zstd and
/// decodes the mandatory floor, and every other codec needs `codecs`.
#[cfg(all(feature = "floor", not(feature = "codecs")))]
fn needs_codecs(what: &str) -> String {
format!("{what} needs a build with the `codecs` feature; this `wai` holds the mandatory floor alone (encode png, zstd; decode png, flac, zstd)")
}
#[cfg(any(feature = "codecs", feature = "floor"))]
fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let (cap, kind, payload): (&str, &str, Vec<u8>) = match codec {
"png" => {
@ -165,6 +191,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let p = image::png_encode(&rgb, h, w).map_err(|e| format!("{:?}", e))?;
(CAP_IMAGE_PNG, "png", p)
}
#[cfg(feature = "codecs")]
"jpeg" => {
let meta = read_meta(input)?;
let (h, w) = (meta[0], meta[1]);
@ -172,6 +199,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let p = image::jpeg_encode(&rgb, h, w, q).map_err(|e| format!("{:?}", e))?;
(CAP_IMAGE_JPEG, "jpeg", p)
}
#[cfg(feature = "codecs")]
"avif" => {
let meta = read_meta(input)?;
let (h, w) = (meta[0], meta[1]);
@ -179,6 +207,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let p = image::avif_encode(&rgb, h, w, q).map_err(|e| format!("{:?}", e))?;
(CAP_IMAGE_AVIF, "avif", p)
}
#[cfg(feature = "codecs")]
"jxl-lossless" => {
let meta = read_meta(input)?;
let (h, w) = (meta[0], meta[1]);
@ -186,6 +215,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let p = image::jxl_encode(&rgb, h, w, None).map_err(|e| format!("{:?}", e))?;
(CAP_IMAGE_JXL, "jxl", p)
}
#[cfg(feature = "codecs")]
"jxl" => {
let meta = read_meta(input)?;
let (h, w) = (meta[0], meta[1]);
@ -194,6 +224,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
.map_err(|e| format!("{:?}", e))?;
(CAP_IMAGE_JXL, "jxl", p)
}
#[cfg(feature = "codecs")]
"opus" => {
let meta = read_meta(input)?;
let sr = meta[0];
@ -206,6 +237,7 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
.map_err(|e| format!("{:?}", e))?;
(CAP_AUDIO_OPUS, "opus", p)
}
#[cfg(feature = "codecs")]
"flac" => {
let meta = read_meta(input)?;
let sr = meta[0];
@ -223,12 +255,14 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
.map_err(|e| format!("{:?}", e))?;
(CAP_TEXT_ZSTD, "zstd", p)
}
#[cfg(feature = "codecs")]
"xz" => {
let data = fs::read(input).map_err(|e| e.to_string())?;
let p = text::xz_encode(&data, (q as u32).clamp(0, 9))
.map_err(|e| format!("{:?}", e))?;
(CAP_TEXT_XZ, "xz", p)
}
#[cfg(feature = "codecs")]
"av1" | "av1-lossless" => {
// Video: input is a packed-RGB blob with .meta containing
// "N H W fps_num fps_den"
@ -251,6 +285,10 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
let cap = if lossless { CAP_VIDEO_AV1_LOSSLESS } else { CAP_VIDEO_AV1 };
(cap, "av1", p)
}
#[cfg(not(feature = "codecs"))]
"jpeg" | "avif" | "jxl-lossless" | "jxl" | "opus" | "flac" | "xz" | "av1" | "av1-lossless" => {
return Err(needs_codecs(&format!("encoding {codec}")))
}
c => return Err(format!("unknown codec '{c}'")),
};
@ -260,13 +298,13 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "codecs")]
#[cfg(any(feature = "codecs", feature = "floor"))]
fn decode(input: &str, out: &str) -> Result<(), String> {
let wai = Wai::read(input).map_err(|e| e.to_string())?;
// SPEC §4 dispatch, shared with `inspect` so the two agree: the primary
// capability if this sink decodes it (step 3), else the fallback (step 5).
// A companion-only capability is never dispatched standalone (SPEC §5).
let native = wai::sink_capabilities();
let native = native_capabilities();
let roles = wai.check_component_roles();
// The capabilities this crate decodes natively decode against no parameter
// set, so no pin applies to them and the verifier is never called.
@ -290,21 +328,25 @@ fn decode(input: &str, out: &str) -> Result<(), String> {
fs::write(out, &rgb).map_err(|e| e.to_string())?;
fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_IMAGE_JPEG => {
let (rgb, h, w) = image::jpeg_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
fs::write(out, &rgb).map_err(|e| e.to_string())?;
fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_IMAGE_AVIF => {
let (rgb, h, w) = image::avif_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
fs::write(out, &rgb).map_err(|e| e.to_string())?;
fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_IMAGE_JXL => {
let (rgb, h, w) = image::jxl_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
fs::write(out, &rgb).map_err(|e| e.to_string())?;
fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_AUDIO_OPUS => {
let (samples, sr) = audio::opus_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
let bytes: Vec<u8> = samples.iter().flat_map(|f| f.to_le_bytes()).collect();
@ -321,10 +363,12 @@ fn decode(input: &str, out: &str) -> Result<(), String> {
let d = text::zstd_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
fs::write(out, &d).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_TEXT_XZ => {
let d = text::xz_decode(&wai.payload).map_err(|e| format!("{:?}", e))?;
fs::write(out, &d).map_err(|e| e.to_string())?;
}
#[cfg(feature = "codecs")]
c if c == CAP_VIDEO_AV1 || c == CAP_VIDEO_AV1_LOSSLESS => {
// One frame at a time to the file: a payload within the reader limits can
// decode to far more than fits in memory at once.
@ -930,6 +974,13 @@ fn main() -> ExitCode {
usage();
return ExitCode::from(2);
}
if let Some(r) = dx::dispatch(&args) {
return match r {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => die(e),
};
}
let r = match args[0].as_str() {
#[cfg(feature = "platform_encode")]
"encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => platform_encode_cli::encode(&args[1..]),

View file

@ -0,0 +1,163 @@
//! `wai create <dir> --template <name>`: a new project from one of the examples in
//! `wai/examples/`, whose files this binary holds ([`templates`]), so it needs neither the
//! repository nor a network.
//!
//! It writes into a directory that does not exist or is empty, and never over a file. Each
//! example's `test/` directory is the repository's check that the example works, and is not
//! part of the project. Where a template names a generated file (a watch room's sample
//! sound), it is made here.
#[path = "templates.rs"]
mod templates;
use super::{flag, opt, positional, project};
use std::path::Path;
pub const USAGE: &str = "wai create <dir> --template <name> | wai create --list [--json] a new project from one of the examples";
/// A file of a template: its path in the project, and its bytes.
pub type File = (&'static str, &'static [u8]);
/// A file made when the project is created, by a function of this binary.
pub type Generated = (&'static str, fn() -> Vec<u8>);
/// A template: its name, the line `--list` shows, and its files.
pub struct Template {
pub name: &'static str,
pub title: &'static str,
pub files: &'static [File],
pub generated: &'static [Generated],
}
pub fn main(args: &[String]) -> Result<bool, String> {
if flag(args, "--list") {
if flag(args, "--json") {
let v: Vec<serde_json::Value> = templates::TEMPLATES
.iter()
.map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).collect::<Vec<_>>() }))
.collect();
println!("{}", serde_json::to_string_pretty(&v).expect("json"));
} else {
for t in templates::TEMPLATES {
println!("{:<20} {}", t.name, t.title);
}
}
return Ok(true);
}
if let Some(a) = args.iter().find(|a| a.starts_with("--") && *a != "--template") {
return Err(format!("wai create: {a}: not an option ({USAGE})"));
}
let pos = positional(args, &["--template"]);
let (Some(dir), Some(name), 1) = (pos.first(), opt(args, "--template"), pos.len()) else {
return Err(format!("{USAGE}\ntemplates: {}", names()));
};
let t = templates::TEMPLATES.iter().find(|t| t.name == name).ok_or_else(|| format!("no template {name}; the templates: {}", names()))?;
let dir = Path::new(dir);
write(t, dir)?;
println!("wai create: {} in {}", t.title, dir.display());
for (f, _) in t.files.iter() {
println!(" {f}");
}
for (f, _) in t.generated.iter() {
println!(" {f} (made now)");
}
println!("Next:\n cd {}\n wai dev then open the link it prints; the README says what you will see", dir.display());
Ok(true)
}
fn names() -> String {
templates::TEMPLATES.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
}
/// The template's files in `dir`, which must not exist or must be empty. A project file is
/// written where the template carries none.
fn write(t: &Template, dir: &Path) -> Result<(), String> {
if dir.exists() && std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?.next().is_some() {
return Err(format!("{}: not empty; name a new directory", dir.display()));
}
let mut files: Vec<(&str, Vec<u8>)> = t.files.iter().map(|(p, b)| (*p, b.to_vec())).collect();
files.extend(t.generated.iter().map(|(p, f)| (*p, f())));
if !files.iter().any(|(p, _)| *p == project::FILE) {
files.push((project::FILE, project::minimal(t.name).into_bytes()));
}
for (rel, bytes) in &files {
if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") {
return Err(format!("{rel}: not a path a template may write"));
}
let p = dir.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&p).map_err(|e| format!("{}: {e}", p.display()))?;
std::io::Write::write_all(&mut f, bytes).map_err(|e| format!("{}: {e}", p.display()))?;
}
// The project file the template wrote must read: a template is checked as it is made.
project::load(dir)?.ok_or("the project file is missing")?;
Ok(())
}
/// A sample sound for a watch room: twenty seconds of a tone that steps in pitch each
/// second, 8 kHz, 16-bit, mono, so any stretch of it is told from another by ear.
pub fn tone_wav() -> Vec<u8> {
let (rate, seconds) = (8000u32, 20u32);
let n = rate * seconds;
let mut b = Vec::with_capacity(44 + 2 * n as usize);
b.extend_from_slice(b"RIFF");
b.extend_from_slice(&(36 + 2 * n).to_le_bytes());
b.extend_from_slice(b"WAVEfmt ");
b.extend_from_slice(&16u32.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&rate.to_le_bytes());
b.extend_from_slice(&(2 * rate).to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&16u16.to_le_bytes());
b.extend_from_slice(b"data");
b.extend_from_slice(&(2 * n).to_le_bytes());
for i in 0..n {
let f = 220.0 + 20.0 * ((i / rate) % 12) as f64;
let v = (6000.0 * (2.0 * std::f64::consts::PI * f * i as f64 / rate as f64).sin()).round() as i16;
b.extend_from_slice(&v.to_le_bytes());
}
b
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wai-create-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn every_template_writes_a_project_that_reads() {
assert!(!templates::TEMPLATES.is_empty());
for t in templates::TEMPLATES {
let d = tmp(t.name);
write(t, &d).unwrap_or_else(|e| panic!("{}: {e}", t.name));
let p = project::load(&d).unwrap().unwrap();
assert_eq!(p.template.as_deref(), Some(t.name), "{}: its project file names it", t.name);
assert!(d.join("README.md").is_file(), "{}: a README", t.name);
let _ = std::fs::remove_dir_all(&d);
}
}
#[test]
fn a_directory_that_holds_anything_is_never_written_to() {
let d = tmp("full");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("mine.txt"), "mine").unwrap();
assert!(write(&templates::TEMPLATES[0], &d).unwrap_err().contains("not empty"));
assert_eq!(std::fs::read_to_string(d.join("mine.txt")).unwrap(), "mine");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_tone_is_a_wav_of_twenty_seconds() {
let b = tone_wav();
assert_eq!(&b[0..4], b"RIFF");
assert_eq!(b.len(), 44 + 2 * 8000 * 20);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,161 @@
//! `wai hello`: WAI's four steps on a picture made here, each said as it runs.
//!
//! 1. **Name.** A picture is packed into a `WAI1` envelope whose manifest names the
//! capability it needs, `wai.image.png` (PNG, of the mandatory floor).
//! 2. **Dispatch.** The envelope is read back and dispatched by SPEC §4 against the
//! capabilities this binary decodes.
//! 3. **Rebuild.** The capability runs, and the picture it rebuilds is compared with the
//! one packed, byte for byte.
//! 4. **Receipt.** The object's content hash goes into a group receipt signed with an
//! Ed25519 key made for this run (jwp-receipts §2.4), written as http-delivery §8 lays
//! receipts out beside an object: `<cid>.wai`, its index `<cid>.wai.receipts` and the
//! group's file `rcpt/<link hash>.json`. Then they are checked as `wai verify` checks
//! them.
//!
//! The key's secret is never written: the receipt proves the bytes were signed by the key
//! it names, and a later `wai verify` checks that again, offline.
pub const USAGE: &str = "wai hello [<dir>] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to <dir> (wai-hello)";
#[cfg(not(feature = "quickstart"))]
pub fn main(_: &[String]) -> Result<bool, String> {
Err("`wai hello` needs a build with the feature `quickstart`".into())
}
#[cfg(feature = "quickstart")]
pub fn main(args: &[String]) -> Result<bool, String> {
use super::hex;
use std::path::PathBuf;
use wai::codecs::image;
use wai::http_binding::receipts::{IndexItem, RECEIPT_DIR, group_file, index_file};
use wai::jwp_delivery::{GroupBuilder, ObjectLabel, WaiReceiptProperty};
use wai::{CAP_IMAGE_PNG, Conditioning, Manifest, ModelRequirement, Wai};
if let Some(a) = args.iter().find(|a| a.starts_with("--")) {
return Err(format!("wai hello: {a}: not an option ({USAGE})"));
}
let pos = super::positional(args, &[]);
if pos.len() > 1 {
return Err(USAGE.into());
}
let dir = PathBuf::from(pos.first().copied().unwrap_or("wai-hello"));
// Only a directory that is empty, or one an earlier `wai hello` wrote, is written to.
if dir.exists() {
let theirs = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten().any(|e| {
let n = e.file_name().to_string_lossy().into_owned();
!(n == "rcpt" || n.ends_with(".wai") || n.ends_with(".wai.receipts"))
});
if theirs {
return Err(format!("{}: holds files `wai hello` did not write; name another directory", dir.display()));
}
// An earlier run's object, index and receipt files, and nothing else.
for e in std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten() {
let p = e.path();
if e.file_name() == "rcpt" {
for r in std::fs::read_dir(&p).map_err(|e| e.to_string())?.flatten() {
if r.file_name().to_string_lossy().ends_with(".json") {
let _ = std::fs::remove_file(r.path());
}
}
} else {
let _ = std::fs::remove_file(&p);
}
}
}
std::fs::create_dir_all(dir.join(RECEIPT_DIR)).map_err(|e| format!("{}: {e}", dir.display()))?;
println!("wai hello: WAI's four steps, on this machine\n");
// 1. Name.
let (w, h) = (64u32, 40u32);
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let band = ((x / 8 + y / 8) % 2) as u8;
rgb.extend_from_slice(&[(x * 4) as u8, (y * 6) as u8, 96 + 64 * band]);
}
}
let png = image::png_encode(&rgb, h, w).map_err(|e| format!("{e:?}"))?;
let manifest = Manifest::new(crate::media_for(CAP_IMAGE_PNG), "replicate", ModelRequirement::new(CAP_IMAGE_PNG), Conditioning::new("png"));
let envelope = Wai::new(manifest, png).to_bytes().map_err(|e| e.to_string())?;
let cid = wai::jwp_hash::content_hash(&envelope);
let object = dir.join(format!("{}.wai", hex(&cid)));
std::fs::write(&object, &envelope).map_err(|e| e.to_string())?;
println!("1 Name a {w}×{h} picture, packed in an envelope that names the capability it needs:");
println!(" {CAP_IMAGE_PNG} (PNG, one of the mandatory floor every sink implements)");
println!(" {} ({} bytes)\n", object.display(), envelope.len());
// 2. Dispatch.
let read = Wai::from_bytes(&envelope).map_err(|e| e.to_string())?;
let native = crate::native_capabilities();
let d = read.dispatch_verified(|c| native.contains(&c), wai::container::no_parameter_sets()).map_err(|e| format!("inert at this sink: {e:?}"))?;
println!("2 Dispatch this sink decodes {} natively;", native.join(", "));
println!(" the envelope names {}, so that capability runs (SPEC §4). One it does not", d.capability);
println!(" hold would run the envelope's fallback, or be reported inert, never guessed\n");
// 3. Rebuild.
let (back, bh, bw) = image::png_decode(&read.payload).map_err(|e| format!("{e:?}"))?;
let exact = (bw, bh) == (w, h) && back == rgb;
println!("3 Rebuild {bw}×{bh} pixels rebuilt: {}\n", if exact { "byte for byte the picture packed in step 1" } else { "NOT the picture packed" });
if !exact {
return Ok(false);
}
// 4. Receipt.
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("no random key: {e}"))?;
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
seed.fill(0);
let pk = key.verifying_key().to_bytes();
let (start, end) = (rfc3339(0), rfc3339(1));
let mut b = GroupBuilder::default();
b.add(cid, 0, "wai hello", Some(ObjectLabel::Unmetered));
let (group, objects) = b.finish(&key, &wai::signer_id::did_key(&pk), 0, 0, &start, &end, None, true).ok_or("the group would not seal")?;
let link = group.link_hash();
std::fs::write(dir.join(format!("{RECEIPT_DIR}{}.json", hex(&link))), group_file(&group)?).map_err(|e| e.to_string())?;
let item = IndexItem {
property: WaiReceiptProperty { content_hash: cid, joules_micro: 0, acquisition: String::new(), acquisition_evidence: Vec::new(), group_receipt: link },
object_receipt: objects[0].clone(),
};
std::fs::write(dir.join(format!("{}.wai.receipts", hex(&cid))), index_file(&cid, &[item], &[])?).map_err(|e| e.to_string())?;
println!("4 Receipt the object's BLAKE3 content hash, in a group receipt signed with a key made for");
println!(" this run (Ed25519 {}…; its secret is not kept). Energy is stated as not", &hex(&pk)[..16]);
println!(" measured: this run reads no meter");
let (ok, lines) = super::verify::check_index_file(&dir.join(format!("{}.wai.receipts", hex(&cid))))?;
for l in lines {
println!(" {l}");
}
println!();
println!("Next:");
println!(" wai verify {} check the receipts again, offline", dir.display());
println!(" wai create my-room --template video-room a video room in one page");
println!(" cd my-room && wai dev run it here, and open it in two tabs");
Ok(ok)
}
/// Now plus `plus_s` seconds, as RFC 3339 in UTC, to the second.
#[cfg(feature = "quickstart")]
fn rfc3339(plus_s: u64) -> String {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + plus_s;
let (days, secs) = ((t / 86_400) as i64, t % 86_400);
// Civil from days (H. Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, (secs / 60) % 60, secs % 60)
}
#[cfg(all(test, feature = "quickstart"))]
mod tests {
#[test]
fn rfc3339_is_a_utc_time_of_day() {
let t = super::rfc3339(0);
assert_eq!(t.len(), 20);
assert!(t.ends_with('Z') && &t[4..5] == "-" && &t[10..11] == "T");
}
}

View file

@ -0,0 +1,78 @@
//! The developer's path through WAI, one verb a module, every verb in [`VERBS`]:
//!
//! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample;
//! - `create`: a new project from one of the examples (`wai/examples/`);
//! - `dev`: a relay, a room service and the room page on one origin, on this machine;
//! - `verify`: the receipts under a directory, checked offline.
//!
//! A verb is named in this table and nowhere else: renaming or regrouping one is a line
//! here. The project file a template writes (`wai.json`) is read by [`project`] alone.
pub mod project;
mod create;
mod dev;
mod hello;
mod verify;
/// One verb: its name, its one-line usage, and what runs it. `run` returns `Ok(true)` when
/// everything it checked held, `Ok(false)` when it ran and something did not (exit 1).
pub struct Verb {
pub name: &'static str,
pub usage: &'static str,
pub run: fn(&[String]) -> Result<bool, String>,
}
pub const VERBS: &[Verb] = &[
Verb { name: "hello", usage: hello::USAGE, run: hello::main },
Verb { name: "create", usage: create::USAGE, run: create::main },
Verb { name: "dev", usage: dev::USAGE, run: dev::main },
Verb { name: "verify", usage: verify::USAGE, run: verify::main },
];
/// The verb `args[0]` names, run on the rest; `None` when it names none of these.
pub fn dispatch(args: &[String]) -> Option<Result<bool, String>> {
let v = VERBS.iter().find(|v| Some(v.name) == args.first().map(String::as_str))?;
Some((v.run)(&args[1..]))
}
/// Each verb's usage line, for `wai`'s own usage.
pub fn usage() {
for v in VERBS {
eprintln!("{}", v.usage);
}
}
/// The value after `flag` among `args`.
pub(crate) fn opt<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).map(String::as_str)
}
/// Whether `flag` is among `args`.
pub(crate) fn flag(args: &[String], f: &str) -> bool {
args.iter().any(|a| a == f)
}
/// The arguments that are neither flags nor a flag's value, given the flags that take one.
pub(crate) fn positional<'a>(args: &'a [String], with_value: &[&str]) -> Vec<&'a str> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
let a = args[i].as_str();
if with_value.contains(&a) {
i += 2;
continue;
}
if !a.starts_with("--") {
out.push(a);
}
i += 1;
}
out
}
/// Lowercase hexadecimal.
#[cfg(feature = "quickstart")]
pub(crate) fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}

View file

@ -0,0 +1,149 @@
//! The project file, `wai.json`: what a template wrote, and what `wai dev` runs in the
//! project's directory. This module is the one place it is named, read and written.
//!
//! ```json
//! { "template": "video-room",
//! "title": "A video room in one page",
//! "dev": { "site": ".", "page": "/", "mode": "room", "publishers": 4,
//! "watch": "tone.wav", "watch_ms": 20000, "messaging": false, "e2ee": false } }
//! ```
//!
//! Every member is optional. `dev` gives `wai dev` its defaults; a flag on the command line
//! wins over each. Paths are relative to the file's directory. A member this reader does
//! not know is refused, so a misspelt one is never silently ignored.
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
/// The project file's name.
pub const FILE: &str = "wai.json";
/// What `wai dev` takes from a project file.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Dev {
/// The directory served at `/`, the project's own page.
pub site: Option<PathBuf>,
/// The page `wai dev` names first when it is ready (a path on the front, `/` by default
/// with a site, else the mode's page).
pub page: Option<String>,
/// `room` (the default), `watch`, `call` or `remote`.
pub mode: Option<String>,
pub publishers: Option<u32>,
/// A watch room's source, and its duration where it is not a WAV file.
pub watch: Option<PathBuf>,
pub watch_ms: Option<u64>,
/// A conversation beside the room.
pub messaging: Option<bool>,
pub e2ee: Option<bool>,
}
/// A project file as read.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Project {
/// The directory the file is in; the file's relative paths resolve against it.
pub root: PathBuf,
pub template: Option<String>,
pub title: Option<String>,
pub summary: Option<String>,
pub dev: Dev,
}
/// The project file in `dir`, or `None` when there is none.
pub fn load(dir: &Path) -> Result<Option<Project>, String> {
let path = dir.join(FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
parse(dir, &text).map(Some).map_err(|e| format!("{}: {e}", path.display()))
}
/// A project file's text, its paths resolved against `root`.
pub fn parse(root: &Path, text: &str) -> Result<Project, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
let top = v.as_object().ok_or("not a JSON object")?;
only(top, &["template", "title", "summary", "dev"], "")?;
let s = |m: &Map<String, Value>, k: &str| -> Result<Option<String>, String> {
match m.get(k) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("{k}: a string")),
}
};
let mut p = Project { root: root.to_path_buf(), template: s(top, "template")?, title: s(top, "title")?, summary: s(top, "summary")?, dev: Dev::default() };
if let Some(d) = top.get("dev") {
let d = d.as_object().ok_or("dev: an object")?;
only(d, &["site", "page", "mode", "publishers", "watch", "watch_ms", "messaging", "e2ee"], "dev.")?;
let path = |k: &str| -> Result<Option<PathBuf>, String> { Ok(s(d, k).map_err(|e| format!("dev.{e}"))?.map(|x| root.join(x))) };
let num = |k: &str| -> Result<Option<u64>, String> {
match d.get(k) {
None => Ok(None),
Some(n) => n.as_u64().map(Some).ok_or(format!("dev.{k}: a whole number")),
}
};
let boolean = |k: &str| -> Result<Option<bool>, String> {
match d.get(k) {
None => Ok(None),
Some(b) => b.as_bool().map(Some).ok_or(format!("dev.{k}: true or false")),
}
};
p.dev = Dev {
site: path("site")?,
page: s(d, "page").map_err(|e| format!("dev.{e}"))?,
mode: s(d, "mode").map_err(|e| format!("dev.{e}"))?,
publishers: num("publishers")?.map(|n| u32::try_from(n).map_err(|_| "dev.publishers: too large".to_string())).transpose()?,
watch: path("watch")?,
watch_ms: num("watch_ms")?,
messaging: boolean("messaging")?,
e2ee: boolean("e2ee")?,
};
if let Some(m) = &p.dev.mode
&& !["room", "watch", "call", "remote"].contains(&m.as_str())
{
return Err(format!("dev.mode: room, watch, call or remote, not {m}"));
}
}
Ok(p)
}
/// The text of a project file naming `template`, for `wai create` to write when the
/// template carries none of its own.
pub fn minimal(template: &str) -> String {
format!("{{\n \"template\": {}\n}}\n", Value::from(template))
}
fn only(m: &Map<String, Value>, known: &[&str], at: &str) -> Result<(), String> {
match m.keys().find(|k| !known.contains(&k.as_str())) {
Some(k) => Err(format!("{at}{k}: not a member this version reads ({})", known.join(", "))),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_project_file_reads_and_its_paths_resolve_against_its_directory() {
let p = parse(Path::new("/p"), r#"{"template":"watch-together","dev":{"site":".","mode":"watch","watch":"tone.wav","watch_ms":5000,"messaging":true}}"#).unwrap();
assert_eq!(p.template.as_deref(), Some("watch-together"));
assert_eq!(p.dev.site.as_deref(), Some(Path::new("/p/.")));
assert_eq!(p.dev.watch.as_deref(), Some(Path::new("/p/tone.wav")));
assert_eq!((p.dev.watch_ms, p.dev.messaging), (Some(5000), Some(true)));
}
#[test]
fn a_member_it_does_not_know_and_a_mode_it_does_not_run_are_refused() {
assert!(parse(Path::new("/p"), r#"{"templat":"x"}"#).unwrap_err().contains("templat"));
assert!(parse(Path::new("/p"), r#"{"dev":{"sites":"."}}"#).unwrap_err().contains("dev.sites"));
assert!(parse(Path::new("/p"), r#"{"dev":{"mode":"stage"}}"#).unwrap_err().contains("dev.mode"));
assert!(parse(Path::new("/p"), r#"{"dev":{"publishers":"4"}}"#).unwrap_err().contains("whole number"));
assert!(parse(Path::new("/p"), "[]").is_err());
}
#[test]
fn the_minimal_file_reads_back() {
assert_eq!(parse(Path::new("/p"), &minimal("video-room")).unwrap().template.as_deref(), Some("video-room"));
}
}

View file

@ -0,0 +1,130 @@
//! The templates `wai create` writes: each example of `wai/examples/` but its `test/`
//! directory, its files held in this binary. `tests/cli_dx.rs` holds this table to the
//! examples on disk, file for file and byte for byte, so the two cannot drift.
use super::Template;
macro_rules! ex {
($t:literal, $f:literal) => {
($f, include_bytes!(concat!("../../../../../examples/", $t, "/", $f)) as &[u8])
};
}
pub const TEMPLATES: &[Template] = &[
Template {
name: "video-room",
title: "A video room in one page",
files: &[
ex!("video-room", ".gitignore"),
ex!("video-room", "README.md"),
ex!("video-room", "index.html"),
ex!("video-room", "wai.json"),
],
generated: &[],
},
Template {
name: "watch-together",
title: "Watch together",
files: &[
ex!("watch-together", ".gitignore"),
ex!("watch-together", "README.md"),
ex!("watch-together", "index.html"),
ex!("watch-together", "wai.json"),
],
generated: &[("tone.wav", super::tone_wav)],
},
Template {
name: "messaging",
title: "Messaging and a call",
files: &[
ex!("messaging", ".gitignore"),
ex!("messaging", "README.md"),
ex!("messaging", "index.html"),
ex!("messaging", "wai.json"),
],
generated: &[],
},
Template {
name: "encoder-ingest",
title: "Stream from an encoder into a room",
files: &[
ex!("encoder-ingest", ".gitignore"),
ex!("encoder-ingest", "Cargo.toml"),
ex!("encoder-ingest", "README.md"),
ex!("encoder-ingest", "src/main.rs"),
ex!("encoder-ingest", "wai.json"),
],
generated: &[],
},
Template {
name: "package-and-deliver",
title: "Package a title and play it",
files: &[
ex!("package-and-deliver", ".gitignore"),
ex!("package-and-deliver", "README.md"),
ex!("package-and-deliver", "floor/v.obu"),
ex!("package-and-deliver", "index.html"),
ex!("package-and-deliver", "pack.sh"),
ex!("package-and-deliver", "player.js"),
ex!("package-and-deliver", "policy.toml"),
ex!("package-and-deliver", "test-key.hex"),
ex!("package-and-deliver", "wai.json"),
],
generated: &[],
},
Template {
name: "verify-receipts",
title: "Verify receipts in Rust and in Python",
files: &[
ex!("verify-receipts", ".gitignore"),
ex!("verify-receipts", "README.md"),
ex!("verify-receipts", "python/_blake3.py"),
ex!("verify-receipts", "python/_ed25519.py"),
ex!("verify-receipts", "python/verify_receipts.py"),
ex!("verify-receipts", "rust/Cargo.toml"),
ex!("verify-receipts", "rust/src/main.rs"),
ex!("verify-receipts", "wai.json"),
],
generated: &[],
},
Template {
name: "apple-app",
title: "A native app on Apple platforms",
files: &[
ex!("apple-app", ".gitignore"),
ex!("apple-app", "Package.swift"),
ex!("apple-app", "README.md"),
ex!("apple-app", "Sources/WaiApp/PlayerApp.swift"),
ex!("apple-app", "Sources/WaiApp/Replay.swift"),
ex!("apple-app", "Sources/WaiApp/main.swift"),
ex!("apple-app", "sample-session.json"),
ex!("apple-app", "wai.json"),
],
generated: &[],
},
Template {
name: "android-app",
title: "The native sink in Kotlin, for Android",
files: &[
ex!("android-app", ".gitignore"),
ex!("android-app", "README.md"),
ex!("android-app", "replay/build.gradle.kts"),
ex!("android-app", "replay/src/main/kotlin/Replay.kt"),
ex!("android-app", "sample-session.json"),
ex!("android-app", "settings.gradle.kts"),
ex!("android-app", "wai.json"),
],
generated: &[],
},
Template {
name: "remote-desktop",
title: "Remote desktop in a room",
files: &[
ex!("remote-desktop", ".gitignore"),
ex!("remote-desktop", "README.md"),
ex!("remote-desktop", "host.sh"),
ex!("remote-desktop", "wai.json"),
],
generated: &[],
},
];

View file

@ -0,0 +1,142 @@
//! `wai verify <path>…`: every receipt index (`*.wai.receipts`, http-delivery §8.2) under
//! each path, checked offline, as `wai http receipts` checks one over HTTP: each delivery
//! item's group receipt read from `rcpt/<link hash>.json` beside it (§8.1), its parents
//! by their links, jwp-receipts §3 steps 1–5 against the object `<cid>.wai` where it is
//! there (`delivered-verified`), or against nothing (`inclusion-verified`); and each claim
//! the index lists. Nothing is fetched from anywhere.
//!
//! A signature proves the bytes were signed by the key the receipt names. Which party holds
//! that key is a key log's to say (http-delivery §8.4); this command names the key.
pub const USAGE: &str = "wai verify <path>... the receipts under each path (receipt indexes *.wai.receipts), checked offline";
#[cfg(not(feature = "quickstart"))]
pub fn main(_: &[String]) -> Result<bool, String> {
Err("`wai verify` needs a build with the feature `quickstart`".into())
}
#[cfg(feature = "quickstart")]
pub fn main(args: &[String]) -> Result<bool, String> {
use std::path::{Path, PathBuf};
if let Some(a) = args.iter().find(|a| a.starts_with("--")) {
return Err(format!("wai verify: {a}: not an option ({USAGE})"));
}
let paths = super::positional(args, &[]);
if paths.is_empty() {
return Err(USAGE.into());
}
fn walk(p: &Path, out: &mut Vec<PathBuf>, depth: usize) -> Result<(), String> {
if p.is_file() {
if p.to_string_lossy().ends_with(".wai.receipts") {
out.push(p.to_path_buf());
}
return Ok(());
}
if !p.is_dir() {
return Err(format!("{}: no such file or directory", p.display()));
}
if depth > 16 {
return Ok(());
}
let mut entries: Vec<PathBuf> = std::fs::read_dir(p).map_err(|e| format!("{}: {e}", p.display()))?.flatten().map(|e| e.path()).collect();
entries.sort();
for e in entries {
if e.is_dir() && e.file_name().is_some_and(|n| n == "rcpt") {
continue;
}
walk(&e, out, depth + 1)?;
}
Ok(())
}
let mut indexes = Vec::new();
for p in &paths {
walk(Path::new(p), &mut indexes, 0)?;
}
if indexes.is_empty() {
return Err(format!("no receipt index (*.wai.receipts) under {}", paths.join(", ")));
}
let mut all = true;
for ix in &indexes {
println!("{}", ix.display());
let (ok, lines) = check_index_file(ix)?;
for l in lines {
println!(" {l}");
}
all &= ok;
}
let n = indexes.len();
println!("wai verify: {} receipt index{} {}", n, if n == 1 { "" } else { "es" }, if all { "verified" } else { "with a refusal (above)" });
Ok(all)
}
/// One receipt index, checked: whether every item and claim it lists held, and a line for
/// each.
#[cfg(feature = "quickstart")]
pub(crate) fn check_index_file(index: &std::path::Path) -> Result<(bool, Vec<String>), String> {
use super::hex;
use wai::http_binding::receipts::{Limits, ObjectHeld, RECEIPT_DIR, check_index};
use wai::jwp_delivery::ReceiptChains;
use wai::jwp_hash::EstablishedHash;
let name = index.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let cid_hex = name.strip_suffix(".wai.receipts").unwrap_or("");
let cid: [u8; 32] = match (0..32).map(|i| cid_hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect::<Option<Vec<u8>>>() {
Some(v) if cid_hex.len() == 64 => v.try_into().expect("32 bytes"),
_ => return Ok((false, vec![format!("FAIL {name}: an index is named <cid>.wai.receipts, the cid 64 hexadecimal digits")])),
};
let dir = index.parent().unwrap_or(std::path::Path::new("."));
let bytes = std::fs::read(index).map_err(|e| format!("{}: {e}", index.display()))?;
let object = dir.join(format!("{cid_hex}.wai"));
let held = match std::fs::read(&object) {
Ok(b) => ObjectHeld::Object(EstablishedHash::of_envelope(&b)),
Err(_) => ObjectHeld::Nothing,
};
let mut fetch = |h: &[u8; 32], limit: usize| -> Option<Vec<u8>> {
let mut b = std::fs::read(dir.join(format!("{RECEIPT_DIR}{}.json", hex(h)))).ok()?;
b.truncate(limit);
Some(b)
};
let mut chains = ReceiptChains::new();
let mut lines = Vec::new();
let mut ok = true;
let short = |b: &[u8]| hex(b)[..16].to_string();
match check_index(&cid, &bytes, &held, &mut fetch, &mut chains, 0, &Limits::default(), None) {
Err(e) => {
ok = false;
lines.push(format!("FAIL the index: {} ({e})", e.code()));
}
Ok(r) => {
if r.delivery.is_empty() && r.claims.is_empty() {
ok = false;
lines.push("FAIL the index lists nothing".into());
}
for d in &r.delivery {
match d {
Ok(v) => lines.push(format!(
"ok {}: group {} signed by {}… ({}), {}{}",
v.evidence.label(),
short(&v.group.link_hash()),
short(&v.group.signer_pubkey),
v.group.signer_id,
v.admission.label(),
if matches!(held, ObjectHeld::Nothing) { "; the object is not here, so its delivery is not shown" } else { "" }
)),
Err(e) => {
ok = false;
lines.push(format!("FAIL delivery: {} ({e})", e.code()));
}
}
}
for c in &r.claims {
match c {
Ok(v) => lines.push(format!("ok claim {}: attests {}", v.claim.class().label(), v.attests.label())),
Err(e) => {
ok = false;
lines.push(format!("FAIL claim: {} ({e})", e.code()));
}
}
}
}
}
Ok((ok, lines))
}

View file

@ -4,8 +4,11 @@
//! Sample format: f32 in [-1, 1] for the WAI-facing API; codecs may
//! convert internally to i16/i24 as needed.
#[cfg(feature = "codecs")]
use byteorder::{LittleEndian, WriteBytesExt};
#[cfg(feature = "codecs")]
use ogg::{PacketReader, PacketWriteEndInfo, PacketWriter};
#[cfg(feature = "codecs")]
use opus::{Application, Channels, Decoder, Encoder};
use std::io::Cursor;
@ -33,10 +36,13 @@ fn too_long(what: &str, max: usize) -> AudioError {
// Opus only supports specific sample rates (8/12/16/24/48 kHz) and
// fixed frame sizes. We frame at 20 ms (the Opus default) and resample
// upstream if needed (caller's job).
#[cfg(feature = "codecs")]
const OPUS_FRAME_MS: usize = 20;
#[cfg(feature = "codecs")]
fn opus_frame_size(sr: u32) -> usize { (sr as usize) * OPUS_FRAME_MS / 1000 }
#[cfg(feature = "codecs")]
/// Encode mono f32 PCM as standard Ogg-Opus (RFC 7845). The bytes are
/// a real `.opus` file — drops into any Opus-aware tool (ffmpeg, VLC,
/// the browser <audio> tag, etc.) without re-framing.
@ -108,12 +114,14 @@ pub fn opus_encode(samples: &[f32], sr: u32, bitrate_bps: i32) -> Result<Vec<u8>
Ok(out.into_inner())
}
#[cfg(feature = "codecs")]
/// Decodes an Ogg Opus payload (RFC 7845) to mono `f32` samples within the reader
/// limit of SPEC §2, [`AUDIO_DECODED_MAX_SAMPLES`].
pub fn opus_decode(bytes: &[u8]) -> Result<(Vec<f32>, u32)> {
opus_decode_within(bytes, AUDIO_DECODED_MAX_SAMPLES)
}
#[cfg(feature = "codecs")]
/// [`opus_decode`] with the sample limit the caller gives (`audio_decoded_too_long`).
/// While it decodes, it holds at most that many samples, the pre-skip and one packet.
pub fn opus_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec<f32>, u32)> {
@ -175,6 +183,7 @@ pub fn opus_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec<f32>,
// ---- FLAC (lossless) -------------------------------------------------
// libFLAC bindings for encode; pure-Rust claxon for decode.
#[cfg(feature = "codecs")]
pub fn flac_encode(samples: &[f32], sr: u32) -> Result<Vec<u8>> {
// A conforming encoder writes no payload a conforming reader may refuse (SPEC §2).
if samples.len() > AUDIO_DECODED_MAX_SAMPLES {
@ -235,7 +244,9 @@ pub fn flac_decode_within(bytes: &[u8], max_samples: usize) -> Result<(Vec<f32>,
Ok((out, sr))
}
#[cfg(test)]
// The tests exercise every codec here: a build with `codecs` runs them; the floor's are
// tests/floor.rs.
#[cfg(all(test, feature = "codecs"))]
mod tests {
use super::*;
use std::f32::consts::PI;

View file

@ -1,5 +1,7 @@
//! Image codec wrappers. PNG (universal floor), JPEG (compatibility),
//! AVIF (modern lossy, AV1-based), JPEG-XL (lossless + lossy).
//! AVIF (modern lossy, AV1-based), JPEG-XL (lossless + lossy). A build with the
//! `floor` feature alone has PNG, through the `image` crate's pure-Rust codec; the
//! others need `codecs`.
//!
//! Each codec exposes `encode(rgb, h, w, quality) -> Vec<u8>` and
//! `decode(bytes) -> (rgb, h, w)`. RGB is row-major, 8-bit, 3 channels.
@ -13,7 +15,9 @@
use std::io::Cursor;
use image::ImageEncoder;
#[cfg(feature = "codecs")]
use jpegxl_rs::encode::EncoderSpeed;
#[cfg(feature = "codecs")]
use jpegxl_rs::{decoder_builder, encoder_builder};
#[derive(Debug)]
@ -87,6 +91,7 @@ pub fn png_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
// libjpeg q (the universal convention in the field).
// * We DON'T enable optimized Huffman tables — they're standard JPEG
// but the `zune-jpeg` decoder we link via `image` misreads them.
#[cfg(feature = "codecs")]
fn remap_q_to_jpeg_encoder(public_q: u8) -> u8 {
// Empirically calibrated against libjpeg on Kodak (bench harness):
// user-q 50 → encoder-q 35 ≈ libjpeg q=50 bytes
@ -98,6 +103,7 @@ fn remap_q_to_jpeg_encoder(public_q: u8) -> u8 {
mapped.clamp(1, 100) as u8
}
#[cfg(feature = "codecs")]
pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
check_dimensions(w as u64, h as u64)?;
let mut out = Vec::new();
@ -109,11 +115,13 @@ pub fn jpeg_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
Ok(out)
}
#[cfg(feature = "codecs")]
pub fn jpeg_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
decode_as(bytes, image::ImageFormat::Jpeg)
}
// ---- AVIF (modern lossy, AV1-based) ----
#[cfg(feature = "codecs")]
pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
check_dimensions(w as u64, h as u64)?;
let mut out = Vec::new();
@ -122,6 +130,7 @@ pub fn avif_encode(rgb: &[u8], h: u32, w: u32, quality: u8) -> Result<Vec<u8>> {
Ok(out)
}
#[cfg(feature = "codecs")]
pub fn avif_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
// libavif directly. The `image` crate's own AVIF decoder is encode-only in the
// default build; relying on it silently returned empty buffers (bench harness
@ -178,6 +187,7 @@ pub fn avif_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
}
// ---- JPEG-XL (lossless or lossy) ----
#[cfg(feature = "codecs")]
pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option<f32>) -> Result<Vec<u8>> {
check_dimensions(w as u64, h as u64)?;
// `quality=None` ⇒ mathematically lossless (the `.lossless(true)`
@ -202,6 +212,7 @@ pub fn jxl_encode(rgb: &[u8], h: u32, w: u32, quality: Option<f32>) -> Result<Ve
Ok(r.data)
}
#[cfg(feature = "codecs")]
/// The width and height a JPEG XL payload declares, read by libjxl from its header
/// alone (the decoder is asked for nothing past the basic information).
fn jxl_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
@ -239,6 +250,7 @@ fn jxl_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
}
}
#[cfg(feature = "codecs")]
pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
let (w, h) = jxl_dimensions(bytes)?;
check_dimensions(w as u64, h as u64)?;
@ -258,7 +270,9 @@ pub fn jxl_decode(bytes: &[u8]) -> Result<(Vec<u8>, u32, u32)> {
Ok((pixels, info.height, info.width))
}
#[cfg(test)]
// The tests exercise every codec here: a build with `codecs` runs them; the floor's are
// tests/floor.rs.
#[cfg(all(test, feature = "codecs"))]
mod tests {
use super::*;

View file

@ -10,11 +10,13 @@
//! natively are thin wrappers around their reference libraries (the `codecs`
//! feature); the registry itself needs no feature.
#[cfg(feature = "codecs")]
// Each of the three holds a codec of the mandatory floor (SPEC §5): PNG, FLAC, zstd. The
// `floor` feature builds those alone, with no system library; `codecs` builds every one.
#[cfg(any(feature = "codecs", feature = "floor"))]
pub mod audio;
#[cfg(feature = "codecs")]
#[cfg(any(feature = "codecs", feature = "floor"))]
pub mod image;
#[cfg(feature = "codecs")]
#[cfg(any(feature = "codecs", feature = "floor"))]
pub mod text;
#[cfg(feature = "codecs")]
pub mod video;
@ -668,6 +670,12 @@ pub fn sink_capabilities() -> &'static [&'static str] {
]
}
/// The mandatory floor of SPEC §5: the capabilities every conforming sink implements, and
/// the ones a build with the `floor` feature alone decodes (no system library).
pub fn floor_capabilities() -> &'static [&'static str] {
&[CAP_IMAGE_PNG, CAP_AUDIO_FLAC, CAP_TEXT_ZSTD]
}
/// Every capability string WAI recognizes — floor, modern, and neural —
/// including ones whose decoder is **sink-supplied** rather than bundled here
/// (`wai.image.jpegai`, `wai.neural.{mlicpp,glc,dcvc_rt}`). Registration is not

View file

@ -1,7 +1,9 @@
//! Text/general-binary compression wrappers. zstd (universal balance)
//! and XZ/LZMA (max classical ratio). Both lossless by construction.
use std::io::{Read, Write};
use std::io::Read;
#[cfg(feature = "codecs")]
use std::io::Write;
#[derive(Debug)]
pub struct TextError(pub String);
@ -41,6 +43,7 @@ pub const TEXT_WINDOW_MAX_LOG: u32 = 27;
/// length limit bounds the output, and this bounds what the decoder holds beside it.
pub const TEXT_WINDOW_MAX_LEN: usize = 1 << TEXT_WINDOW_MAX_LOG;
#[cfg(feature = "codecs")]
/// What liblzma may allocate to decode one stream: the dictionary, at most
/// [`TEXT_WINDOW_MAX_LEN`], and the decoder's own fixed state beside it.
const XZ_DECODER_MEMLIMIT: u64 = TEXT_WINDOW_MAX_LEN as u64 + (1 << 20);
@ -111,6 +114,7 @@ pub fn zstd_decode_within(bytes: &[u8], max_out: usize) -> Result<Vec<u8>> {
}
}
#[cfg(feature = "codecs")]
/// XZ/LZMA2 at the given preset (0..=9; 6 is the default, 9 is "extreme"). Refuses more
/// than [`TEXT_DECODED_MAX_LEN`] bytes (`text_decoded_too_long`), as [`zstd_encode`]
/// does. No preset asks for a dictionary past [`TEXT_WINDOW_MAX_LEN`].
@ -125,6 +129,7 @@ pub fn xz_encode(data: &[u8], level: u32) -> Result<Vec<u8>> {
Ok(out)
}
#[cfg(feature = "codecs")]
/// Decodes a `wai.text.xz` payload within the reader limits of SPEC §2:
/// [`TEXT_DECODED_MAX_LEN`] bytes of output and [`TEXT_WINDOW_MAX_LEN`] bytes of
/// dictionary.
@ -132,6 +137,7 @@ pub fn xz_decode(bytes: &[u8]) -> Result<Vec<u8>> {
xz_decode_within(bytes, TEXT_DECODED_MAX_LEN)
}
#[cfg(feature = "codecs")]
/// [`xz_decode`] with the output limit the caller gives: for a caller that knows what
/// its payload may hold, and for tests. `max_out` is capped at
/// [`TEXT_DECODED_MAX_LEN`]. The dictionary limit is not the caller's to change.
@ -153,7 +159,9 @@ pub fn xz_decode_within(bytes: &[u8], max_out: usize) -> Result<Vec<u8>> {
}
}
#[cfg(test)]
// The tests exercise every codec here: a build with `codecs` runs them; the floor's are
// tests/floor.rs.
#[cfg(all(test, feature = "codecs"))]
mod tests {
use super::*;

View file

@ -1304,7 +1304,7 @@ pub use wai_quantum::quantum_qdata;
pub use wai_quantum::quantum_qbom;
pub use codecs::{
sink_capabilities,
floor_capabilities, sink_capabilities,
CAP_AUDIO_FLAC, CAP_AUDIO_OPUS,
CAP_IMAGE_AVIF, CAP_IMAGE_JPEG, CAP_IMAGE_JXL, CAP_IMAGE_PNG,
CAP_NEURAL_BMSHJ2018, CAP_NEURAL_DAC, CAP_NEURAL_DCVC_RT, CAP_NEURAL_ENCODEC32,

138
wai/wai-rs/tests/cli_dx.rs Normal file
View file

@ -0,0 +1,138 @@
//! The developer's path through the `wai` binary, as a person runs it: `wai hello` writes an
//! object, its receipt index and its group's receipt file, and `wai verify` checks them
//! offline and refuses each when it is altered; `wai create` writes each example of
//! `wai/examples/` as a project, file for file and byte for byte, and never into a directory
//! that holds anything.
#![cfg(feature = "quickstart")]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-dx-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn wai(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).output().expect("wai runs")
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// The one file under `dir` whose name ends with `suffix`.
fn one(dir: &Path, suffix: &str) -> PathBuf {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(suffix)).collect();
assert_eq!(v.len(), 1, "one {suffix} in {}", dir.display());
v.remove(0)
}
#[test]
fn hello_runs_the_four_steps_and_verify_checks_what_it_wrote() {
let d = tmp("hello");
let o = wai(&d, &["hello", "h"]);
assert!(o.status.success(), "{}", text(&o));
let t = text(&o);
for step in ["1 Name", "2 Dispatch", "3 Rebuild", "4 Receipt", "byte for byte", "delivered-verified"] {
assert!(t.contains(step), "hello says {step}: {t}");
}
let h = d.join("h");
let v = wai(&d, &["verify", "h"]);
assert!(v.status.success(), "{}", text(&v));
assert!(text(&v).contains("delivered-verified") && text(&v).contains("1 receipt index verified"), "{}", text(&v));
// A byte of the object changed: its content hash is not the receipt's.
let obj = one(&h, ".wai");
let good = std::fs::read(&obj).unwrap();
let mut bad = good.clone();
let n = bad.len();
bad[n - 1] ^= 1;
std::fs::write(&obj, &bad).unwrap();
let v = wai(&d, &["verify", "h"]);
assert_eq!(v.status.code(), Some(1), "{}", text(&v));
assert!(text(&v).contains("FAIL"), "{}", text(&v));
std::fs::write(&obj, &good).unwrap();
// A byte of the group's receipt file changed: its name is no longer its link hash, or its
// signature no longer verifies.
let rcpt = one(&h.join("rcpt"), ".json");
let g = std::fs::read_to_string(&rcpt).unwrap();
let i = g.find("\"sig\":\"").expect("a signature") + 7;
let mut b = g.into_bytes();
b[i] = if b[i] == b'0' { b'1' } else { b'0' };
std::fs::write(&rcpt, &b).unwrap();
let v = wai(&d, &["verify", "h"]);
assert_eq!(v.status.code(), Some(1), "{}", text(&v));
// Nothing to verify is an error, not a pass.
std::fs::create_dir_all(d.join("empty")).unwrap();
let v = wai(&d, &["verify", "empty"]);
assert_eq!(v.status.code(), Some(2), "{}", text(&v));
// And hello never writes into a directory of someone else's files.
std::fs::write(d.join("empty/mine.txt"), "mine").unwrap();
assert_eq!(wai(&d, &["hello", "empty"]).status.code(), Some(2));
let _ = std::fs::remove_dir_all(&d);
}
/// Every file of an example but its `test/` directory, relative to it, sorted.
fn example_files(dir: &Path) -> Vec<String> {
fn walk(root: &Path, p: &Path, out: &mut Vec<String>) {
for e in std::fs::read_dir(p).unwrap().flatten() {
let q = e.path();
let rel = q.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/");
if rel == "test" || rel.starts_with("test/") || rel.ends_with(".DS_Store") || rel == "target" || rel.starts_with("target/") {
continue;
}
if q.is_dir() {
walk(root, &q, out);
} else {
out.push(rel);
}
}
}
let mut v = Vec::new();
walk(dir, dir, &mut v);
v.sort();
v
}
#[test]
fn create_writes_every_example_as_it_is_in_the_repository() {
let d = tmp("create");
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples");
let o = wai(&d, &["create", "--list", "--json"]);
assert!(o.status.success(), "{}", text(&o));
let list: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
let list = list.as_array().unwrap();
let names: Vec<&str> = list.iter().map(|t| t["template"].as_str().unwrap()).collect();
// Every example directory is a template, and every template an example.
let mut dirs: Vec<String> = std::fs::read_dir(&examples).unwrap().flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
dirs.sort();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(sorted, dirs, "the templates are the examples");
for t in list {
let name = t["template"].as_str().unwrap();
let generated: Vec<&str> = t["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).filter(|f| !examples.join(name).join(f).exists()).collect();
let o = wai(&d, &["create", name, "--template", name]);
assert!(o.status.success(), "{name}: {}", text(&o));
let made = example_files(&d.join(name));
let mut want = example_files(&examples.join(name));
want.extend(generated.iter().map(|g| g.to_string()));
want.sort();
assert_eq!(made, want, "{name}: the project holds the example's files, and those made for it");
for f in example_files(&examples.join(name)) {
assert_eq!(std::fs::read(d.join(name).join(&f)).unwrap(), std::fs::read(examples.join(name).join(&f)).unwrap(), "{name}/{f}: byte for byte");
}
}
// Never into a directory that holds anything.
let o = wai(&d, &["create", "video-room", "--template", "video-room"]);
assert_eq!(o.status.code(), Some(2), "{}", text(&o));
assert!(text(&o).contains("not empty"));
let o = wai(&d, &["create", "x", "--template", "no-such"]);
assert_eq!(o.status.code(), Some(2));
let _ = std::fs::remove_dir_all(&d);
}

133
wai/wai-rs/tests/floor.rs Normal file
View file

@ -0,0 +1,133 @@
//! The mandatory floor (SPEC §5: PNG, FLAC, zstd) in a build with the `floor` feature and no
//! other: the library decodes each, and the `wai` binary encodes PNG and zstd and decodes all
//! three, with no system codec library linked. A codec outside the floor is refused, saying
//! which build has it.
#![cfg(feature = "floor")]
use std::path::{Path, PathBuf};
use std::process::Command;
use wai::codecs::{audio, image, text};
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-floor-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn wai(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_wai")).args(args).output().expect("wai runs")
}
/// CRC-8 (polynomial 0x07) and CRC-16 (0x8005) of RFC 9639 §9.1.8 and §9.3.
fn crc8(b: &[u8]) -> u8 {
b.iter().fold(0u8, |mut c, &x| {
c ^= x;
for _ in 0..8 {
c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 };
}
c
})
}
fn crc16(b: &[u8]) -> u16 {
b.iter().fold(0u16, |mut c, &x| {
c ^= (x as u16) << 8;
for _ in 0..8 {
c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 };
}
c
})
}
/// A FLAC stream (RFC 9639) of one frame of sixteen 16-bit mono samples at 8 kHz, in a
/// verbatim subframe: written field by field, so the test needs no encoder.
fn flac(samples: &[i16; 16]) -> Vec<u8> {
let mut b = b"fLaC".to_vec();
b.extend_from_slice(&[0x80, 0, 0, 34]); // the last metadata block: STREAMINFO, 34 bytes
b.extend_from_slice(&16u16.to_be_bytes()); // min and max block size
b.extend_from_slice(&16u16.to_be_bytes());
b.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // frame sizes unknown
// 20 bits sample rate (8000), 3 bits channels-1 (0), 5 bits bps-1 (15), 36 bits samples (16).
let v: u64 = (8000u64 << 44) | (15u64 << 36) | 16;
b.extend_from_slice(&v.to_be_bytes());
b.extend_from_slice(&[0; 16]); // MD5 unknown
let start = b.len();
// Sync, fixed blocking; block size from an 8-bit field, rate from STREAMINFO; mono, 16 bits;
// frame 0; block size - 1.
b.extend_from_slice(&[0xff, 0xf8, 0x60, 0x08, 0x00, 15]);
let c = crc8(&b[start..]);
b.push(c);
b.push(0x02); // a verbatim subframe, no wasted bits
for s in samples {
b.extend_from_slice(&s.to_be_bytes());
}
let c = crc16(&b[start..]);
b.extend_from_slice(&c.to_be_bytes());
b
}
#[test]
fn the_library_decodes_the_floor() {
let rgb: Vec<u8> = (0..4 * 3 * 3).map(|i| (i * 7) as u8).collect();
let png = image::png_encode(&rgb, 3, 4).unwrap();
assert_eq!(image::png_decode(&png).unwrap(), (rgb, 3, 4));
let s: [i16; 16] = std::array::from_fn(|i| (i as i16 - 8) * 1000);
let (pcm, rate) = audio::flac_decode(&flac(&s)).unwrap();
assert_eq!(rate, 8000);
assert_eq!(pcm, s.iter().map(|&x| x as f32 / 32768.0).collect::<Vec<_>>());
let t = b"the mandatory floor".repeat(20);
assert_eq!(text::zstd_decode(&text::zstd_encode(&t, 3).unwrap()).unwrap(), t);
assert_eq!(wai::floor_capabilities(), &["wai.image.png", "wai.audio.flac", "wai.text.zstd"]);
}
fn ok(o: &std::process::Output) {
assert!(o.status.success(), "{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
}
fn s(p: &Path) -> &str {
p.to_str().unwrap()
}
#[test]
fn the_cli_encodes_png_and_zstd_and_decodes_the_floor() {
let d = tmp("cli");
// PNG: a 4x3 picture in, the same pixels out.
let rgb: Vec<u8> = (0..36).map(|i| (i * 5) as u8).collect();
std::fs::write(d.join("pic.rgb"), &rgb).unwrap();
std::fs::write(d.join("pic.rgb.meta"), "3 4\n").unwrap();
ok(&wai(&["encode", "png", s(&d.join("pic.rgb")), "-o", s(&d.join("pic.wai"))]));
ok(&wai(&["decode", s(&d.join("pic.wai")), "-o", s(&d.join("pic.out"))]));
assert_eq!(std::fs::read(d.join("pic.out")).unwrap(), rgb);
// zstd.
let t = b"carried, compressed and rebuilt".repeat(9);
std::fs::write(d.join("t.txt"), &t).unwrap();
ok(&wai(&["encode", "zstd", s(&d.join("t.txt")), "-o", s(&d.join("t.wai"))]));
ok(&wai(&["decode", s(&d.join("t.wai")), "-o", s(&d.join("t.out"))]));
assert_eq!(std::fs::read(d.join("t.out")).unwrap(), t);
// FLAC: wrapped as it comes, decoded by the floor.
let smp: [i16; 16] = std::array::from_fn(|i| (i as i16) * 300);
std::fs::write(d.join("a.flac"), flac(&smp)).unwrap();
ok(&wai(&["wrap", "wai.audio.flac", s(&d.join("a.flac")), "-o", s(&d.join("a.wai"))]));
ok(&wai(&["decode", s(&d.join("a.wai")), "-o", s(&d.join("a.f32"))]));
let f: Vec<u8> = smp.iter().flat_map(|&x| (x as f32 / 32768.0).to_le_bytes()).collect();
assert_eq!(std::fs::read(d.join("a.f32")).unwrap(), f);
assert_eq!(std::fs::read_to_string(d.join("a.f32.meta")).unwrap().trim(), "8000");
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(not(feature = "codecs"))]
#[test]
fn a_codec_outside_the_floor_is_refused_naming_the_build_that_has_it() {
let d = tmp("outside");
std::fs::write(d.join("x.txt"), b"x").unwrap();
let o = wai(&["encode", "xz", s(&d.join("x.txt")), "-o", s(&d.join("x.wai"))]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("`codecs` feature"), "{}", String::from_utf8_lossy(&o.stderr));
// A payload the floor does not decode is inert here, never guessed.
std::fs::write(d.join("y.jxl"), b"\xff\x0a").unwrap();
ok(&wai(&["wrap", "wai.image.jxl", s(&d.join("y.jxl")), "-o", s(&d.join("y.wai"))]));
let o = wai(&["decode", s(&d.join("y.wai")), "-o", s(&d.join("y.out"))]);
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("inert"), "{}", String::from_utf8_lossy(&o.stderr));
let _ = std::fs::remove_dir_all(&d);
}