diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 5ab9cd4ec..5a7df690b 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -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 diff --git a/.github/workflows/wai-examples.yml b/.github/workflows/wai-examples.yml new file mode 100644 index 000000000..e2ba01ce1 --- /dev/null +++ b/.github/workflows/wai-examples.yml @@ -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 diff --git a/.github/workflows/wai-release.yml b/.github/workflows/wai-release.yml new file mode 100644 index 000000000..4ef5da847 --- /dev/null +++ b/.github/workflows/wai-release.yml @@ -0,0 +1,245 @@ +name: wai command line release + +# On a tag wai-v (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"/* diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 95ac2c65b..2241bf598 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -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 `** checks + every receipt index under a directory offline; **`wai create --template `** 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`) 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 diff --git a/wai/README.md b/wai/README.md index 333266736..bcf907c98 100644 --- a/wai/README.md +++ b/wai/README.md @@ -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 diff --git a/wai/examples/README.md b/wai/examples/README.md new file mode 100644 index 000000000..a39bb308a --- /dev/null +++ b/wai/examples/README.md @@ -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. diff --git a/wai/examples/android-app/.gitignore b/wai/examples/android-app/.gitignore new file mode 100644 index 000000000..20023cbdb --- /dev/null +++ b/wai/examples/android-app/.gitignore @@ -0,0 +1,3 @@ +.gradle/ +build/ +replay/build/ diff --git a/wai/examples/android-app/README.md b/wai/examples/android-app/README.md new file mode 100644 index 000000000..b6aeaf8f2 --- /dev/null +++ b/wai/examples/android-app/README.md @@ -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 /wai/wai-sink-android && scripts/build-native.sh host +cd my-android +export WAI_SINK_ANDROID=/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. diff --git a/wai/examples/android-app/replay/build.gradle.kts b/wai/examples/android-app/replay/build.gradle.kts new file mode 100644 index 000000000..339eb0c3f --- /dev/null +++ b/wai/examples/android-app/replay/build.gradle.kts @@ -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("run") { + systemProperty("java.library.path", project(":core").layout.buildDirectory.dir("native").get().asFile.path) + workingDir = rootDir + args("sample-session.json") +} diff --git a/wai/examples/android-app/replay/src/main/kotlin/Replay.kt b/wai/examples/android-app/replay/src/main/kotlin/Replay.kt new file mode 100644 index 000000000..5d6f099d5 --- /dev/null +++ b/wai/examples/android-app/replay/src/main/kotlin/Replay.kt @@ -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) { + @Suppress("UNCHECKED_CAST") + val doc = Json.obj(File(args.firstOrNull() ?: "sample-session.json").readBytes()) + @Suppress("UNCHECKED_CAST") + val objects = doc["objects"] as Map + fun bytes(name: Any?): ByteArray? = (name as? String)?.let { hex(objects.getValue(it)) } + @Suppress("UNCHECKED_CAST") + val sink = WaiSink.open(doc["config"] as Map, 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>) { + 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, bytes(d["body"])!!); verified++ } + "event" -> sink.event(d["name"] as String, t()) + "observe" -> { @Suppress("UNCHECKED_CAST") sink.observe(d["obs"] as Map) } + "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) + @Suppress("UNCHECKED_CAST") + val want = doc["outcome"] as Map + 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) +} diff --git a/wai/examples/android-app/sample-session.json b/wai/examples/android-app/sample-session.json new file mode 100644 index 000000000..ff1d176c4 --- /dev/null +++ b/wai/examples/android-app/sample-session.json @@ -0,0 +1,659 @@ +{ + "note": "A playback session recorded from the native sink corpus (wai/live-conformance/native-sink, case signed-vod): what a player asked for and received, in order. The app replays it through the sink.", + "owner": "a42c0b85c7d0f73cde731efc13f7892207283355bff6f3afa73831955e63da8f", + "channels": [ + "film-library", + "news-east" + ], + "config": { + "channel": "film-library", + "owner": "a42c0b85c7d0f73cde731efc13f7892207283355bff6f3afa73831955e63da8f" + }, + "seed": "394db3cb0099f03957c158322aa47a19707ea5e79651ac8739d16a3613cf51f7", + "start_us": 1792886405000000, + "calls": [ + { + "call": "playlist", + "url": "https://media.example/title/master.m3u8", + "body": "multivariant", + "receipt": null + }, + { + "call": "playlist", + "url": "https://media.example/title/v/v.m3u8", + "body": "vod", + "receipt": "vod-receipt" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/master.m3u8", + "t_us": 1792886405010000 + }, + "body": "multivariant" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/v.m3u8", + "t_us": 1792886405020000 + }, + "body": "vod" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/init.mp4", + "t_us": 1792886405050000 + }, + "body": "init" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a.m4s", + "t_us": 1792886405200000 + }, + "body": "f0" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6.m4s", + "t_us": 1792886405350000 + }, + "body": "f1" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b.m4s", + "t_us": 1792886405500000 + }, + "body": "f2" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf.m4s", + "t_us": 1792886405650000 + }, + "body": "f3" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15.m4s", + "t_us": 1792886405800000 + }, + "body": "f4" + }, + { + "call": "event", + "name": "present", + "t_us": 1792886406000000 + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886414000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "event", + "name": "end", + "t_us": 1792886414000000 + }, + { + "call": "end", + "ended": "ended", + "t_us": 1792886414000000 + } + ], + "objects": { + "multivariant": "234558544d33550a234558542d582d56455253494f4e3a370a234558542d582d494e444550454e44454e542d5345474d454e54530a234558542d582d53545245414d2d494e463a42414e4457494454483d31303239352c415645524147452d42414e4457494454483d393032362c434f444543533d22617630312e302e30304d2e3038222c5245534f4c5554494f4e3d36347833360a762e6d3375380a", + "vod": "234558544d33550a234558542d582d56455253494f4e3a370a234558542d582d5441524745544455524154494f4e3a330a234558542d582d4d454449412d53455155454e43453a300a234558542d582d504c41594c4953542d545950453a564f440a234558542d582d494e444550454e44454e542d5345474d454e54530a234558542d582d4d41503a5552493d22696e69742e6d7034220a23455854494e463a322e3430302c0a343230616534633934316262613665313261366337666462376430316463363931373065636234323430646666643265383638303864646638376261613132612e6d34730a23455854494e463a312e3030302c0a303463393438363038616161376235643738616435333535303432613266663635623066373564396434623161643738663365626666623838366137346265362e6d34730a23455854494e463a322e3030302c0a326639336630616434343966396363383831623738313663366162333366643434353033376436353338623434356263633736343331663963396639316335622e6d34730a23455854494e463a312e3030302c0a303664356364373962613338363063636364633235326665393135646530393032663934316133633661303030323935373134663763616231393361613663662e6d34730a23455854494e463a312e3630302c0a393961323464653034663762653966633231303937653639643264363162323232653435386231663933623830383230323661393837346165623966346331352e6d34730a234558542d582d454e444c4953540a", + "vod-receipt": "7b226b696e64223a227761692e766964656f2e6d616e6966657374222c226d616e69666573745f68617368223a2235393637396132336363323838653139323135303631393835616339623231663734396239616362613934343737373739313739393063343438663834353962222c2273657373696f6e5f636c6173735f68617368223a2237343834633836626266623236326566313537633436376364363539613061626438343134346435653931363865343631633831396463393534343836373766222c22636c617373223a7b22636f6e74656e745f6964223a227469746c652d31222c22656e7469746c656d656e74223a227075626c6963222c2267656f223a22222c22626c61636b6f757473223a5b5d2c2261645f706f645f6964223a22222c2277696e646f77223a307d2c227365676d656e7473223a5b7b22696e646578223a302c226b696e64223a22636f6e74656e74222c22757269223a22343230616534633934316262613665313261366337666462376430316463363931373065636234323430646666643265383638303864646638376261613132612e6d3473222c226475726174696f6e5f6d73223a323430302c2261645f6964223a6e756c6c7d2c7b22696e646578223a312c226b696e64223a22636f6e74656e74222c22757269223a22303463393438363038616161376235643738616435333535303432613266663635623066373564396434623161643738663365626666623838366137346265362e6d3473222c226475726174696f6e5f6d73223a313030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a322c226b696e64223a22636f6e74656e74222c22757269223a22326639336630616434343966396363383831623738313663366162333366643434353033376436353338623434356263633736343331663963396639316335622e6d3473222c226475726174696f6e5f6d73223a323030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a332c226b696e64223a22636f6e74656e74222c22757269223a22303664356364373962613338363063636364633235326665393135646530393032663934316133633661303030323935373134663763616231393361613663662e6d3473222c226475726174696f6e5f6d73223a313030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a342c226b696e64223a22636f6e74656e74222c22757269223a22393961323464653034663762653966633231303937653639643264363162323232653435386231663933623830383230323661393837346165623966346331352e6d3473222c226475726174696f6e5f6d73223a313630302c2261645f6964223a6e756c6c7d5d2c226d65726b6c655f726f6f74223a2266366138656566666463333263336135313035336665333862623835346534663834356337326435613936663931353762353861653463353065303236343864222c22776f726b5f636f6d706f73655f6f7073223a352c22766965776572735f736572766564223a312c226a6f756c65735f6d6963726f223a302c226772616e74223a7b226772616e745f68617368223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030222c226361706162696c697479223a22766964656f2e6d616e6966657374222c226a6f756c655f6365696c696e675f6d6963726f223a6e756c6c2c2266756e64735f6365696c696e67223a6e756c6c7d2c22706172656e745f726563656970745f68617368223a6e756c6c2c227369676e65725f7075626b6579223a2233633462613937356434626666306565336337333766303434323934346631343434383163393532326230383133643061303164303162396632366434653037222c227369676e65725f6964223a226469643a6578616d706c653a7075626c6973686572222c22736967223a226661383235326366313435613635633731653238363961653438653337393437353530356161363032343536336237636263396635663834616137353036613235333036653062356434326431306561663832363266646263646266316265666261336538346633343433376331396137313239316231346166633333373038222c2272656e646572223a7b2276657273696f6e223a372c226d656469615f73657175656e6365223a302c22646973636f6e74696e756974795f73657175656e6365223a302c227461726765745f6475726174696f6e5f73223a332c22706172745f7461726765745f6d73223a6e756c6c2c22706172745f686f6c645f6261636b5f6d73223a6e756c6c2c2263616e5f626c6f636b5f72656c6f6164223a66616c73652c22656e645f6c697374223a747275652c22706c61796c6973745f74797065223a22564f44222c22696e646570656e64656e745f7365676d656e7473223a747275652c226d61705f757269223a22696e69742e6d7034227d2c22657874726173223a5b5d7d0a", + "init": "0000001c66747970636d666300000000636d666369736f3661763031000002586d6f6f760000006c6d76686400000000000000000000000000000019000000000001000001000000000000000000000000010000000000000000000000000000000100000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002000001bc7472616b0000005c746b6864000000030000000000000000000000010000000000000000000000000000000000000000000000000001000000000000000000000000000000010000000000000000000000000000400000000040000000240000000001586d646961000000206d646864000000000000000000000000000000190000000055c400000000002668646c72000000000000000076696465000000000000000000000000766964656f000000010a6d696e6600000014766d68640000000100000000000000000000002464696e660000001c6472656600000000000000010000000c75726c2000000001000000ca7374626c0000007e7374736400000000000000010000006e6176303100000000000000010000000000000000000000000000000000400024004800000048000000000000000100000000000000000000000000000000000000000000000000000000000000000018ffff000000186176314381000c000a0a00000002aff19b5f30080000001073747473000000000000000000000010737473630000000000000000000000147374737a000000000000000000000000000000107374636f0000000000000000000000286d7665780000002074726578000000000000000100000001000000000000000000000000", + "f0": "000003286d6f6f66000000106d66686400000000000000010000031074726166000000107466686400020000000000010000001474666474010000000000000000000000000002e47472756e000007010000003c00000330000000010000011802000000000000010000001f01010000000000010000001b01010000000000010000001e01010000000000010000001a01010000000000010000001901010000000000010000001b01010000000000010000001d01010000000000010000001801010000000000010000001801010000000000010000001901010000000000010000001901010000000000010000001701010000000000010000001901010000000000010000001701010000000000010000001801010000000000010000001e01010000000000010000001a01010000000000010000001801010000000000010000001701010000000000010000001901010000000000010000001901010000000000010000001601010000000000010000001901010000000000010000001901010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001701010000000000010000001701010000000000010000002301010000000000010000001f01010000000000010000001701010000000000010000002001010000000000010000001901010000000000010000001a01010000000000010000001901010000000000010000001c01010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001701010000000000010000001b01010000000000010000001b01010000000000010000001a01010000000000010000001801010000000000010000001901010000000000010000001801010000000000010000001e01010000000000010000001c01010000000000010000001a01010000000000010000001901010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001a01010000000000010000001701010000000000010000001801010000000000010000001801010000000000010000001b01010000000007056d6461740a0a00000002aff19b5f300832890210009b0000020b0002d800ce1a563008d4ca3e2e16ec6f636ea85ac0193ff92d7f7989299e359d284aa3a420d2893c63c21fed652531c1cd6e88ed1419f54561fe9bc57ee2715d85ce85bd088b1668dfa1a42b54ef12a611fd5cf1cd0f7d90cdc79dc5dafe8f97b6ed3e1f761a8f745aee6b4e66f2869242a680d9e817f34b972859d396f4d963a1a3e53f017f116909fbc6d60f8f2d0a86225b1eef95be15e199f7ea63d1eeb196b10b54c09f1463046579924e9c30f9469a97b78813710fd5a6870a142d2ebd2efb5054a0879f8bb56e984e7db71bbe4f31277c353905c67700c5977e3d02734412f06c7056d4a934888db5fbb5e929e3bd8165b468a227bd8d30cf8b1deda859c0321d3003c080000006d00000028000008000cfee0db7d6fe60e1bd86a73240321930040104000006d00000028000008000cd861752553fe53140321c30060208800006d00000028000008000cc2a55293a8837d8603ddca832183008040d100006d00000028000008000cfe5d7f95a9e79143217300a0811a04006d00000028000008000c7550f99e3d0603219300c1016308806d00000028000008000c58cd5222c17f41d2a321b300e201ac0d106d00000028000008000c3a23dfa6661dfb95f325032163010009f511a06d00000028000008000c1f386102e80321630120107e16306d00000028b80008000bf9af923d23a321730140208f1ac06d00000028000008000d26f88070a858032173016040d11f506d00000028b80008000bb39f8db5303c0321530180811a07e06d00000028000008000bb634c73103217301a1016308f06d00000028b00008000b76c6bda4e98303215301c201ac0d106d00000028000008000b9708d53d83216301e009f511a06d00000028b80008000b3d867aa48e0321c3021c107e16306d00000028b80008000d0510040d7408e3cb1840280321830220207e56006d00000028000008000d59f1b7d5855b5e032163024040cf5a806d00000028b00008000acfb5178d64032153026081195f006d00000028000008000b56628f3c2321730281016347806d00000028000008000ac778a1f0915a03217302a201ac4c806d00000028000008000d7f7c196ec2f803214302c009f551806d00000028000008000ab3df08a3217302e0207e56006d00000028b80008000b730f2c8e543e232173030040cf5a806d00000028b0002800085795f8f8306f832163032081195f006d00000028000008000db85494205f5321630341016347806d00000028b00008000a730b293e0ac32163036201ac4c806d00000028000008000dcce854cf56932153038009f551806d00000028b00008000a35c939aab3215303a0207e56006d00000028000008000dd2ae8da883221303c040cf5a806d000c3000028b8002800bfdc4c45194543e4a16af830e0a47d36321d303e081195f006d00061800028b8002800c312c6dc53893f96b1413180321530419016347806c000618000280000080098036cc8321e304220163c5006d00082000028b8002800d78c61e69d32274c3c1e5b5efe32173044009eccd006d0006180002800000800c133d359bc80321830460207dd1006d0006180002800000800aff4447169887632173048040cfd5006d00082000028b8000800c88dd22544e0321a304a08119dd006d0008200002800000800b399959006a0f842863216304c20163c5006d0008200002800000800b6ea6d64a83216304e009eccd006d0006180002800000800aa1c06f4e0321630500207dd1006d0006180002800000800c155b9e32032153052040cfd5006d0006180002800000800a70b1bc43219305408119dd006d0008200002808000800dc3ab0503e0de5793219305620163c5006d0008200002800000800a3a3fb2a57ecf60432183058009eccd006d0006180002800000800480c25c4a55d1c3216305a0207dd1006d0002080002800000800a6653eb28c3217305c040cfd5006d000410000280000080047047186abc83216305e08119dd006d0004100002800000800a9d2848480321c306160163c5006d0006180002800000800cefa44d392fc126e7cb3c8321a306200963f9006d0006180002800000800cd763cc7d84ab2caae321830640206cf9006d0006180002800000800dfd5d361398daa32173066040cdf9006d00061800028000008003aaf99c2ea1c3216306808119f9006d00061800028000008009c988ea2c03216306a00963f9006d00061800028000008003c24c6ae9b3216306c0206cf9006d0006180002800000800a2fc8567a03218306e040cdf9006d0008200002800000800d3025eaafef7323215307008119f9006d00082000028000008009e74d9263216307200963f9006d0006180002800000800bb4847f7c0321630740206cf9006d00061800028000008009abb46b88032193076040cdf9006d0006180002808000800d875acf78ce617c0", + "f1": "000001846d6f6f66000000106d66686400000000000000020000016c7472616600000010746668640002000000000001000000147466647401000000000000000000003c000001407472756e00000701000000190000018c000000010000003302000000000000010000002901010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000002601010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001401010000000000010000002c01010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001501010000000002516d6461740a0a00000002aff19b5f300832251400234000008000000032183c0a30ea5d604a6be4b7fdf6e91b0327e1de9d1d9a2c32b62032273201e040000003680000014580014000cfe4df7ed7a61fe3eb2e7e3a7c1b33b079c9bb0231ad523212320200820000036800000140000040008af43212320301044000036800000140000040008a803212320402068800036800000140000040008780321132050408d020036800000140000040008532113206080b1844036800000140000040008232113207100d6068836800000140000040008232113208004fa88d036800000140000040007c322432090083f0b1836800000145c0004000d2107e20ef0101c6dc78a580f479cacfcf590c1f3211320a010478d6036800000140000040006c3211320b020688fa836800000140000040006c3211320c0408d03f03680000014000004000683211320d080b184783680000014000004000683211320e100d606883680000014000004000683211320f004fa88d036800000140000040006832123210e083f0b1836800000140000040009cf0322a32110103f2b00368001040001400001400d4af0224e5dd33e63d4fc77b12bf88df15dddfe01e613c5e053212321202067ad4036800104000140000040058321232130408caf803680010400014000004005032123214080b1a3c03680010400014000004005032123215100d626403680010400014000004005032123216004faa8c036800104000140000040050321232170103f2b00368001040001400000400503213321802067ad40368001040001400000400be40", + "f2": "000002b06d6f6f66000000106d666864000000000000000300000298747261660000001074666864000200000000000100000014746664740100000000000000000000550000026c7472756e0000070100000032000002b8000000010000022402000000000000010000001401010000000000010000002201010000000000010000001401010000000000010000002301010000000000010000001301010000000000010000001b01010000000000010000001301010000000000010000001b01010000000000010000001301010000000000010000001f01010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000002401010000000000010000001301010000000000010000001e01010000000000010000001301010000000000010000001e01010000000000010000001e01010000000000010000001901010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000001701010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001301010000000000010000001801010000000000010000001901010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000002001010000000000010000001301010000000000010000001a01010000000000010000001301010000000006b06d6461740a0a00000002aff19b5f300832950410009b0000020b0002dd2e1aca8acc801a7698a61db1ff5b7609ac6fd04a8b16858a9ccd728af2ee1fe52a3fea4d301d822b65ba987fb685504e5526b2e8b8ba193af1b133438d92caceac434ae35b8c9d2c31199f49ccfaee8b618c4883583beed6f6ee7089bf2b7ab1acc50b926ec22779233a488047f7cc155d7e97b5a5e1dee47f38b383bb3d01a61135c5f148bcf0aa6287d87773c45f2200e50cd367861a99ed5ec96f73352c7a815427baceefbee8710e081a4f217937a734ae996c913e807cf306a0357be06d4a141ab08615851824f76c1d1b1fb6a4944f3fb3fc2005f0e0a8aac7f268250e2844f810527ccbbc91d7f5c738d65033f3aa33b65bc2456d9dbd84ae62d46dbac3601a61c3632dd0e3a0e11b61e35bc7b09d5a3846715d1c918b0a49dd35d177e2dbc10a101890b73afe67e12ce0a0f6e3a965558c4475fbe81a37f6e3a2e083bdd5fd432b40d3e3514e689665c0f50a8a2305fd9d775d9487457f517d5dd82af0ba17acd4208a933b03760161b5192d36394fecfd76a15a368adfea7b24d24f9a867eeff9562b1946d2fdae314ba63cf97f07988eb99edeca01965d6c6ff5c635576f96720efb01e10fb8be9dfcb5178e7869b6d485e751079b2bfc069295b3f8d761215823410e3b85988c8933199231f0fb39d5ea3f326505a94e4b11f67c927ae6bea89d0ebe88a249fa02c889725a8621538c38bb4969156625fec33066394db600da181dad78e54c32123003c080000006d000000280000080009883322030040104000006d00000028000008000dc5c716b21b80f23f7241a59c935ee10321230060208800006d000000280000080008a8032213008040d100006d00000028000008000d0acd6a1000b3a1b44a4622b80e82baab83211300a0811a04006d000000280000080007f3219300c1016308806d00000028000008000ca2deb4873ed7d40c03211300e201ac0d106d000000280000080007632193010009f511a06d00000028000008000c7b4a1fb64d6ad3c90321130120107e16306d000000280000080006c321d30140208f1ac06d00000028080028000c4404b19fda0a826a06505ee3632113016040d11f506d0000002800000800068321b30180811a07e06d00000028000008000dd882ba37519964338ec803211301a1016308f06d00000028000008000683222301c201ac0d106d00000028b80028000dd49032c6e72cb7a612909141d80dcf780803211301e009f511a06d0000002800000800058321c3021c107e16306d00000028000008000db46432f01c10b01dde9ee8a321130220207e56006d0000002800000800086321c3024040cf5a806d00000028b00008000de19b7d31c3118b8b3353ed0321c3026081195f006d00000028000008000c5653f4328cfe1aeebfe9121321730281016347806d00000028000008000dd66b64ce004803211302a201ac4c806d00000028000008000503217302c009f551806d00000028000008000dc99f46e7164803211302e0207e56006d000000280000080005032153030040cf5a806d00000028000008000db629c646032113032081195f006d0000002800000800050321830341016347806d00000028000008000d9cc6c6463e9908032113036201ac4c806d000000280000080006032183038009f551806d00000028000008000e0509f0150119e403211303a0207e56006d00000028000008000303218303c040cf5a806d00000028000008000b9e6825756264fb03211303e081195f006d0000002800000800060321430419016347806c00000028000008000981066283211304220163c5006d000000280000080007e321b3044009eccd006d00000028000008000b38348570c4cb4c2ce6eba321130460207dd1006d000000280000080002032183048040cfd5006d00000028000008000c12a00403585e5553211304a08119dd006d00000028000008000203211304c20163c5006d00000028000008000203216304e009eccd006d00000028000008000b2ed0f61a440321730500207dd1006d00000028000008000afda8d1136378032113052040cfd5006d0000002800000800020321b305408119dd006d00000028b00028000a65d637e083bc86446c7973211305620163c5006d000000280000080002032173058009eccd006d00000028000008000b419bfbd7360403211305a0207dd1006d0000002800000800020321e305c040cfd5006d00000028b80028000b2822ba821299b66a7e57ef0c6583211305e08119dd006d00000028000008000203218306160163c5006d00000028000008000cbc1f1569aed6f803211306200963f9006d0000002800000800082", + "f3": "000001846d6f6f66000000106d66686400000000000000040000016c74726166000000107466686400020000000000010000001474666474010000000000000000000087000001407472756e00000701000000190000018c000000010000003302000000000000010000002901010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000002601010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001401010000000000010000002c01010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001501010000000002516d6461740a0a00000002aff19b5f300832251400234000008000000032183c0a30ea5d604a6be4b7fdf6e91b0327e1de9d1d9a2c32b62032273201e040000003680000014580014000cfe4df7ed7a61fe3eb2e7e3a7c1b33b079c9bb0231ad523212320200820000036800000140000040008af43212320301044000036800000140000040008a803212320402068800036800000140000040008780321132050408d020036800000140000040008532113206080b1844036800000140000040008232113207100d6068836800000140000040008232113208004fa88d036800000140000040007c322432090083f0b1836800000145c0004000d2107e20ef0101c6dc78a580f479cacfcf590c1f3211320a010478d6036800000140000040006c3211320b020688fa836800000140000040006c3211320c0408d03f03680000014000004000683211320d080b184783680000014000004000683211320e100d606883680000014000004000683211320f004fa88d036800000140000040006832123210e083f0b1836800000140000040009cf0322a32110103f2b00368001040001400001400d4af0224e5dd33e63d4fc77b12bf88df15dddfe01e613c5e053212321202067ad4036800104000140000040058321232130408caf803680010400014000004005032123214080b1a3c03680010400014000004005032123215100d626403680010400014000004005032123216004faa8c036800104000140000040050321232170103f2b00368001040001400000400503213321802067ad40368001040001400000400be40", + "f4": "000002386d6f6f66000000106d666864000000000000000500000220747261660000001074666864000200000000000100000014746664740100000000000000000000a0000001f47472756e000007010000002800000240000000010000020902000000000000010000001d01010000000000010000001501010000000000010000001401010000000000010000002401010000000000010000001301010000000000010000002301010000000000010000001d01010000000000010000001b01010000000000010000001801010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001901010000000000010000001401010000000000010000001701010000000000010000001901010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001301010000000000010000003101010000000000010000001301010000000000010000001301010000000000010000002d01010000000000010000001301010000000000010000002801010000000000010000001301010000000000010000001401010000000000010000002201010000000000010000001301010000000000010000001301010000000000010000002301010000000000010000001301010000000000010000001e01010000000000010000001301010000000005d36d6461740a0a00000002aff19b5f300832fa0310009b0082000020b00020dd2b5a94181dbe57910471b2b44e6393dede368a4e90bfb20da6587760f5c17da3e400091f8d227f619ce5f0f1eaa8e0ac0a28df13357428ec6013f14666fb1a5bdf65ce7121305e56d11e5d7eb8f1a2129c4c85dc5a7c8241abb5808d00bad72b22e625a5793d489bd5cb173c6ddf03c81f66d20d3e801e1ff0ac22567f766f1dbf1c6fc81d58e4cfface4b0286491f7322fa6a573b343fb86a83fd361d434077363d40bfec506965337014df2bd867ad3615e770dd02ab18bce869d9c801825195055029cb97f07e2e7fe8e45f2899585386da039947f51fa92c155fbed7db351919809e651dbda2cb2789b56312f61e1d0128674e98a13017355e6ada4bb9cd5e8d64171da47cd48921c166bdb053f7f0ff152965f9405a4f2d0b1b3a5ad18bcf35b37f0bdf664ccf0ce39d863a4b2ef846a4072b6638b15cb37fea04e92d1396eb6fb59c2b3575f9cb3cde67f26505cf70bd1671a31bb9917b9048f1366131a808ed9d2dcc8a7bbb240e5bd3eeb48be87408a289b48de51acefb98712e0c3716bb8c007314679b953c49370b0e653ed401fcb49cf89ed1afad876b13fd0dbc599bb28e2092ee3d50a5336599b48147c43a6a5bbce3b6a2a05af990f7daa839f5a8b3a11d2b5e9caeb8fe1156677b7d4f7774cf16d3196e9021d6b31c0800c73b4787541d2cb1b3c177075fa2f2321b3003c080000006d0008200002800000800cfe49bc877f23c425331321330040104000006d00082000028000008008abc321230060208800006d000000280000080008a8032223008040d100006d00000028b00028000cef0f0ebc3defe87ccbc5985936506a7a6403211300a0811a04006d000000280000080007d3221300c1016308806d00000028b80008000cc0d1d1a060bc018834f4c3b9a88800463321b300e201ac0d106d00000028000008000c82e3e8ad3f178703a5c2032193010009f511a06d00000028000008000c44d557ca4f2efd63b321630120107e16306d00000028000008000be9fe65d0770321130140208f1ac06d000000280000080005c32143016040d11f506d00000028000008000bf474fa2321130180811a07e06d00000028000008000583214301a1016308f06d00000028000008000bef716143211301c201ac0d106d00000028000008000583217301e009f511a06d00000028b80008000bf1c90697630b032123021c107e16306d000000280000080009910321530220207e56006d00000028000832000e04f8d269832173024040cf5a806d00000028000008000d309494a79756032113026081195f006d0000002800000800050321730281016347806d00000028080008000bf2be133f7a4a93211302a201ac4c806d00000028000008000503214302c009f551806d00000028000008000be4474f53211302e0207e56006d000000280000080003032113030040cf5a806d0000002800000800030322f3032081195f006d00000028b80028000c05f2208077e76c8ffd34cf1a160b673d2966a901d49689bb5106ef0973a90321130341016347806d000000280000080003032113036201ac4c806d0000002800000800060322b3038009f551806d00000028b00028000c0f343ccfcb7775cf60cdc2e6af4f42bd82bceb0edbfa14dddd0723211303a0207e56006d00000028000008000603226303c040cf5a806d00000028b00028000c1c2ad0bdbbd36a457d1bfefa5a7051155ca867ccdc03211303e081195f006d0000002800000800060321230419016347806c0000002800000800098353220304220163c5006d00000028b00028000cc8897b5e13136dbf5179c5f1b85cdbe32113044009eccd006d0000002800000800020321130460207dd1006d000000280000080002032213048040cfd5006d00000028b00028000bd95d7dbb9961d3900685bd5857e39d3be3211304a08119dd006d0000002800000800020321c304c20163c5006d00000028b00028000bdc3db4636c609195e2047c03211304e009eccd006d0000002800000800020" + }, + "outcome": { + "outcome": "accept", + "digest": "e96f6662405c2119c2e3d20fc45403804a7e52c9e46773c130a36fae5fbfaff9", + "channel": "c54a2ce987d8b6f5085ed3aaedc11cf5ac94033b937abbebbdafab9bcd8c7540", + "presentation": "on_demand", + "start": 1792886405000000, + "end": 1792886414000000, + "ended": "ended", + "presented_ms": 8000, + "viewable_ms": 8000, + "audible_ms": 8000, + "ad_ms": 0, + "delivery": [ + { + "origin": "https://media.example", + "class": "fetch", + "objects": 6, + "bytes": 9654 + }, + { + "origin": "https://media.example", + "class": "ancillary", + "objects": 2, + "bytes": 744 + } + ], + "repeats": { + "objects": 0, + "bytes": 0 + }, + "startup_ms": 1000, + "stalls": 0, + "stall_ms": 0, + "energy": [ + { + "role": "sink_decode", + "class": "unmetered", + "joules_micro": 0 + }, + { + "role": "display", + "class": "unmetered", + "joules_micro": 0 + } + ], + "claims": [], + "notes": 0, + "attachments": 0 + } +} diff --git a/wai/examples/android-app/settings.gradle.kts b/wai/examples/android-app/settings.gradle.kts new file mode 100644 index 000000000..38df0aa80 --- /dev/null +++ b/wai/examples/android-app/settings.gradle.kts @@ -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") diff --git a/wai/examples/android-app/test/android-app.test.sh b/wai/examples/android-app/test/android-app.test.sh new file mode 100755 index 000000000..cc330bd53 --- /dev/null +++ b/wai/examples/android-app/test/android-app.test.sh @@ -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 diff --git a/wai/examples/android-app/wai.json b/wai/examples/android-app/wai.json new file mode 100644 index 000000000..94b492067 --- /dev/null +++ b/wai/examples/android-app/wai.json @@ -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." +} diff --git a/wai/examples/apple-app/.gitignore b/wai/examples/apple-app/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/wai/examples/apple-app/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/wai/examples/apple-app/Package.swift b/wai/examples/apple-app/Package.swift new file mode 100644 index 000000000..99564b7cd --- /dev/null +++ b/wai/examples/apple-app/Package.swift @@ -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"), + ] +) diff --git a/wai/examples/apple-app/README.md b/wai/examples/apple-app/README.md new file mode 100644 index 000000000..0e79c9a6f --- /dev/null +++ b/wai/examples/apple-app/README.md @@ -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 /wai/wai-sink-swift && WAI_SINK_SLICES=macos scripts/build-xcframework.sh +cd my-app +export WAI_SINK_SWIFT=/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. diff --git a/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift b/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift new file mode 100644 index 000000000..15162b9fa --- /dev/null +++ b/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift @@ -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)" + } + } +} diff --git a/wai/examples/apple-app/Sources/WaiApp/Replay.swift b/wai/examples/apple-app/Sources/WaiApp/Replay.swift new file mode 100644 index 000000000..0467ef2c8 --- /dev/null +++ b/wai/examples/apple-app/Sources/WaiApp/Replay.swift @@ -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.. /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 diff --git a/wai/examples/apple-app/wai.json b/wai/examples/apple-app/wai.json new file mode 100644 index 000000000..1b6305043 --- /dev/null +++ b/wai/examples/apple-app/wai.json @@ -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." +} diff --git a/wai/examples/encoder-ingest/.gitignore b/wai/examples/encoder-ingest/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/wai/examples/encoder-ingest/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/wai/examples/encoder-ingest/Cargo.toml b/wai/examples/encoder-ingest/Cargo.toml new file mode 100644 index 000000000..1e18531ea --- /dev/null +++ b/wai/examples/encoder-ingest/Cargo.toml @@ -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" } diff --git a/wai/examples/encoder-ingest/README.md b/wai/examples/encoder-ingest/README.md new file mode 100644 index 000000000..a00c0f2b5 --- /dev/null +++ b/wai/examples/encoder-ingest/README.md @@ -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. diff --git a/wai/examples/encoder-ingest/src/main.rs b/wai/examples/encoder-ingest/src/main.rs new file mode 100644 index 000000000..215ed7494 --- /dev/null +++ b/wai/examples/encoder-ingest/src/main.rs @@ -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>>>; + +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), 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 = std::env::args().skip(1).collect(); + let synthetic: Option = 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) -> Result { + // 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) +} diff --git a/wai/examples/encoder-ingest/test/encoder-ingest.test.sh b/wai/examples/encoder-ingest/test/encoder-ingest.test.sh new file mode 100755 index 000000000..5d4d46fcd --- /dev/null +++ b/wai/examples/encoder-ingest/test/encoder-ingest.test.sh @@ -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 diff --git a/wai/examples/encoder-ingest/wai.json b/wai/examples/encoder-ingest/wai.json new file mode 100644 index 000000000..1341393d3 --- /dev/null +++ b/wai/examples/encoder-ingest/wai.json @@ -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." +} diff --git a/wai/examples/messaging/.gitignore b/wai/examples/messaging/.gitignore new file mode 100644 index 000000000..7a3455e10 --- /dev/null +++ b/wai/examples/messaging/.gitignore @@ -0,0 +1 @@ +.wai/ diff --git a/wai/examples/messaging/README.md b/wai/examples/messaging/README.md new file mode 100644 index 000000000..8682bb057 --- /dev/null +++ b/wai/examples/messaging/README.md @@ -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. diff --git a/wai/examples/messaging/index.html b/wai/examples/messaging/index.html new file mode 100644 index 000000000..72ff7c243 --- /dev/null +++ b/wai/examples/messaging/index.html @@ -0,0 +1,24 @@ + + + + + +Messages and calls + + + +

Messages and calls

+
+ + + + +
+ + diff --git a/wai/examples/messaging/test/messaging.test.mjs b/wai/examples/messaging/test/messaging.test.mjs new file mode 100644 index 000000000..74e14e9ce --- /dev/null +++ b/wai/examples/messaging/test/messaging.test.mjs @@ -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"); diff --git a/wai/examples/messaging/wai.json b/wai/examples/messaging/wai.json new file mode 100644 index 000000000..f5fa0d606 --- /dev/null +++ b/wai/examples/messaging/wai.json @@ -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 } +} diff --git a/wai/examples/package-and-deliver/.gitignore b/wai/examples/package-and-deliver/.gitignore new file mode 100644 index 000000000..22393500e --- /dev/null +++ b/wai/examples/package-and-deliver/.gitignore @@ -0,0 +1,3 @@ +.wai/ +video-base-01.y4m +package/ diff --git a/wai/examples/package-and-deliver/README.md b/wai/examples/package-and-deliver/README.md new file mode 100644 index 000000000..918dc2502 --- /dev/null +++ b/wai/examples/package-and-deliver/README.md @@ -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. diff --git a/wai/examples/package-and-deliver/floor/v.obu b/wai/examples/package-and-deliver/floor/v.obu new file mode 100644 index 000000000..40171fdd0 Binary files /dev/null and b/wai/examples/package-and-deliver/floor/v.obu differ diff --git a/wai/examples/package-and-deliver/index.html b/wai/examples/package-and-deliver/index.html new file mode 100644 index 000000000..f6778e843 --- /dev/null +++ b/wai/examples/package-and-deliver/index.html @@ -0,0 +1,27 @@ + + + + + +A packed title + + + +

A packed title

+

Run sh pack.sh first, then reload.

+ + + + diff --git a/wai/examples/package-and-deliver/pack.sh b/wai/examples/package-and-deliver/pack.sh new file mode 100755 index 000000000..5633b1b21 --- /dev/null +++ b/wai/examples/package-and-deliver/pack.sh @@ -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" diff --git a/wai/examples/package-and-deliver/player.js b/wai/examples/package-and-deliver/player.js new file mode 100644 index 000000000..352579c62 --- /dev/null +++ b/wai/examples/package-and-deliver/player.js @@ -0,0 +1,30 @@ +// Play a packed title's floor in a