wai: the developer's path (hello, create, dev, verify), the floor with no system library, nine examples, and the quickstart archive #504
97 changed files with 6766 additions and 24 deletions
16
.github/workflows/wai-conformance.yml
vendored
16
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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
196
.github/workflows/wai-examples.yml
vendored
Normal 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
245
.github/workflows/wai-release.yml
vendored
Normal 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"/*
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
26
wai/examples/README.md
Normal 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
3
wai/examples/android-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.gradle/
|
||||
build/
|
||||
replay/build/
|
||||
29
wai/examples/android-app/README.md
Normal file
29
wai/examples/android-app/README.md
Normal 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.
|
||||
32
wai/examples/android-app/replay/build.gradle.kts
Normal file
32
wai/examples/android-app/replay/build.gradle.kts
Normal 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")
|
||||
}
|
||||
40
wai/examples/android-app/replay/src/main/kotlin/Replay.kt
Normal file
40
wai/examples/android-app/replay/src/main/kotlin/Replay.kt
Normal 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)
|
||||
}
|
||||
659
wai/examples/android-app/sample-session.json
Normal file
659
wai/examples/android-app/sample-session.json
Normal file
File diff suppressed because one or more lines are too long
27
wai/examples/android-app/settings.gradle.kts
Normal file
27
wai/examples/android-app/settings.gradle.kts
Normal 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")
|
||||
26
wai/examples/android-app/test/android-app.test.sh
Executable file
26
wai/examples/android-app/test/android-app.test.sh
Executable 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
|
||||
5
wai/examples/android-app/wai.json
Normal file
5
wai/examples/android-app/wai.json
Normal 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
2
wai/examples/apple-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.build/
|
||||
.swiftpm/
|
||||
20
wai/examples/apple-app/Package.swift
Normal file
20
wai/examples/apple-app/Package.swift
Normal 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"),
|
||||
]
|
||||
)
|
||||
31
wai/examples/apple-app/README.md
Normal file
31
wai/examples/apple-app/README.md
Normal 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.
|
||||
50
wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift
Normal file
50
wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
wai/examples/apple-app/Sources/WaiApp/Replay.swift
Normal file
47
wai/examples/apple-app/Sources/WaiApp/Replay.swift
Normal 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
|
||||
}
|
||||
}
|
||||
13
wai/examples/apple-app/Sources/WaiApp/main.swift
Normal file
13
wai/examples/apple-app/Sources/WaiApp/main.swift
Normal 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()
|
||||
659
wai/examples/apple-app/sample-session.json
Normal file
659
wai/examples/apple-app/sample-session.json
Normal file
File diff suppressed because one or more lines are too long
24
wai/examples/apple-app/test/apple-app.test.sh
Executable file
24
wai/examples/apple-app/test/apple-app.test.sh
Executable 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
|
||||
5
wai/examples/apple-app/wai.json
Normal file
5
wai/examples/apple-app/wai.json
Normal 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."
|
||||
}
|
||||
1
wai/examples/encoder-ingest/.gitignore
vendored
Normal file
1
wai/examples/encoder-ingest/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
25
wai/examples/encoder-ingest/Cargo.toml
Normal file
25
wai/examples/encoder-ingest/Cargo.toml
Normal 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" }
|
||||
30
wai/examples/encoder-ingest/README.md
Normal file
30
wai/examples/encoder-ingest/README.md
Normal 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.
|
||||
252
wai/examples/encoder-ingest/src/main.rs
Normal file
252
wai/examples/encoder-ingest/src/main.rs
Normal 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)
|
||||
}
|
||||
34
wai/examples/encoder-ingest/test/encoder-ingest.test.sh
Executable file
34
wai/examples/encoder-ingest/test/encoder-ingest.test.sh
Executable 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
|
||||
5
wai/examples/encoder-ingest/wai.json
Normal file
5
wai/examples/encoder-ingest/wai.json
Normal 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
1
wai/examples/messaging/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wai/
|
||||
31
wai/examples/messaging/README.md
Normal file
31
wai/examples/messaging/README.md
Normal 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.
|
||||
24
wai/examples/messaging/index.html
Normal file
24
wai/examples/messaging/index.html
Normal 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>
|
||||
62
wai/examples/messaging/test/messaging.test.mjs
Normal file
62
wai/examples/messaging/test/messaging.test.mjs
Normal 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");
|
||||
6
wai/examples/messaging/wai.json
Normal file
6
wai/examples/messaging/wai.json
Normal 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 }
|
||||
}
|
||||
3
wai/examples/package-and-deliver/.gitignore
vendored
Normal file
3
wai/examples/package-and-deliver/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.wai/
|
||||
video-base-01.y4m
|
||||
package/
|
||||
29
wai/examples/package-and-deliver/README.md
Normal file
29
wai/examples/package-and-deliver/README.md
Normal 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.
|
||||
BIN
wai/examples/package-and-deliver/floor/v.obu
Normal file
BIN
wai/examples/package-and-deliver/floor/v.obu
Normal file
Binary file not shown.
27
wai/examples/package-and-deliver/index.html
Normal file
27
wai/examples/package-and-deliver/index.html
Normal 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>
|
||||
16
wai/examples/package-and-deliver/pack.sh
Executable file
16
wai/examples/package-and-deliver/pack.sh
Executable 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"
|
||||
30
wai/examples/package-and-deliver/player.js
Normal file
30
wai/examples/package-and-deliver/player.js
Normal 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 };
|
||||
}
|
||||
47
wai/examples/package-and-deliver/policy.toml
Normal file
47
wai/examples/package-and-deliver/policy.toml
Normal 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
|
||||
1
wai/examples/package-and-deliver/test-key.hex
Normal file
1
wai/examples/package-and-deliver/test-key.hex
Normal file
|
|
@ -0,0 +1 @@
|
|||
5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a
|
||||
|
|
@ -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");
|
||||
28
wai/examples/package-and-deliver/test/package-and-deliver.test.sh
Executable file
28
wai/examples/package-and-deliver/test/package-and-deliver.test.sh
Executable 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
|
||||
6
wai/examples/package-and-deliver/wai.json
Normal file
6
wai/examples/package-and-deliver/wai.json
Normal 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": "." }
|
||||
}
|
||||
1
wai/examples/remote-desktop/.gitignore
vendored
Normal file
1
wai/examples/remote-desktop/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wai/
|
||||
31
wai/examples/remote-desktop/README.md
Normal file
31
wai/examples/remote-desktop/README.md
Normal 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.
|
||||
22
wai/examples/remote-desktop/host.sh
Executable file
22
wai/examples/remote-desktop/host.sh
Executable 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 "$@"
|
||||
40
wai/examples/remote-desktop/test/remote-desktop.test.mjs
Normal file
40
wai/examples/remote-desktop/test/remote-desktop.test.mjs
Normal 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");
|
||||
41
wai/examples/remote-desktop/test/remote-desktop.test.sh
Executable file
41
wai/examples/remote-desktop/test/remote-desktop.test.sh
Executable 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
|
||||
6
wai/examples/remote-desktop/wai.json
Normal file
6
wai/examples/remote-desktop/wai.json
Normal 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" }
|
||||
}
|
||||
3
wai/examples/verify-receipts/.gitignore
vendored
Normal file
3
wai/examples/verify-receipts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.wai/
|
||||
rust/target/
|
||||
receipts/
|
||||
29
wai/examples/verify-receipts/README.md
Normal file
29
wai/examples/verify-receipts/README.md
Normal 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.
|
||||
113
wai/examples/verify-receipts/python/_blake3.py
Normal file
113
wai/examples/verify-receipts/python/_blake3.py
Normal 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])
|
||||
182
wai/examples/verify-receipts/python/_ed25519.py
Normal file
182
wai/examples/verify-receipts/python/_ed25519.py
Normal 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)
|
||||
124
wai/examples/verify-receipts/python/verify_receipts.py
Normal file
124
wai/examples/verify-receipts/python/verify_receipts.py
Normal 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]))
|
||||
14
wai/examples/verify-receipts/rust/Cargo.toml
Normal file
14
wai/examples/verify-receipts/rust/Cargo.toml
Normal 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"] }
|
||||
70
wai/examples/verify-receipts/rust/src/main.rs
Normal file
70
wai/examples/verify-receipts/rust/src/main.rs
Normal 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) }
|
||||
}
|
||||
60
wai/examples/verify-receipts/test/verify-receipts.test.sh
Executable file
60
wai/examples/verify-receipts/test/verify-receipts.test.sh
Executable 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
|
||||
5
wai/examples/verify-receipts/wai.json
Normal file
5
wai/examples/verify-receipts/wai.json
Normal 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
1
wai/examples/video-room/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wai/
|
||||
25
wai/examples/video-room/README.md
Normal file
25
wai/examples/video-room/README.md
Normal 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.
|
||||
38
wai/examples/video-room/index.html
Normal file
38
wai/examples/video-room/index.html
Normal 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>
|
||||
46
wai/examples/video-room/test/video-room.test.mjs
Normal file
46
wai/examples/video-room/test/video-room.test.mjs
Normal 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");
|
||||
6
wai/examples/video-room/wai.json
Normal file
6
wai/examples/video-room/wai.json
Normal 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": "." }
|
||||
}
|
||||
1
wai/examples/watch-together/.gitignore
vendored
Normal file
1
wai/examples/watch-together/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wai/
|
||||
28
wai/examples/watch-together/README.md
Normal file
28
wai/examples/watch-together/README.md
Normal 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.
|
||||
22
wai/examples/watch-together/index.html
Normal file
22
wai/examples/watch-together/index.html
Normal 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>
|
||||
52
wai/examples/watch-together/test/watch-together.test.mjs
Normal file
52
wai/examples/watch-together/test/watch-together.test.mjs
Normal 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");
|
||||
6
wai/examples/watch-together/wai.json
Normal file
6
wai/examples/watch-together/wai.json
Normal 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
35
wai/quickstart/README.md
Normal 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
44
wai/quickstart/build-web.sh
Executable 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
98
wai/quickstart/build.sh
Executable 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
101
wai/quickstart/formulae.py
Normal 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
43
wai/quickstart/install.sh
Executable 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
34
wai/quickstart/publish.sh
Executable 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
|
||||
64
wai/quickstart/test/clean-machine.sh
Executable file
64
wai/quickstart/test/clean-machine.sh
Executable 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
130
wai/quickstart/test/lib.mjs
Normal 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
58
wai/quickstart/test/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
wai/quickstart/test/package.json
Normal file
10
wai/quickstart/test/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
wai/quickstart/test/run-examples.sh
Executable file
57
wai/quickstart/test/run-examples.sh
Executable 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 ]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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..]),
|
||||
|
|
|
|||
163
wai/wai-rs/src/bin/wai_cli/dx/create.rs
Normal file
163
wai/wai-rs/src/bin/wai_cli/dx/create.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1006
wai/wai-rs/src/bin/wai_cli/dx/dev.rs
Normal file
1006
wai/wai-rs/src/bin/wai_cli/dx/dev.rs
Normal file
File diff suppressed because it is too large
Load diff
161
wai/wai-rs/src/bin/wai_cli/dx/hello.rs
Normal file
161
wai/wai-rs/src/bin/wai_cli/dx/hello.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
78
wai/wai-rs/src/bin/wai_cli/dx/mod.rs
Normal file
78
wai/wai-rs/src/bin/wai_cli/dx/mod.rs
Normal 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()
|
||||
}
|
||||
149
wai/wai-rs/src/bin/wai_cli/dx/project.rs
Normal file
149
wai/wai-rs/src/bin/wai_cli/dx/project.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
130
wai/wai-rs/src/bin/wai_cli/dx/templates.rs
Normal file
130
wai/wai-rs/src/bin/wai_cli/dx/templates.rs
Normal 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: &[],
|
||||
},
|
||||
];
|
||||
142
wai/wai-rs/src/bin/wai_cli/dx/verify.rs
Normal file
142
wai/wai-rs/src/bin/wai_cli/dx/verify.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
138
wai/wai-rs/tests/cli_dx.rs
Normal 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
133
wai/wai-rs/tests/floor.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue