diff --git a/.github/workflows/join.yml b/.github/workflows/join.yml new file mode 100644 index 0000000..4e21a04 --- /dev/null +++ b/.github/workflows/join.yml @@ -0,0 +1,64 @@ +name: Join page + +# Deploys the join page (js/join) to GitHub Pages from main, at the domain +# `--join-page` defaults to. Check builds it on every ready PR. + +permissions: + contents: read + +on: + push: + branches: [main] + paths: + - "js/join/**" + - "package.json" + - "bun.lock" + - "flake.lock" + - ".github/workflows/join.yml" + workflow_dispatch: + +# One deploy at a time, and never cancel one halfway. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + if: github.repository_owner == 'moq-dev' + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/nix + + - name: Build + run: | + nix develop --command bun install --frozen-lockfile + nix develop --command bun run --cwd js/join build + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: js/join/dist + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + # The only job with write access. `github-pages` deploys only from main. + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + permissions: + pages: write + id-token: write + + steps: + - id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.gitignore b/.gitignore index 26da5a3..8fd68f6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ result # direnv .direnv + +# The join page's dependencies and build. +node_modules +dist diff --git a/Cargo.lock b/Cargo.lock index 9395c63..d603dd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3196,6 +3196,7 @@ name = "moq-auth" version = "0.2.1" dependencies = [ "aws-lc-rs", + "axum", "base64 0.23.1", "data-encoding", "jsonwebtoken", @@ -3398,6 +3399,17 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "moq-room" +version = "0.2.11" +dependencies = [ + "kio", + "moq-auth", + "moq-json", + "moq-net", + "thiserror 2.0.21", +] + [[package]] name = "moq-scene" version = "0.1.0" @@ -3467,11 +3479,13 @@ dependencies = [ "moq-mux", "moq-net", "moq-relay", + "moq-room", "moq-scene", "moq-stats", "moq-tokio", "moq-video", "pollster", + "qrcodegen", "tempfile", "tokio", "tracing", @@ -4925,6 +4939,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 420e5bf..b4cb309 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,13 @@ allow_attributes_without_reason = "warn" [workspace.dependencies] hang = { path = "moq/rs/hang" } kio = { path = "moq/rs/kio" } +moq-auth = { path = "moq/rs/moq-auth", default-features = false } moq-audio = { path = "moq/rs/moq-audio", default-features = false } -moq-auth = { path = "moq/rs/moq-auth" } moq-json = { path = "moq/rs/moq-json" } moq-mux = { path = "moq/rs/moq-mux" } moq-net = { path = "moq/rs/moq-net" } moq-relay = { path = "moq/rs/moq-relay", default-features = false } +moq-room = { path = "moq/rs/moq-room" } moq-scene = { path = "rs/moq-scene" } moq-stats = { path = "moq/rs/moq-stats" } moq-tokio = { path = "moq/rs/moq-tokio", default-features = false } diff --git a/README.md b/README.md index 633c0da..67ff8fa 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Every panel is a tab you can drag, split, or stack. | Items | Place, show, hide, and order what the preview scene or the top layer draws, back to front. Select an item to filter or copy it. | | Sources | Add a source by hand, see whether each and its own track are live and where it is shown, clone one, and remove one. Select a source to filter it. | | Devices | List this machine's cameras, displays, windows, and audio inputs, and add one as a source. | +| Guests | Invite a guest, show their join link as a QR code, see who joined, and take a guest out of every scene. | | Filters | Crop the selected source or item, add, order, and set its filters, set an item's blend mode, and copy or paste filters. | | Audio | Set each audio source's gain and mute, and watch its meter and the program's. | | Output | See the broadcast path, whether the program is encoding, the canvas, and the encoder's work. | @@ -134,6 +135,63 @@ best video and audio its catalog lists, switches when the catalog changes, and comes back on its own when the publisher restarts. Audio from every source is mixed into the program whether or not its scene is live; mute a source to leave it out. +## Guests + +Guests and phones join a room the studio owns on its relay, from a web page, +and their camera and screen appear as broadcast sources. The room follows +[moq-room](https://github.com/moq-dev/moq/tree/main/rs/moq-room): a guest +named `alice` publishes `alice/camera.hang` and `alice/screen.hang` under it. +Guests need a relay that verifies tokens, so run your own with +[`moq auth serve`](https://doc.moq.dev/bin/cli) behind it, and a key that can +sign only for the room: + +```bash +moq auth generate --algorithm ES256 --out room.jwk --public-dir keys \ + --root show/guests --publish '**' --subscribe '**' +moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \ + --room guests --room-key room.jwk --join-page https://join.example.com/ +``` + +- `--room` is a path under the `--connect` URL's, here `show/guests` on the + relay. The studio's own token must let it subscribe there. A guest's browser + dials the same relay, so `--connect` must be `https://`, or `http://` only to + this machine or the local network. +- `--room-key` signs each guest's token. The studio refuses a key whose scope + reaches outside the room, so a stolen key cannot sign for the rest of the + relay. Put its public half in the directory `moq auth serve --key-dir` reads. +- `--join-page` is where the join page (`js/join`, `bun run --cwd js/join + build`) is hosted. Browsers open the camera only on an `https://` page or + `localhost`, so the studio refuses any other. It defaults to `https://join.studio.moq.dev/`, which this + repository deploys from `main`, but that domain is not live yet, so for now + host the page yourself and pass its URL. +- `--guest-expiry` (default `4h`) is how long a link works. The relay + disconnects a guest when their link expires, so send a new one before then. +- With `--failover`, a link still names only the `--connect` relay: the join + page dials one relay, and a guest's token is rooted at the room there, so + guests drop while that relay is down. The studio follows the room through + both relays, like any broadcast source, so a guest the failover routes at + the same path, as a relay in the same cluster does, keeps reaching it. + Anyone the failover lets publish in the room joins as a guest, so guard the + room there as on the `--connect` relay. + +**Invite** in the Guests panel makes a link for one guest name and shows it as +a QR code; **Copy link** puts it on the clipboard instead. The guest's token is +in the link's fragment, which the browser never sends to a server, and lets +them publish only under their own name, so they cannot appear as someone else. +Treat the link like a password: anyone holding it can join as that guest until +it expires. A guest's broadcasts become sources named after them (`alice`, +`alice screen`) when they first appear, muted and in no scene: place them from +the Items panel and unmute them in Audio to put them on air. That includes a +guest invited before a restart whose link still works. A guest's source is +saved to the scene file but is not your edit: it stays out of History, so undo +never removes it and a guest joining never loses what you could redo. A guest +who reconnects after you removed their source comes back under a new name +(`alice 2`), since undoing the removal brings back the old one. +**Remove from scenes** is your edit, so it undoes: it takes a guest, any clone +of their sources, and any mask reading them out of every scene and the top +layer and mutes them; a signed token cannot be taken back, so they stay +connected until it expires. + ## Scene file The collection is TOML, so it diffs and reviews like code. It names devices, @@ -374,9 +432,11 @@ just check # Lint everything, including the quest tree just test # Run the tests just ci # Both, as CI runs them just fix # Auto-fix formatting +just join # Serve the join page locally ``` -`rs/moq-scene` is the UI-free compositor and `rs/moq-studio` the app. See +`rs/moq-scene` is the UI-free compositor, `rs/moq-studio` the app, and +`js/join` the join page, built on `@moq/room` and `@moq/publish`. See [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md), and the [quest tree](quest/README.md) for planned work. diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..0ae743c --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,51 @@ +// moq's settings, so the join page reads like moq's own packages. +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + // Only the JavaScript. `!!moq` keeps the submodule out of the scan, whose + // own root config biome would otherwise refuse. + "includes": ["js/**", "package.json", "biome.jsonc", "!!moq"] + }, + "formatter": { + "lineWidth": 120, + "indentStyle": "tab", + "indentWidth": 4, + "lineEnding": "lf" + }, + "linter": { + "rules": { + "suspicious": { + "noImportCycles": { + "level": "error", + "options": { + "ignoreTypes": true + } + } + }, + "nursery": { + "noFloatingPromises": "error", + "noMisusedPromises": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "yaml": { + "formatter": { + "enabled": false + } + }, + "markdown": { + "formatter": { + "enabled": false + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c6b4854 --- /dev/null +++ b/bun.lock @@ -0,0 +1,242 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "studio", + "devDependencies": { + "@biomejs/biome": "^2.6.4", + }, + }, + "js/join": { + "name": "@moq/studio-join", + "version": "0.0.0", + "dependencies": { + "@moq/net": "^0.4.7", + "@moq/publish": "^0.5.4", + "@moq/room": "^0.2.1", + "@moq/signals": "^0.2.6", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + "vite": "^8.3.5", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.6.4", "", { "optionalDependencies": { "@biomejs/cli-android-arm64": "2.6.4", "@biomejs/cli-android-x64": "2.6.4", "@biomejs/cli-darwin-arm64": "2.6.4", "@biomejs/cli-darwin-x64": "2.6.4", "@biomejs/cli-linux-arm64": "2.6.4", "@biomejs/cli-linux-arm64-musl": "2.6.4", "@biomejs/cli-linux-x64": "2.6.4", "@biomejs/cli-linux-x64-musl": "2.6.4", "@biomejs/cli-win32-arm64": "2.6.4", "@biomejs/cli-win32-x64": "2.6.4" }, "bin": { "biome": "bin/biome" } }, "sha512-8sKKwaNW4ot22awZ3lBjwWLHnUFXgXnrBPYS2gNlBAXRbDC++7kqjK2H7dR/FgCDtPvO1xfKpgEhJkGxAKYkxA=="], + + "@biomejs/cli-android-arm64": ["@biomejs/cli-android-arm64@2.6.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jwrwigP4tBcHxzuV8izioX/BOUhJL/4e2rNSOE5nCjBkVC018LQAQUGiH+CNkFYvLtQ1dcZo5/xXERmaAGc7JQ=="], + + "@biomejs/cli-android-x64": ["@biomejs/cli-android-x64@2.6.4", "", { "os": "android", "cpu": "x64" }, "sha512-HdGInPJ4nZFwHxzvEtcp2nmSBtUyJrurmtIK43upFckuS3+ZqVM05HtsxQWnqGsuy/pxckIAjyKWlEKRlBXUyA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0VvcUz46wbg59Q4TZ59KkIjDJrxEyu4Eoe79+F4SChxoWe+W7AEyIp6F5r72C9+p6MsB6dIovnZt1D30lGM3XA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fxmvkF81srlS3DiMyfm+aG3J0eG+6/BjaxRyjy57KbLSafnq0PO+hHfVWmtH2ty0S7nm6yDXBRwySfFeaRJKRQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+bK1Y/evmIUiJr7auertuMbiaYVicJhqA1bZyL47lCMWugv8l0gFQCqvR/gN1vZMHYm+HwoTKgeMxdNFCoQTg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-31ZaH/MHqPX+O+evRwRrCHQ8W5/9adY+/eMytGGWZnRx1T41QsWIF09+/WujynLBhWZUxNGS5CtsWLwgOFTAyg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tbFZMdL6dP/ws4edXxYG9WhvaVa1ZDzL4G5DzahnWW7Psib9H+VTdyU4XXVxpJk5j/nYZroSbvSNyYlpW3yCIw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-bomSa2XdQAGGj6Cwdm4M13qE+lN9+AOlW8KtI4hdyVyRRF/Z71qPAi95T4wtdZK0QpBbkTW8hgbZZCwPi4xzIw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-eCsqt8Ma3eVap+oyJCDnJZH60SIU464B66n3y38EUTJdM1T/HfZol1FJC9vb+sHToX0ix8XzqPcwJnOzyAJz0g=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-pYsMy5AThiHFOZAsngg8qN4PurM6nInKBhVX7sbj3eNpRQqo1Hb0MgCwPL7VqVMrmcEEMj9Ks3qbkallR65e8g=="], + + "@kixelated/libavjs-webcodecs-polyfill": ["@kixelated/libavjs-webcodecs-polyfill@0.5.6", "", { "dependencies": { "@libav.js/types": "^6.7.7", "@ungap/global-this": "^0.4.4" } }, "sha512-A6FBsEAT/t0cDDaxo3h5XabiHX2RtycQbWV+MNKUghmctrXU1ygl05i3TTtnSF/eLmCLLO8xBMrXO+eGnpLWFw=="], + + "@libav.js/types": ["@libav.js/types@6.10.9", "", {}, "sha512-AdngxD/VO8OI6/w7wlJC77kuFcsSvCUuo2RuC6gGeSW2DEvbzgBo2JtFLV6UWZp/tRNZhKm6jS0Tv8gwCKBHLg=="], + + "@libav.js/variant-opus-af": ["@libav.js/variant-opus-af@6.10.9", "", {}, "sha512-yULJUCQIHDtwDPbT38t9pP/7EGgqwynVcOau46hZjtCe8TWMp67DTi2X0Emr2M4Ci3nzxNPf25IzO+IXSmHDBg=="], + + "@moq/flate": ["@moq/flate@0.1.4", "", { "dependencies": { "pako": "^3.0.2" } }, "sha512-o+rQuNeHpNC81oLX38DV4HwmZOTJqjvkluNh3Z/qOJ+kpqDAB8rOS9ge1qSk6q399XC9gwBzbWE0RIyWUfWwpQ=="], + + "@moq/hang": ["@moq/hang@0.5.4", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.10.9", "@moq/json": "^0.4.3", "@moq/loc": "^0.2.4", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "@svta/cml-iso-bmff": "^1.0.7", "@svta/cml-utils": "^1.6.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-oHRqFBF1P5O1TVpFmxqWRM6Nkltnu8jzvybk/nIBSyMbUYeFWbbiWaWNDxXu3jeXX7ohm01oh5kkrwT/+bQ1uw=="], + + "@moq/json": ["@moq/json@0.4.3", "", { "dependencies": { "@moq/flate": "^0.1.3", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-HTfLRnuvA5sWvLzXjsfAfrdAUJiLG8FyiWUGmWR598kPq+S2SNJ3gK1uoUWeb/+z/fgEY/kdrg1BHv+TmAz/3g=="], + + "@moq/loc": ["@moq/loc@0.2.4", "", { "dependencies": { "@moq/net": "^0.4.2" } }, "sha512-cwe0y+j7mfIuC+DOrfdLB1k5YU2+/xIfhnTv8VEiKLM5ZyfOZHblK3UwnrKqChcKkGFt3FkedRWFLMDIt9GCuA=="], + + "@moq/msf": ["@moq/msf@0.3.1", "", { "dependencies": { "@moq/net": "^0.4.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-ZM9aZEtK/3dudeM2t5N9lujIpqEeDDOi6uHQp63mYOsBLlEUFEKff6y6WLicJJxGT9BM3h0mxh1Sv9YBT6jceQ=="], + + "@moq/net": ["@moq/net@0.4.7", "", { "dependencies": { "@moq/pattern": "^0.2.1", "@moq/qmux": "^0.3.3", "@moq/signals": "^0.2.6", "async-mutex": "^0.5.0", "bowser": "^2.14.1" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-klFav+4XZjR1GsgRJr6jNt1BuBNGc01+AivI8a1f/pBeKNcqL7t9k9xn5RP78Bprnf4H7BUgTi14vvexPCuTNA=="], + + "@moq/pattern": ["@moq/pattern@0.2.1", "", {}, "sha512-NXlegs0abkYoIUkcxyJjnUHC1CGw8VkooAbuNKLK5AF0hCAyUWw1QNliHaLBty4K76qfrrvJK6cf7j31Z0KF0A=="], + + "@moq/publish": ["@moq/publish@0.5.4", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/net": "^0.4.7", "@moq/signals": "^0.2.6", "mediabunny": "^1.58.1" } }, "sha512-uOMShdgkpXZ4WNjD8IDPlbrXl6UB69IeT3OrcDzX45nAJxuzNKJsOMwh7XPxa2/2Kr6fmV71XxUGb1ap74GHjg=="], + + "@moq/qmux": ["@moq/qmux@0.3.4", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.1" } }, "sha512-6XxjqsK1bi2bdIZ7+g8gkHh3v6NOEArqb6IMtHokZI+wP4Y7OB45zHrxUe7v42pcf08RnfqfTkphxHgZfVLyPw=="], + + "@moq/room": ["@moq/room@0.2.1", "", { "dependencies": { "@moq/hang": "^0.5.1", "@moq/json": "^0.4.1", "@moq/net": "^0.4.1", "@moq/publish": "^0.5.1", "@moq/signals": "^0.2.5", "@moq/watch": "^0.6.1" } }, "sha512-QEGqpcCF0x1zZFg1FYxny/NfMc7bFCY2lqAgjfD+NgKIf7xgbi/YGziUwynkhciTBZUdfHDD1FoJNlWxhdgmVA=="], + + "@moq/signals": ["@moq/signals@0.2.6", "", { "peerDependencies": { "@types/react": "^19.2.17", "react": "^19.3.0", "solid-js": "^1.9.15" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-8L5y9cJSOVYGrHCZ20kcZsrmoxVXzl2dBQhsRj8H4zbbul70SmZcdeLG+O3JBP5W3tjo8EmLR+DOmh1/p7QQYw=="], + + "@moq/studio-join": ["@moq/studio-join@workspace:js/join"], + + "@moq/watch": ["@moq/watch@0.6.6", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/msf": "^0.3.1", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "media-captions": "0.0.18" } }, "sha512-wtUoblpIS/UTgrW/IeQ6dlsZJDVo5nAbzU8oJWDU4K6JSiKyD1b08p19LPloeRJ/UPpA17r1PM4l35IOuoMXdg=="], + + "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.1", "", {}, "sha512-VKi/GE/CLqmbEZlgNbBLbAFk2pQTxT+KUD2DjCSoxqZxbS3KBe5BBZ0m3fila4fEGyll2JT5MmgEyVr4zkZEVA=="], + + "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.7", "", { "peerDependencies": { "@svta/cml-utils": "^1.6.2" } }, "sha512-pYM60+pcGd8G8USeVKOe4X25FdmKd4ihTLQWRszxSE/E0I/SFugqeexdlXyBiJbOhkzc8PFceIWv+RvrB9+NDw=="], + + "@svta/cml-utils": ["@svta/cml-utils@1.7.0", "", {}, "sha512-NNa9v5CxrmULRAu5zx1ENHVwF4xKfclEdm+OmaIZDwoTNLVpf7JGgwbxkYsbRQYPCZrQTTLlR4sVjwgS6gcmug=="], + + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], + + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.12", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="], + + "@zod/mini": ["@zod/mini@4.6.7", "", { "peerDependencies": { "zod": "^4.6.0" } }, "sha512-oLORzy4OERpr+gaITCsrCK9uDWd9lOCLG00fRZEJidVITLS5SQtifmzP6fBSFH9YxkXFoT6/o4bRZDq2tTHrog=="], + + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + + "bowser": ["bowser@2.15.0", "", {}, "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ=="], + + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], + + "mediabunny": ["mediabunny@1.65.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-TNnDNOmD5Fme4rwMGWuNv485a9iQwd9lgw88PI9zQ0FMnzEUoo6EJpG7ZhdJ2iwZFWo8AzNkP1KPq5IAHaktKw=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "pako": ["pako@3.0.3", "", {}, "sha512-tmrebBBYI5FOw8QICGwJNqE8fotC8R+gwcxOvoFNv+GKehOI6Lc6YBolNcQNnC4v96l1wt7rh102ifD4kVeZAQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], + + "rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], + + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/flake.nix b/flake.nix index 048ecb6..d268027 100644 --- a/flake.nix +++ b/flake.nix @@ -117,6 +117,9 @@ cargo-shear quest-cli + # The join page: install, lint, type-check, test, and build. + bun + # `just check` lints. shellcheck shfmt diff --git a/js/join/index.html b/js/join/index.html new file mode 100644 index 0000000..c7cbadf --- /dev/null +++ b/js/join/index.html @@ -0,0 +1,39 @@ + + + + + + + + + Join the studio + + + + +
+

Join the studio

+ + + + + +
+ + + + + diff --git a/js/join/package.json b/js/join/package.json new file mode 100644 index 0000000..d5ad0d6 --- /dev/null +++ b/js/join/package.json @@ -0,0 +1,26 @@ +{ + "name": "@moq/studio-join", + "private": true, + "type": "module", + "version": "0.0.0", + "description": "The page a guest opens to join a MoQ Studio room", + "license": "(MIT OR Apache-2.0)", + "repository": "github:moq-dev/studio", + "scripts": { + "dev": "vite", + "build": "vite build", + "check": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@moq/net": "^0.4.7", + "@moq/publish": "^0.5.4", + "@moq/room": "^0.2.1", + "@moq/signals": "^0.2.6" + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + "vite": "^8.3.5" + } +} diff --git a/js/join/src/index.css b/js/join/src/index.css new file mode 100644 index 0000000..0908644 --- /dev/null +++ b/js/join/src/index.css @@ -0,0 +1,73 @@ +:root { + color-scheme: dark; + font-family: system-ui, sans-serif; + background: #111; + color: #eee; +} + +main { + max-width: 40rem; + margin: 0 auto; + padding: 1rem; +} + +button { + font: inherit; + padding: 0.5rem 1rem; + border: 1px solid #444; + border-radius: 0.375rem; + background: #222; + color: inherit; + cursor: pointer; +} + +button[aria-pressed="true"] { + background: #1f6f4a; + border-color: #2a9d68; +} + +#join { + background: #1f6f4a; +} + +canvas { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 0.375rem; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: #c93; +} + +.dot.connected { + background: #3c8; +} + +.dot.disconnected, +.error { + color: #f77; + background: #c33; +} + +.error { + background: none; +} diff --git a/js/join/src/index.ts b/js/join/src/index.ts new file mode 100644 index 0000000..f45ac4d --- /dev/null +++ b/js/join/src/index.ts @@ -0,0 +1,146 @@ +/** + * The join page: a guest opens the studio's link and publishes their camera, + * microphone, and screen into the studio's room, following moq-room's + * `{name}/camera.hang` and `{name}/screen.hang`. + * + * @module + */ + +import * as Net from "@moq/net"; +import * as Publish from "@moq/publish"; +import { Local } from "@moq/room"; +import { Effect } from "@moq/signals"; +import { type Link, parse } from "./link.ts"; + +/** Where the fragment is kept once it leaves the address bar, so a reload rejoins. */ +const STORED = "moq-studio-join"; + +function element(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`missing #${id}`); + return found as T; +} + +function fail(err: unknown): void { + const error = element("error"); + error.textContent = err instanceof Error ? err.message : String(err); + error.hidden = false; +} + +/** + * `use` on the page's session storage, or `undefined` where the browser denies + * it, which only costs rejoining after a reload. + */ +function stored(use: (storage: Storage) => T): T | undefined { + try { + return use(sessionStorage); + } catch { + return undefined; + } +} + +/** The link this page was opened with, moved out of the address bar. */ +function read(): Link { + const fragment = location.hash.slice(1) || stored((storage) => storage.getItem(STORED)); + if (!fragment) throw new Error("open the join link the host sent you"); + const link = parse(fragment); + // Out of the address bar, history, and anything that shows them, such as a screen share. + stored((storage) => storage.setItem(STORED, fragment)); + history.replaceState(null, "", location.pathname + location.search); + return link; +} + +/** Publish into the room until the guest leaves. Returns how to leave. */ +function join(link: Link): () => void { + const signals = new Effect(); + const connection = new Net.Connection({ url: link.url, enabled: true }); + signals.cleanup(() => connection.close()); + + const local = new Local({ + connection, + identity: Net.Path.from(link.name), + enabled: true, + cameraEnabled: true, + microphoneEnabled: true, + user: { id: link.name, name: link.name }, + }); + signals.cleanup(() => local.close()); + + const preview = new Publish.Preview.Renderer({ + canvas: element("preview"), + frames: local.cameraCapture.out.frames, + display: local.cameraCapture.out.display, + flip: true, + }); + signals.cleanup(() => preview.close()); + + // A refused permission or a missing device leaves the toggle on with nothing sent, so say so. + signals.run((effect) => { + const failures = [ + ["camera", effect.get(local.webcam.out.error)], + ["microphone", effect.get(local.microphone.out.error)], + ] as const; + const messages = failures.flatMap(([device, error]) => (error ? [`No ${device}: ${error.message}`] : [])); + // The screen picker gives no reason when it is closed or refused. + if (effect.get(local.screenEnabled) && !effect.get(local.share.out.source)) { + messages.push("No screen shared: pick one in the browser's prompt, or turn Share screen off."); + } + const capture = element("capture"); + capture.textContent = messages.join(" "); + capture.hidden = messages.length === 0; + }); + + signals.run((effect) => { + const status = effect.get(connection.status); + element("status").textContent = status.charAt(0).toUpperCase() + status.slice(1); + element("status-dot").className = `dot ${status}`; + const error = effect.get(connection.error); + if (error) fail(error); + }); + + const toggles = [ + ["camera", local.cameraEnabled], + ["microphone", local.microphoneEnabled], + ["screen", local.screenEnabled], + ] as const; + for (const [id, enabled] of toggles) { + const button = element(id); + signals.event(button, "click", () => enabled.set(!enabled.peek())); + signals.run((effect) => { + button.setAttribute("aria-pressed", String(effect.get(enabled))); + }); + } + + return () => signals.close(); +} + +function main(): void { + let link: Link; + try { + link = read(); + } catch (err) { + fail(err); + return; + } + + element("name").textContent = link.name; + element("ready").hidden = false; + + // Capture starts on a click, which some phones require before they open the camera. + let leave: (() => void) | undefined; + element("join").addEventListener("click", () => { + element("ready").hidden = true; + element("error").hidden = true; + element("joined").hidden = false; + leave = join(link); + }); + element("leave").addEventListener("click", () => { + leave?.(); + leave = undefined; + stored((storage) => storage.removeItem(STORED)); + element("joined").hidden = true; + element("ready").hidden = false; + }); +} + +main(); diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts new file mode 100644 index 0000000..2fb01fc --- /dev/null +++ b/js/join/src/link.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { parse } from "./link.ts"; + +const fragment = (params: Record) => new URLSearchParams(params).toString(); + +test("a link dials the room with its token in the query", () => { + const link = parse(fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c" })); + expect(link.name).toBe("alice"); + expect(link.url.origin).toBe("https://relay.example:4443"); + expect(link.url.pathname).toBe("/show/guests"); + expect(link.url.searchParams.get("jwt")).toBe("a.b.c"); +}); + +test("a name with spaces survives the fragment's encoding", () => { + const link = parse(fragment({ url: "https://relay.example/room", name: "Alice Smith", jwt: "t" })); + expect(link.name).toBe("Alice Smith"); +}); + +test("an incomplete or malformed link is refused", () => { + const valid = { url: "https://relay.example/room", name: "alice", jwt: "t" }; + for (const key of ["url", "name", "jwt"] as const) { + const { [key]: _, ...missing } = valid; + expect(() => parse(fragment(missing))).toThrow("incomplete"); + } + expect(() => parse(fragment({ ...valid, url: "not a url" }))).toThrow("invalid relay"); + expect(() => parse(fragment({ ...valid, url: "moqt://relay.example/room" }))).toThrow("cannot dial"); + for (const name of ["a/b", "a\\b", " alice"]) { + expect(() => parse(fragment({ ...valid, name }))).toThrow("invalid guest"); + } +}); diff --git a/js/join/src/link.ts b/js/join/src/link.ts new file mode 100644 index 0000000..455cd40 --- /dev/null +++ b/js/join/src/link.ts @@ -0,0 +1,40 @@ +/** + * The join link's fragment: the room's relay URL, the guest's name, and the + * token the studio signed for that name. A fragment never reaches a server, so + * the token stays out of every request log on the way here. + * + * @module + */ + +/** A guest's way into the studio's room. */ +export interface Link { + /** The relay at the room, with the token in its query, as `@moq/net` dials it. */ + url: URL; + /** The guest's identity in the room: one path segment. */ + name: string; +} + +/** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */ +export function parse(fragment: string): Link { + const params = new URLSearchParams(fragment); + const relay = params.get("url"); + const name = params.get("name"); + const jwt = params.get("jwt"); + if (!relay || !name || !jwt) throw new Error("this join link is incomplete; ask the host for a new one"); + + let url: URL; + try { + url = new URL(relay); + } catch { + throw new Error(`this join link names an invalid relay: ${relay}`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`a browser cannot dial ${url.protocol}// relays`); + } + if (name.includes("/") || name.includes("\\") || name.trim() !== name) { + throw new Error(`this join link names an invalid guest: ${name}`); + } + + url.searchParams.set("jwt", jwt); + return { url, name }; +} diff --git a/js/join/tsconfig.json b/js/join/tsconfig.json new file mode 100644 index 0000000..461eb10 --- /dev/null +++ b/js/join/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"], + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "isolatedModules": true, + "types": ["bun"], + + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "useUnknownInCatchVariables": true, + "erasableSyntaxOnly": true, + "skipLibCheck": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/js/join/vite.config.ts b/js/join/vite.config.ts new file mode 100644 index 0000000..4654c3e --- /dev/null +++ b/js/join/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + // Relative asset URLs, so the built page works from any path it is hosted under. + base: "./", + build: { + target: "esnext", + // @moq/publish's capture and encode pipeline is most of the page, in one chunk. + chunkSizeWarningLimit: 1024, + }, +}); diff --git a/justfile b/justfile index a40fff9..dd34f2e 100644 --- a/justfile +++ b/justfile @@ -7,7 +7,7 @@ features := "moq-scene/capture,moq-studio/capture,moq-studio/openh264" default: @just --list -# Lint the repository tooling, the quest tree, and the workspace. +# Lint the repository tooling, the quest tree, the join page, and the workspace. check: sh/check-moq-pin.sh quest check @@ -16,6 +16,10 @@ check: shfmt -d .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh taplo format --check nixfmt --check flake.nix + bun install --frozen-lockfile + bun biome check . + bun run check + bun run --cwd js/join build cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo clippy --locked --workspace --all-targets --features {{ features }} -- -D warnings @@ -24,6 +28,7 @@ check: [positional-arguments] test *args: bash .claude/hooks/direnv.test.sh + bun run test cargo nextest run --locked --workspace --features {{ features }} "$@" # Run what CI's Check runs: the lints, then the tests. @@ -36,6 +41,7 @@ fix: shfmt -w .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh taplo format nixfmt flake.nix + bun biome check --write . cargo fmt --all # Composite scenes and publish a program through wgpu on Mesa's software Vulkan @@ -53,3 +59,8 @@ render-sw: # Move the moq submodule to moq's latest main (or a given commit) and refresh Cargo.lock. upgrade-moq rev="origin/main": sh/upgrade-moq.sh {{ rev }} + +# Serve the join page locally while working on it. +join: + bun install --frozen-lockfile + bun run --cwd js/join dev diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2ae849 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "studio", + "version": "0.0.0", + "private": true, + "type": "module", + "workspaces": [ + "js/join" + ], + "scripts": { + "check": "bun run --filter='*' check", + "test": "bun run --filter='*' test", + "fix": "bun biome check --write ." + }, + "devDependencies": { + "@biomejs/biome": "^2.6.4" + } +} diff --git a/quest/m0/README.md b/quest/m0/README.md index 37e1902..6b91606 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -96,7 +96,7 @@ repository. - [ML video effects](/quest/m0/effects.md) - background removal and other ML effects, baked into their source's track - [Destinations](/quest/m0/destinations.md) - RTMP, RTMPS, and WHIP push with per-destination codecs and shared encoders, on a UI-free egress library a hosted service can reuse - [Multiple canvases](/quest/m0/multi-canvas.md) - landscape and portrait canvases composite at once from shared sources -- [Guests](/quest/m0/guests.md) - remote guests and phone cameras join through a moq-room link or QR code and appear as sources +- [The join page is live](/quest/m0/join-page-live.md) - `join.studio.moq.dev` serves the join page `--join-page` defaults to - [Guest return feed](/quest/m0/guest-return.md) - guests see the program and hear a mix-minus - [Guest token refresh](/quest/m0/guest-refresh.md) - a show outlasts the guest link's expiry without new links - [Audio mixes](/quest/m0/mixes.md) - per-output mixes, meters, monitoring, and sync offsets diff --git a/quest/m0/broadcast-sync.md b/quest/m0/broadcast-sync.md index dba0ab0..437d640 100644 --- a/quest/m0/broadcast-sync.md +++ b/quest/m0/broadcast-sync.md @@ -38,4 +38,3 @@ video waits for audio instead. Update `README.md`. ## Related - [Audio jitter target](https://github.com/moq-dev/moq/blob/main/quest/m1/audio-jitter-target/README.md) - the delay estimate this reuses -- [Guests](/quest/m0/guests.md) - guests are broadcast sources and inherit this diff --git a/quest/m0/guests.md b/quest/m0/guests.md deleted file mode 100644 index 01a714a..0000000 --- a/quest/m0/guests.md +++ /dev/null @@ -1,27 +0,0 @@ -# [M] Studio guests - -## Goal - -The studio owns a moq-room prefix and shows a join link and QR code. A guest -or a phone opens a join page this repository serves (built on @moq/publish), and each participant's camera -and screen appear in the studio as sources. The host sees who joined and can -remove a participant from the scene. - -## Plan - -- **Rooms:** reuse moq-room's conventions (`{identity}/camera.hang`, - `{identity}/screen.hang`). Do not invent a guest protocol. -- **Tokens:** - - Minting a moq-auth token needs a signing key or an issuer API. For a - self-hosted relay, the studio accepts only a key whose moq-auth `Scope` - is rooted at its room prefix, and refuses an unscoped relay-wide key. A - stolen workstation key then cannot sign for other tenants. A hosted - issuer brings its own API. - - Each guest token is short-lived and minted with `moq_room::claims(room, - identity)`, so it publishes only under that guest's own `/`. - A guest cannot impersonate another participant. -- **Join link:** carry the token in the URL fragment, so it never reaches a - server log. -- **Relay required:** guests join through a relay. Peer-to-peer WHIP ingress - inside the studio was considered and dropped, because it brings NAT - traversal and TURN for no capability the relay lacks. diff --git a/quest/m0/join-page-live.md b/quest/m0/join-page-live.md new file mode 100644 index 0000000..a578ca1 --- /dev/null +++ b/quest/m0/join-page-live.md @@ -0,0 +1,18 @@ +# [XS] The join page is live + +## Goal + +`https://join.studio.moq.dev/` serves the join page (`js/join`), so +`--join-page` works without being passed. The maintainer: + +- enables GitHub Pages for moq-dev/studio with GitHub Actions as its source, + and limits the `github-pages` environment's deployment branches to `main` +- protects `main` so only reviewed PRs reach it, since whatever lands there + deploys to the page every guest's token passes through +- points `join.studio.moq.dev` at Pages in DNS, sets it as the custom domain, + and enforces HTTPS +- runs the Join page workflow (`.github/workflows/join.yml`) once + +Check: `curl -sI https://join.studio.moq.dev/` answers 200 over a valid +certificate, and the page loads. Then drop the README's note that the +default is not live yet, and delete this quest. diff --git a/rs/moq-scene/src/edit.rs b/rs/moq-scene/src/edit.rs index d6ebbf2..e7bd3c6 100644 --- a/rs/moq-scene/src/edit.rs +++ b/rs/moq-scene/src/edit.rs @@ -111,6 +111,14 @@ impl Change { } } + /// The name of the source, audio, or video entry the change makes. + fn entry(&self) -> Option<&str> { + match self { + Self::Source { name, .. } | Self::Audio { name, .. } | Self::Video { name, .. } => Some(name), + Self::Scene { .. } | Self::Item { .. } | Self::Transition { .. } => None, + } + } + /// Whether the change leaves its part as it was. fn is_noop(&self) -> bool { match self { @@ -666,6 +674,63 @@ impl History { Ok(()) } + /// Make `edit` to `collection` without recording it, for a change the + /// user did not make, such as a guest joining. It is not undone, and every + /// edit here still undoes and redoes around it. + /// + /// So it must commute with each of them: it may only add source, audio, + /// and video entries, under names no edit here changes, and nothing that + /// names another source, such as a clone or a mask, which an edit could + /// remove. Anything else is refused. + pub fn apply_outside(&self, collection: &mut Collection, edit: &Edit) -> Result<(), Error> { + for change in &edit.changes { + let name = match change { + Change::Source { + name, + before: None, + after: Some(source), + } if source.opens() => name, + Change::Audio { + name, + before: None, + after: Some(_), + } => name, + Change::Video { + name, + before: None, + after: Some(video), + } if video.filters.iter().all(|filter| filter.source().is_none()) => name, + _ => { + return Err(Error::Invalid(format!( + "`{}` changes more than adding a source's own entries", + edit.label + ))); + } + }; + if self.touches(name) { + return Err(conflict(format!("the history edits `{name}`"))); + } + } + collection.apply(edit) + } + + /// Whether any edit here, done or undone, changes the source, audio, or + /// video entry called `name`. + pub fn touches(&self, name: &str) -> bool { + let mut changes = self.entries().flat_map(|edit| &edit.changes); + changes.any(|change| change.entry() == Some(name)) + } + + /// `base`, or `base 2`, `base 3`, and so on: the first name no source of + /// `collection` has and no edit here changes, for + /// [`apply_outside`](Self::apply_outside) to add. + pub fn unused_name(&self, collection: &Collection, base: &str) -> String { + std::iter::once(base.to_string()) + .chain((2..).map(|n| format!("{base} {n}"))) + .find(|name| !collection.sources.contains_key(name) && !self.touches(name)) + .expect("an unused name") + } + /// Serialize to the history file format, in the scene file's version, /// since entries hold its sources, scenes, and items. pub fn to_toml(&self) -> Result { @@ -987,6 +1052,82 @@ mod tests { assert!(!edited.sources.contains_key("blue")); } + /// A change from outside, such as a guest joining, drops nothing to redo, + /// and undo and redo pass over it, here or after a reload. + #[test] + fn an_outside_edit_keeps_the_history_around_it() { + let mut collection = collection(); + let mut history = History::new(); + drag(&mut collection, &mut history, 1.0, None); + drag(&mut collection, &mut history, 2.0, None); + history.undo(&mut collection).unwrap(); + + let mut draft = collection.draft("Add guest"); + draft.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap(); + draft.set_audio("guest", Some(Audio::default())).unwrap(); + history + .apply_outside(&mut collection, &draft.finish().unwrap()) + .unwrap(); + assert_eq!((history.position(), history.entries().len()), (1, 2)); + + history.redo(&mut collection).unwrap(); + assert_eq!(collection.scenes[0].items[0].rect.x, 2.0); + history.seek(&mut collection, 0).unwrap(); + assert_eq!(collection.scenes[0].items[0].rect.x, 0.0); + assert!(collection.sources.contains_key("guest")); + + // The scene file now holds the guest, and the whole history still replays. + let (mut loaded, dropped) = History::from_toml(&history.to_toml().unwrap(), &collection).unwrap(); + assert_eq!(dropped, 0); + loaded.seek(&mut collection, 2).unwrap(); + assert_eq!(collection.scenes[0].items[0].rect.x, 2.0); + assert!(collection.sources.contains_key("guest")); + } + + /// An outside change that an edit's undo or redo would collide with, or + /// that moves what an edit names by index, is refused. + #[test] + fn refuses_an_outside_edit_the_history_could_not_pass_over() { + let mut collection = collection(); + let mut history = History::new(); + let mut draft = collection.draft("Add blue"); + draft.set_source("blue", Some(color([0, 0, 255, 255]))).unwrap(); + commit(&mut collection, &mut history, draft, None); + history.undo(&mut collection).unwrap(); + let before = collection.clone(); + + let mut taken = collection.draft("Add guest"); + taken.set_source("blue", Some(color([0, 0, 255, 255]))).unwrap(); + let mut clone = collection.draft("Add guest"); + clone + .set_source("clone", Some(Source::Clone { source: "red".into() })) + .unwrap(); + let mut placed = collection.draft("Add guest"); + placed.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap(); + let item = Item::new("guest", Rect::fill(&collection.canvas)); + placed.insert_item(&main(), 0, item).unwrap(); + let mut masked = collection.draft("Add guest"); + masked.set_source("guest", Some(color([0, 255, 0, 255]))).unwrap(); + let mask = Filter::Mask { + source: "red".into(), + mode: Default::default(), + }; + let video = Video { + filters: vec![mask], + ..Default::default() + }; + masked.set_video("guest", Some(video)).unwrap(); + let mut removed = collection.draft("Remove red"); + removed.remove_source("red").unwrap(); + for draft in [taken, clone, placed, masked, removed] { + let edit = draft.finish().unwrap(); + assert!(history.apply_outside(&mut collection, &edit).is_err(), "{edit:?}"); + } + assert_eq!(collection, before); + assert_eq!(history.unused_name(&collection, "blue"), "blue 2"); + assert_eq!(history.unused_name(&collection, "red"), "red 2"); + } + #[test] fn refuses_a_file_that_is_not_a_history() { let collection = collection(); diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 52bd1ef..ac9c56a 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -45,14 +45,20 @@ egui_dock = "0.21" hang = { workspace = true } kio = { workspace = true, features = ["async", "runtime"] } moq-audio = { workspace = true, features = ["bus"] } +# Signs guest tokens with the room key. +moq-auth = { workspace = true } moq-json = { workspace = true } moq-mux = { workspace = true } moq-net = { workspace = true } +# The guest room: its path convention, roster, and token claims. +moq-room = { workspace = true } moq-scene = { workspace = true } moq-stats = { workspace = true } moq-tokio = { workspace = true, default-features = false, features = ["aws-lc-rs", "net"] } moq-video = { workspace = true } pollster = { workspace = true } +# The join link's QR code. No dependencies of its own. +qrcodegen = "1.8" tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } @@ -60,7 +66,8 @@ url = { workspace = true } usage-rs = { workspace = true, features = ["completions", "config"] } [dev-dependencies] -moq-auth = { workspace = true } +# The auth server a test relay verifies guest tokens with. +moq-auth = { workspace = true, features = ["serve"] } moq-relay = { workspace = true, features = ["test-support"] } # A paused clock, so health tests sample without waiting. tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 32b0fa7..be655ba 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -103,6 +103,16 @@ impl State { Ok(()) } + /// Apply `draft`, a change the user did not make, such as a guest joining, + /// without recording it: it is not undone, and drops nothing that could be + /// redone. See [`History::apply_outside`] for what it may change. + pub fn apply_outside(&mut self, draft: Draft) -> anyhow::Result<()> { + let edit = draft.finish()?; + self.history.apply_outside(&mut self.collection, &edit)?; + self.edited(); + Ok(()) + } + /// Undo the newest edit, returning its label, or `None` with none left. pub fn undo(&mut self) -> anyhow::Result> { let label = self.history.undo(&mut self.collection)?.map(|edit| edit.label.clone()); @@ -231,6 +241,8 @@ pub struct Engine { pub bus: moq_audio::bus::Bus, /// `None` when running without a relay. pub output: Option, + /// The room guests join, when configured. + pub room: Option>, /// Keeps broadcast sources' origin open when no connection holds it. _origin: Option, } @@ -253,6 +265,8 @@ pub struct Launch { pub broadcast: String, /// Whether the program video encodes only while someone watches. pub on_demand: bool, + /// The room guests join, if any. + pub room: Option>, } impl Engine { @@ -261,7 +275,8 @@ impl Engine { /// starting at once. /// Publish the program at `launch.broadcast` on every one of /// `launch.relays`, preferred first, encoding its video only while someone - /// watches when `launch.on_demand`. + /// watches when `launch.on_demand`, and add the guests who join `launch.room` + /// as sources. /// /// Call inside the tokio runtime that serves the relay connections. pub async fn start(launch: Launch, autosave: Autosave, loaded: autosave::Loaded) -> anyhow::Result> { @@ -270,9 +285,20 @@ impl Engine { relays, broadcast, on_demand, + room, } = launch; let bus = moq_audio::bus::Bus::new(Default::default())?; let subscribe = moq_tokio::origin::spawn(); + // Every relay feeds `subscribe`, so a guest either one routes is seen. + let guests = match &room { + Some(room) => Some( + subscribe + .consume() + .scope(room.path(), &moq_auth::Pattern::all().into()) + .context("cannot follow the room")?, + ), + None => None, + }; let sources = subscribe.consume(); let autosave::Loaded { collection, @@ -317,9 +343,9 @@ impl Engine { health: Mutex::default(), bus, output, + room, _origin: origin, }); - let (video, audio, clock, publisher) = match publish { Some(Publish { video, @@ -367,6 +393,12 @@ impl Engine { save.autosave.run(|| save.serialize()).await })?; tokio::spawn(engine.clone().grade(health::Sampler::new(publisher))); + // Last, once nothing can fail: a start that fails is retried from setup, + // and a follower already running would keep this engine and its + // publication alive beside the retry's. + if let Some(guests) = guests { + tokio::spawn(crate::guests::run(engine.clone(), guests)); + } Ok(engine) } @@ -924,6 +956,7 @@ mod tests { }], broadcast: "studio".into(), on_demand: false, + room: None, }; let (autosave, _) = Autosave::open(dir.path().join("show.toml")).unwrap(); let loaded = autosave::Loaded { diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs new file mode 100644 index 0000000..c96809c --- /dev/null +++ b/rs/moq-studio/src/guests.rs @@ -0,0 +1,909 @@ +//! Guests: people who join the studio's room from a web page, whose camera and +//! screen become broadcast sources. +//! +//! The room is a moq-room prefix on the studio's relay. Inviting a guest signs +//! a short-lived token with [`moq_room::claims`], so the guest publishes only +//! under its own `/`, and hands it to the join page in a link's +//! fragment, which no server logs. The guest publishes moq-room's +//! `/camera.hang` and `/screen.hang`, and the studio adds +//! each as a muted [`Source::Broadcast`] when it is announced. Only the room key +//! mints those tokens, so guests can make the studio hold at most two sources +//! per name it signed for. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Context; +use moq_net::Path; +use moq_room::Kind; +use moq_scene::{Collection, Draft, Filter, History, Item, Layer, Source, Video}; +use url::Url; + +use crate::engine::Engine; + +/// The domain `.github/workflows/join.yml` deploys `js/join` to. +const JOIN_PAGE: &str = "https://join.studio.moq.dev/"; + +/// The guest flags. +#[derive(usage_rs::Args, Clone, Debug)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub struct Config { + /// The room guests join: a path under the `--connect` URL's, which the + /// studio's own connection must be allowed to subscribe to. Needs + /// `--room-key`. + #[usage(long)] + pub room: Option, + + /// The key that signs guest tokens, as `moq auth generate` writes it. Its + /// scope must stay inside the room, so a stolen key cannot sign for the rest + /// of the relay; an unscoped key is refused. + #[usage(long, value_hint = usage_rs::ValueHint::FilePath, extensions("jwk", "json"))] + pub room_key: Option, + + /// The join page a guest opens, `js/join` hosted anywhere. Its link carries + /// the room's relay URL, the guest's name, and its token in the fragment. + /// Defaults to `https://join.studio.moq.dev/`. + #[usage(long)] + pub join_page: Option, + + /// How long a join link works. The relay disconnects a guest when its link + /// expires, so send a new one before then for a longer show. + #[usage(long, default = "4h")] + pub guest_expiry: moq_tokio::cli::Duration, +} + +impl Config { + /// The room these flags describe on the relay at `connect`, or `None` without + /// `--room`. + pub fn load(&self, connect: Option<&Url>) -> anyhow::Result> { + let (room, key) = match (&self.room, &self.room_key, &self.join_page) { + (None, None, None) => return Ok(None), + (Some(room), Some(key), _) => (room, key), + _ => anyhow::bail!("guests need both --room and --room-key"), + }; + let page = self + .join_page + .clone() + .unwrap_or_else(|| Url::parse(JOIN_PAGE).expect("the default join page is a URL")); + let connect = connect.context("guests join through the relay; add --connect")?; + let key = moq_auth::Key::from_file(key).with_context(|| format!("reading the room key {}", key.display()))?; + let room = Room::new(connect, room, key, page, self.guest_expiry.into())?; + Ok(Some(room)) + } +} + +/// The studio's room: who was invited, and what each guest publishes. +pub struct Room { + /// The room, relative to the studio's connection. + path: Path, + /// The room on the relay: what guest tokens are rooted at. + root: Path, + /// Where a guest dials: the relay at the room, without the studio's credentials. + url: Url, + key: moq_auth::Key, + page: Url, + expiry: Duration, + participants: Mutex>, +} + +/// One guest the studio knows of. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Participant { + /// The newest link the host made for this guest this session, if any. + pub invite: Option, + /// Whether the guest's camera broadcast is announced. + pub camera: bool, + /// Whether the guest's screen broadcast is announced. + pub screen: bool, +} + +/// A join link for one guest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Invite { + /// The join page, with the relay, name, and token in its fragment. + pub link: Url, + /// When the token, and so the link, stops working. + pub expires: kio::clock::Wall, +} + +impl Room { + /// The room at `room` under the `connect` URL's path, whose guests `key` + /// signs for and who join at `page` within `expiry`. + /// + /// Refuses a key whose scope reaches outside the room, or that cannot sign a + /// guest's claims, and a relay, page, or expiry no guest could join with. + pub fn new(connect: &Url, room: &str, key: moq_auth::Key, page: Url, expiry: Duration) -> anyhow::Result { + anyhow::ensure!( + matches!(connect.scheme(), "https" | "http"), + "guests join from a browser, which cannot dial {}://; connect over https://", + connect.scheme() + ); + anyhow::ensure!( + connect.scheme() == "https" || local(connect), + "a guest's browser blocks http:// to {} from a secure page; \ + connect over https://, or over http:// only to this machine or the local network", + connect.host_str().unwrap_or_default() + ); + anyhow::ensure!( + page.scheme() == "https" || (page.scheme() == "http" && loopback(&page)), + "browsers open the camera only on a secure page, so the join page {page} must be https://, \ + or http:// on localhost" + ); + // The token carries whole seconds, so anything shorter expires as it is made. + anyhow::ensure!(expiry.as_secs() > 0, "--guest-expiry must be at least a second"); + let path = Path::new(room).with_context(|| format!("invalid room `{room}`"))?; + anyhow::ensure!(!path.is_empty(), "the room needs a path"); + let base = Path::new(connect.path().trim_matches('/')).context("invalid --connect path")?; + let root = base.join(&path).context("the room path is too long")?; + + // Only the relay and the room: never the studio's own `?jwt=`. + let mut url = connect.clone(); + url.set_query(None); + url.set_fragment(None); + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_path(root.as_str()); + // The relay compares a token's root with the dialed path as sent, so a room + // the URL would percent-encode could never match its guests' tokens. + anyhow::ensure!( + url.path().strip_prefix('/') == Some(root.as_str()), + "the room `{room}` changes when written in a URL; use only letters, digits, and `-_.~`" + ); + + check_scope(&key, &root)?; + let room = Self { + path, + root, + url, + key, + page, + expiry, + participants: Mutex::default(), + }; + // Sign once now, so a key that cannot sign for guests fails at startup. + room.sign("guest")?; + Ok(room) + } + + /// The room, relative to the studio's connection. + pub fn path(&self) -> &Path { + &self.path + } + + /// Every guest the studio knows of, by name. + pub fn participants(&self) -> BTreeMap { + self.participants.lock().unwrap().clone() + } + + /// Make a join link for the guest called `name`, replacing any earlier one. + /// An earlier link keeps working until it expires: a signed token cannot be + /// taken back. + pub fn invite(&self, name: &str) -> anyhow::Result { + let name = name.trim(); + anyhow::ensure!( + !name.contains(['/', '\\']), + "a guest's name is one path segment, without `/` or `\\`" + ); + // The room's announcements hide a path segment starting with `.`, so the + // studio would never see this guest join. + anyhow::ensure!(!name.starts_with('.'), "a guest's name cannot start with `.`"); + let (token, expires) = self.sign(name)?; + let mut link = self.page.clone(); + let fragment = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("url", self.url.as_str()) + .append_pair("name", name) + .append_pair("jwt", &token) + .finish(); + link.set_fragment(Some(&fragment)); + let invite = Invite { link, expires }; + self.participants + .lock() + .unwrap() + .entry(name.to_string()) + .or_default() + .invite = Some(invite.clone()); + Ok(invite) + } + + /// A token for `identity`, and when it expires. + fn sign(&self, identity: &str) -> anyhow::Result<(String, kio::clock::Wall)> { + // Whole seconds, as the token carries them. + let now = kio::clock::wall().saturating_duration_since(kio::clock::Wall::UNIX_EPOCH); + let issued = kio::clock::Wall::UNIX_EPOCH + Duration::from_secs(now.as_secs()); + let expires = issued + self.expiry; + let claims = moq_room::claims(self.root.as_str(), identity) + .with_context(|| format!("cannot invite `{identity}`"))? + .with_issued(issued) + .with_expires(expires); + let token = self + .key + .sign(&claims) + .context("the room key cannot sign a guest's token")?; + Ok((token, expires)) + } + + /// Record that the broadcast at `path` in the room went online or offline, + /// returning the source to add for it, if the collection lacks one. Its + /// name is one `history` never edited, so the source can be added outside it. + /// + /// Every guest is tracked, including one invited before this session whose + /// link still works, so a restart finds the guests already connected. + fn observe( + &self, + path: &Path, + online: bool, + collection: &Collection, + history: &History, + ) -> Option<(String, Source)> { + let parsed = moq_room::parse(path)?; + // Only the two paths a link's one-segment identity publishes, so the + // `/**` its token grants cannot mint more guests or sources. + let canonical = moq_room::broadcast_path(parsed.identity.clone(), parsed.kind).ok()?; + if parsed.identity.parts().count() != 1 || canonical != *path { + tracing::debug!(%path, "ignoring a broadcast that is not a guest's camera or screen"); + return None; + } + let source = self.path.join(path).ok()?.to_string(); + let existing = collection + .sources + .values() + .any(|existing| matches!(existing, Source::Broadcast { path } if *path == source)); + + let identity = parsed.identity.to_string(); + let mut participants = self.participants.lock().unwrap(); + let participant = participants.entry(identity.clone()).or_default(); + match parsed.kind { + Kind::Camera => participant.camera = online, + Kind::Screen => participant.screen = online, + } + if !online || existing { + return None; + } + + let base = match parsed.kind { + Kind::Camera => identity, + Kind::Screen => format!("{identity} screen"), + }; + Some(( + history.unused_name(collection, &base), + Source::Broadcast { path: source }, + )) + } + + /// Take the guest called `identity` off the program in `draft`: their + /// items, the items showing a clone of their sources, and the masks + /// reading them leave every layer, and their sources are muted. Returns + /// whether that changed anything. + pub fn take_off(&self, draft: &mut Draft, identity: &str) -> Result { + let before = draft.collection().clone(); + let sources = self.sources_of(&before, identity); + let ours = |name: &str| sources.iter().any(|source| source == name); + // A clone shows the same capture, and a mask puts their picture on air. + let shows = |item: &Item| ours(before.capture(&item.source)); + let reads = |filter: &Filter| filter.source().is_some_and(ours); + + let placed: Vec<(Layer, usize)> = before + .layers() + .flat_map(|(layer, items)| { + let at = items.iter().enumerate().filter(|(_, item)| shows(item)); + at.map(move |(index, _)| (layer.clone(), index)).collect::>() + }) + .collect(); + // Back to front, so each index still names the item found. + for (layer, index) in placed.into_iter().rev() { + draft.remove_item(&layer, index)?; + } + + let masked: Vec<(Layer, usize, Item)> = draft + .collection() + .layers() + .flat_map(|(layer, items)| { + let at = items + .iter() + .enumerate() + .filter(|(_, item)| item.filters.iter().any(reads)); + at.map(move |(index, item)| (layer.clone(), index, item.clone())) + .collect::>() + }) + .collect(); + for (layer, index, mut item) in masked { + item.filters.retain(|filter| !reads(filter)); + draft.set_item(&layer, index, item)?; + } + let masked: Vec<(String, Video)> = before + .video + .iter() + .filter(|(_, video)| video.filters.iter().any(reads)) + .map(|(name, video)| (name.clone(), video.clone())) + .collect(); + for (name, mut video) in masked { + video.filters.retain(|filter| !reads(filter)); + draft.set_video(&name, Some(video))?; + } + + for source in &sources { + let audio = before.audio.get(source).copied().unwrap_or_default(); + draft.set_audio(source, Some(moq_scene::Audio { muted: true, ..audio }))?; + } + Ok(*draft.collection() != before) + } + + /// The sources in `collection` that show the guest called `identity`. + pub fn sources_of(&self, collection: &Collection, identity: &str) -> Vec { + collection + .sources + .iter() + .filter(|(_, source)| { + let Source::Broadcast { path } = source else { + return false; + }; + let Ok(path) = Path::new(path) else { return false }; + path.strip_prefix(&self.path) + .and_then(moq_room::parse) + .is_some_and(|parsed| parsed.identity.as_str() == identity) + }) + .map(|(name, _)| name.clone()) + .collect() + } +} + +/// Whether `url` names this machine, so a browser treats an `http://` page +/// there as secure. +fn loopback(url: &Url) -> bool { + match url.host() { + Some(url::Host::Domain(host)) => host == "localhost" || host.ends_with(".localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +/// Whether a browser on an `https://` page may reach `url` over `http://`: +/// this machine, or, after a local network prompt, a private address or a +/// `.local` name. Anything else is mixed content it blocks. +fn local(url: &Url) -> bool { + loopback(url) + || match url.host() { + Some(url::Host::Domain(host)) => host.ends_with(".local"), + Some(url::Host::Ipv4(ip)) => ip.is_private() || ip.is_link_local(), + Some(url::Host::Ipv6(ip)) => ip.is_unique_local() || ip.is_unicast_link_local(), + None => false, + } +} + +/// Refuse a key that could sign beyond `room`: one with no scope, or a scope +/// granting any path outside it. +fn check_scope(key: &moq_auth::Key, room: &Path) -> anyhow::Result<()> { + let Some(scope) = &key.scope else { + anyhow::bail!( + "the room key is unscoped, so it could sign for anywhere on the relay; \ + generate one with `moq auth generate --root {room} --publish '**' --subscribe '**'`" + ); + }; + let inside: moq_auth::Patterns = moq_auth::Pattern::subtree(room.as_str())?.into(); + let confined = + |patterns: &moq_auth::Patterns| patterns.rooted(&scope.root).is_ok_and(|rooted| inside.covers(&rooted)); + anyhow::ensure!( + confined(&scope.publish) && confined(&scope.subscribe), + "the room key's scope reaches outside the room `{room}`; generate one with \ + `moq auth generate --root {room} --publish '**' --subscribe '**'`" + ); + Ok(()) +} + +/// Follow the room's announcements for as long as the studio runs, adding each +/// guest's broadcasts as sources. +pub async fn run(engine: Arc, origin: moq_net::origin::Consumer) { + let room = engine.room.as_ref().expect("run only with a room"); + let mut announced = moq_room::Room::new(&origin, None); + while let Some(event) = announced.next().await { + let mut state = engine.state.lock().unwrap(); + let online = event.broadcast.is_some(); + let Some((name, source)) = room.observe(&event.path, online, state.collection(), state.history()) else { + continue; + }; + // Not the host's edit, so it stays out of what they undo and redo. + let mut draft = state.collection().draft(format!("Add guest `{name}`")); + let added = admit(&mut draft, &name, source) + .map_err(anyhow::Error::from) + .and_then(|()| state.apply_outside(draft)); + match added { + Ok(()) => tracing::info!(source = %name, identity = %event.identity, "a guest joined"), + Err(err) => tracing::error!(source = %name, error = format!("{err:#}"), "cannot add a guest"), + } + } + tracing::warn!("the room's announcements ended"); +} + +/// Add a guest's broadcast as the source `name`, muted: it is in no scene, so +/// the guest reaches the program only once the host places or unmutes them. +fn admit(draft: &mut Draft, name: &str, source: Source) -> Result<(), moq_scene::Error> { + let muted = moq_scene::Audio { + muted: true, + ..Default::default() + }; + draft.set_source(name, Some(source))?; + draft.set_audio(name, Some(muted)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXPIRY: Duration = Duration::from_secs(3600); + + fn key(root: &str, publish: &[&str], subscribe: &[&str]) -> moq_auth::Key { + let patterns = |texts: &[&str]| texts.iter().map(|text| text.parse().unwrap()).collect(); + moq_auth::Key::generate(moq_auth::Algorithm::ES256, Some(moq_auth::KeyId::random())) + .unwrap() + .with_scope(moq_auth::Scope { + root: root.into(), + publish: patterns(publish), + subscribe: patterns(subscribe), + }) + .unwrap() + } + + fn room(connect: &str, key: moq_auth::Key) -> anyhow::Result { + let page = Url::parse("https://studio.example/join/").unwrap(); + Room::new(&Url::parse(connect).unwrap(), "guests", key, page, EXPIRY) + } + + fn fragment(link: &Url) -> BTreeMap { + url::form_urlencoded::parse(link.fragment().unwrap().as_bytes()) + .into_owned() + .collect() + } + + #[test] + fn the_guest_flags_come_together_and_need_a_relay() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("room.jwk"); + key("show/guests", &["**"], &["**"]).to_file(&path).unwrap(); + let connect = Url::parse("https://relay.example/show").unwrap(); + let config = |room: bool, key: bool, page: bool| Config { + room: room.then(|| "guests".into()), + room_key: key.then(|| path.clone()), + join_page: page.then(|| Url::parse("https://studio.example/join/").unwrap()), + guest_expiry: EXPIRY.into(), + }; + + assert!(config(false, false, false).load(Some(&connect)).unwrap().is_none()); + for partial in [ + config(true, false, true), + config(false, true, true), + config(false, false, true), + ] { + let err = partial.load(Some(&connect)).err().unwrap(); + assert!(err.to_string().contains("need both"), "{err}"); + } + let err = config(true, true, true).load(None).err().unwrap(); + assert!(err.to_string().contains("--connect"), "{err}"); + let room = config(true, true, true).load(Some(&connect)).unwrap().unwrap(); + assert_eq!(room.path().as_str(), "guests"); + let link = room.invite("alice").unwrap().link; + assert!(link.as_str().starts_with("https://studio.example/join/#"), "{link}"); + + let room = config(true, true, false).load(Some(&connect)).unwrap().unwrap(); + let link = room.invite("alice").unwrap().link; + assert!(link.as_str().starts_with(JOIN_PAGE), "{link}"); + } + + #[test] + fn refuses_a_key_that_reaches_outside_the_room() { + let unscoped = moq_auth::Key::generate(moq_auth::Algorithm::ES256, None).unwrap(); + let err = room("https://relay.example/show", unscoped).err().unwrap(); + assert!(err.to_string().contains("unscoped"), "{err}"); + + // The whole relay, the show around the room, and a sibling room. + for (root, pattern) in [("", "**"), ("show", "**"), ("show/other", "**"), ("", "show/**")] { + let err = room("https://relay.example/show", key(root, &[pattern], &[pattern])) + .err() + .unwrap(); + assert!(err.to_string().contains("outside the room"), "{root} {pattern}: {err}"); + } + } + + #[test] + fn accepts_a_key_scoped_to_the_room_however_it_is_spelled() { + room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + room( + "https://relay.example/show", + key("", &["show/guests/**"], &["show/guests/**"]), + ) + .unwrap(); + room("https://relay.example/", key("guests", &["**"], &["**"])).unwrap(); + } + + #[test] + fn refuses_a_key_too_narrow_to_sign_for_guests() { + let err = room("https://relay.example/show", key("show/guests/alice", &["**"], &["**"])) + .err() + .unwrap(); + assert!(err.to_string().contains("cannot sign"), "{err:#}"); + } + + #[test] + fn refuses_a_room_a_url_would_spell_differently() { + for room in ["guest room", "gäste"] { + let key = key(&format!("show/{room}"), &["**"], &["**"]); + let page = Url::parse("https://studio.example/join/").unwrap(); + let connect = Url::parse("https://relay.example/show").unwrap(); + let err = Room::new(&connect, room, key, page, EXPIRY).err().unwrap(); + assert!( + err.to_string().contains("changes when written in a URL"), + "{room}: {err}" + ); + } + } + + #[test] + fn refuses_a_relay_a_browser_cannot_dial() { + let key = || key("show/guests", &["**"], &["**"]); + for connect in [ + "moqt://relay.example/show", + "http://relay.example/show", + "http://8.8.8.8/show", + ] { + let err = room(connect, key()).err().unwrap(); + assert!(err.to_string().contains("browser"), "{connect}: {err}"); + } + for connect in [ + "http://localhost:4443/show", + "http://127.0.0.1:4443/show", + "http://[::1]:4443/show", + "http://192.168.1.20:4443/show", + "http://[fd00::20]:4443/show", + "http://studio.local:4443/show", + ] { + room(connect, key()).unwrap_or_else(|err| panic!("{connect}: {err}")); + } + } + + #[test] + fn refuses_a_join_page_that_cannot_open_the_camera() { + let connect = Url::parse("https://relay.example/show").unwrap(); + let key = || key("show/guests", &["**"], &["**"]); + for page in [ + "http://join.example/", + "http://192.168.1.20/", + "file:///join/index.html", + ] { + let err = Room::new(&connect, "guests", key(), Url::parse(page).unwrap(), EXPIRY) + .err() + .unwrap(); + assert!(err.to_string().contains("secure page"), "{page}: {err}"); + } + for page in ["http://localhost:5173/", "http://127.0.0.1:5173/"] { + Room::new(&connect, "guests", key(), Url::parse(page).unwrap(), EXPIRY) + .unwrap_or_else(|err| panic!("{page}: {err}")); + } + } + + #[test] + fn refuses_a_link_that_expires_as_it_is_made() { + let connect = Url::parse("https://relay.example/show").unwrap(); + let page = Url::parse("https://studio.example/join/").unwrap(); + for expiry in [Duration::ZERO, Duration::from_millis(500)] { + let key = key("show/guests", &["**"], &["**"]); + let err = Room::new(&connect, "guests", key, page.clone(), expiry).err().unwrap(); + assert!(err.to_string().contains("--guest-expiry"), "{expiry:?}: {err}"); + } + } + + #[test] + fn a_link_carries_the_room_and_a_token_for_one_guest_in_its_fragment() { + let key = key("show/guests", &["**"], &["**"]); + let room = room( + "https://user:pass@relay.example:4443/show?jwt=studio-secret", + key.clone(), + ) + .unwrap(); + let invite = room.invite(" alice ").unwrap(); + + assert_eq!(invite.link.path(), "/join/"); + assert_eq!( + invite.link.query(), + None, + "the token must stay out of what a server sees" + ); + let fragment = fragment(&invite.link); + assert_eq!(fragment["url"], "https://relay.example:4443/show/guests"); + assert_eq!(fragment["name"], "alice"); + + let claims = key.verify(&fragment["jwt"]).unwrap(); + assert_eq!(claims.root, "show/guests"); + assert_eq!(claims.publish, ["alice/**".parse().unwrap()].into_iter().collect()); + assert_eq!(claims.subscribe, [moq_auth::Pattern::all()].into_iter().collect()); + let (issued, expires) = (claims.issued.unwrap(), claims.expires.unwrap()); + assert_eq!(expires.saturating_duration_since(issued), EXPIRY); + assert_eq!(invite.expires, expires); + assert!(room.participants()["alice"].invite.is_some()); + } + + #[test] + fn refuses_a_name_that_is_not_one_literal_segment() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + for name in ["", " ", "a/b", r"a\/b", "*", "al*ce", ".alice"] { + assert!(room.invite(name).is_err(), "`{name}` was accepted"); + } + assert!(room.participants().is_empty()); + } + + #[test] + fn a_guest_becomes_a_muted_source_once() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + let mut collection = Collection::new(); + let history = History::new(); + let path = |text: &str| Path::new(text).unwrap(); + + // Anything else under a guest's own path is not a guest. + for other in ["alice/1/camera.hang", "alice/camera", "alice/mic.hang"] { + assert_eq!(room.observe(&path(other), true, &collection, &history), None, "{other}"); + } + assert!(room.participants().is_empty()); + + // Invited before this session, so the roster has no link for them. + let (name, source) = room + .observe(&path("alice/camera.hang"), true, &collection, &history) + .unwrap(); + assert_eq!(name, "alice"); + assert_eq!( + source, + Source::Broadcast { + path: "guests/alice/camera.hang".into() + } + ); + let mut draft = collection.draft("Add guest"); + admit(&mut draft, &name, source).unwrap(); + history + .apply_outside(&mut collection, &draft.finish().unwrap()) + .unwrap(); + assert!(collection.audio["alice"].muted); + assert_eq!( + room.participants()["alice"], + Participant { + invite: None, + camera: true, + screen: false, + } + ); + + // The same broadcast again, after a restart or a reconnect, is the same source. + assert_eq!( + room.observe(&path("alice/camera.hang"), true, &collection, &history), + None + ); + + // A source already named after the guest pushes the screen's name along. + collection.sources.insert( + "alice screen".into(), + Source::Color { + color: moq_scene::Rgba([0, 0, 0, 255]), + }, + ); + let (name, _) = room + .observe(&path("alice/screen.hang"), true, &collection, &history) + .unwrap(); + assert_eq!(name, "alice screen 2"); + + assert_eq!( + room.observe(&path("alice/camera.hang"), false, &collection, &history), + None + ); + assert!(!room.participants()["alice"].camera); + assert!(room.participants()["alice"].screen); + } + + /// A guest who opens the link reaches the relay with its token, and the + /// studio's own connection sees its camera in the room. + #[tokio::test(flavor = "multi_thread")] + async fn a_guest_joins_through_a_relay_that_verifies_tokens() { + const TIMEOUT: Duration = Duration::from_secs(10); + let _ = moq_tokio::crypto::install_default(); + + // The operator's key for the studio's own connection, and the room's. + let dir = tempfile::tempdir().unwrap(); + let admin = moq_auth::Key::generate(moq_auth::Algorithm::ES256, Some(moq_auth::KeyId::random())).unwrap(); + let scoped = key("show/guests", &["**"], &["**"]); + for key in [&admin, &scoped] { + let kid = key.kid.as_ref().unwrap().encode(); + key.to_public() + .unwrap() + .to_file(dir.path().join(format!("{kid}.jwk"))) + .unwrap(); + } + let mut policy = moq_auth::serve::Policy::default(); + policy.keys = Some(moq_auth::serve::Keys::Dir(dir.path().into())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let auth = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); + let server = moq_auth::serve::Server::new(policy).unwrap(); + tokio::spawn(async move { server.serve(listener).await }); + + let mut config = moq_relay::Config::default(); + config.drain_timeout = Some(Duration::ZERO.into()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.generate = vec!["localhost".into()]; + config.auth.url = Some(auth); + let relay = moq_relay::Relay::load(config).await.unwrap().with_signals(false); + let addr = relay.quic_addr().unwrap(); + let ready = relay.ready(); + tokio::spawn(relay.run()); + ready.wait().await.unwrap(); + + let mut client = moq_tokio::connect::Config::default(); + // The relay's certificate is generated for this test alone. + client.tls.insecure = Some(true); + let client = client.init(Default::default()).unwrap(); + + let studio = moq_auth::Claims::default() + .with_root("show") + .with_subscribe([moq_auth::Pattern::all()]); + let mut connect = Url::parse(&format!("https://localhost:{}/show", addr.port())).unwrap(); + connect + .query_pairs_mut() + .append_pair("jwt", &admin.sign(&studio).unwrap()); + let subscribe = moq_tokio::origin::spawn(); + let all = moq_auth::Pattern::all().into(); + let mut announced = moq_room::Room::new(&subscribe.consume().scope("guests", &all).unwrap(), None); + let _studio = client.clone().with_subscriber(subscribe).connect(connect.clone()); + + let page = Url::parse("https://studio.example/join/").unwrap(); + let room = Room::new(&connect, "guests", scoped, page, EXPIRY).unwrap(); + let fragment = fragment(&room.invite("alice").unwrap().link); + let mut dial = Url::parse(&fragment["url"]).unwrap(); + dial.query_pairs_mut().append_pair("jwt", &fragment["jwt"]); + let guest = moq_tokio::origin::spawn(); + let camera = guest.create_broadcast("alice/camera.hang").unwrap(); + camera.announce(moq_net::origin::Route::default()).unwrap(); + let _guest = client.with_publisher(&guest).connect(dial); + + let event = kio::clock::timeout(TIMEOUT, announced.next()) + .await + .expect("the guest's camera reaches the studio") + .expect("an announcement"); + assert_eq!(event.path.as_str(), "alice/camera.hang"); + assert!(event.broadcast.is_some()); + let (name, source) = room + .observe(&event.path, true, &Collection::new(), &History::new()) + .unwrap(); + assert_eq!(name, "alice"); + assert_eq!( + source, + Source::Broadcast { + path: "guests/alice/camera.hang".into() + } + ); + } + + /// A guest joining is not the host's edit: what the host undid can still + /// be redone, and undo never takes the guest out. A guest whose source the + /// host removed comes back under a name of its own, so undoing the removal + /// still applies. + #[test] + fn a_guest_joining_keeps_what_the_host_can_undo_and_redo() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + let path = |text: &str| Path::new(text).unwrap(); + let mut collection = Collection::new(); + let mut history = History::new(); + let join = |collection: &mut Collection, history: &History, guest: &str| { + let (name, source) = room + .observe(&path(&format!("{guest}/camera.hang")), true, collection, history) + .unwrap(); + let mut draft = collection.draft("Add guest"); + admit(&mut draft, &name, source).unwrap(); + history.apply_outside(collection, &draft.finish().unwrap()).unwrap(); + name + }; + let commit = |collection: &mut Collection, history: &mut History, draft: Draft| { + let edit = draft.finish().unwrap(); + collection.apply(&edit).unwrap(); + history.record(edit, None); + }; + + assert_eq!(join(&mut collection, &history, "bob"), "bob"); + let mut draft = collection.draft("Remove bob"); + draft.remove_source("bob").unwrap(); + commit(&mut collection, &mut history, draft); + let mut draft = collection.draft("Add a color"); + let black = Source::Color { + color: moq_scene::Rgba([0, 0, 0, 255]), + }; + draft.set_source("black", Some(black)).unwrap(); + commit(&mut collection, &mut history, draft); + history.undo(&mut collection).unwrap(); + + assert_eq!(join(&mut collection, &history, "alice"), "alice"); + assert_eq!(join(&mut collection, &history, "bob"), "bob 2"); + assert_eq!(history.entries().len(), 2); + history.redo(&mut collection).unwrap(); + assert!(collection.sources.contains_key("black")); + + history.seek(&mut collection, 0).unwrap(); + for name in ["alice", "bob", "bob 2"] { + assert!(collection.sources.contains_key(name), "{name}"); + } + } + + /// Remove from scenes takes the guest off the program however they reach + /// it, leaves everything else, and is one edit that undoes whole. + #[test] + fn taking_a_guest_off_leaves_nothing_of_them_on_air() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + let mut collection = Collection::new(); + let fill = moq_scene::Rect::fill(&collection.canvas); + let mask = Filter::Mask { + source: "alice".into(), + mode: Default::default(), + }; + let gray = Filter::Grayscale { amount: 1.0 }; + collection.sources.insert( + "alice".into(), + Source::Broadcast { + path: "guests/alice/camera.hang".into(), + }, + ); + collection + .sources + .insert("alice clone".into(), Source::Clone { source: "alice".into() }); + collection.sources.insert( + "bg".into(), + Source::Color { + color: moq_scene::Rgba([0, 0, 0, 255]), + }, + ); + let mut bg = Item::new("bg", fill); + bg.filters = vec![mask.clone(), gray.clone()]; + collection.scenes[0].items = vec![bg, Item::new("alice", fill), Item::new("alice clone", fill)]; + collection.top.push(Item::new("alice", fill)); + let video = Video { + filters: vec![mask], + ..Default::default() + }; + collection.video.insert("bg".into(), video); + collection.validate().unwrap(); + let start = collection.clone(); + + let mut draft = collection.draft("Remove from scenes"); + assert!(room.take_off(&mut draft, "alice").unwrap()); + let edit = draft.finish().unwrap(); + collection.apply(&edit).unwrap(); + let bg = &collection.scenes[0].items; + assert_eq!(bg.len(), 1); + assert_eq!( + (bg[0].source.as_str(), bg[0].filters.as_slice()), + ("bg", [gray].as_slice()) + ); + assert!(collection.top.is_empty()); + assert!(!collection.video.contains_key("bg")); + assert!(collection.audio["alice"].muted); + assert!(collection.sources.contains_key("alice clone"), "a clone stays a source"); + + let mut again = collection.draft("Remove from scenes"); + assert!( + !room.take_off(&mut again, "alice").unwrap(), + "nothing is left to take off" + ); + collection.apply(&edit.inverse()).unwrap(); + assert_eq!(collection, start); + } + + #[test] + fn a_guest_already_in_the_collection_is_tracked_without_being_added() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + let mut collection = Collection::new(); + collection.sources.insert( + "bob".into(), + Source::Broadcast { + path: "guests/bob/camera.hang".into(), + }, + ); + let path = Path::new("bob/camera.hang").unwrap(); + assert_eq!(room.observe(&path, true, &collection, &History::new()), None); + assert!(room.participants()["bob"].camera); + assert_eq!(room.sources_of(&collection, "bob"), ["bob"]); + assert!(room.sources_of(&collection, "alice").is_empty()); + } +} diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 3e8d1d7..4c649f2 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -12,6 +12,7 @@ use anyhow::Context; mod autosave; mod devices; mod engine; +mod guests; mod health; mod setup; mod snap; @@ -38,6 +39,10 @@ pub struct Config { #[usage(long)] pub on_demand: bool, + /// Guests who join from a web page, appearing as sources. + #[usage(flatten)] + pub guests: guests::Config, + /// The MoQ client configuration. Without `--connect`, nothing is published /// and broadcast sources stay offline. #[usage(flatten)] @@ -125,11 +130,13 @@ fn main() -> anyhow::Result<()> { let _runtime = runtime.enter(); let relays = config.relays()?; let gpu = pollster::block_on(engine::Gpu::new())?; + let room = config.guests.load(config.client.url.as_ref())?; let launch = engine::Launch { gpu: gpu.clone(), relays, broadcast: config.broadcast.clone(), on_demand: config.on_demand, + room: room.map(Arc::new), }; let exists = config .scenes @@ -243,6 +250,8 @@ fn run( #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; #[test] @@ -257,6 +266,11 @@ mod tests { assert_eq!(config.scenes, PathBuf::from("show.toml")); assert_eq!(config.broadcast, "studio"); assert!(!config.on_demand, "the program encodes whatever the demand by default"); + assert!(config.guests.room.is_none(), "guests are opt-in"); + assert_eq!( + Duration::from(config.guests.guest_expiry), + Duration::from_secs(4 * 3600) + ); assert_eq!(config.client.url.unwrap().port(), Some(4443)); } diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index cd77ae6..df8632e 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -27,6 +27,7 @@ enum Tab { Items, Sources, Devices, + Guests, Filters, Audio, Output, @@ -43,6 +44,7 @@ impl Tab { Self::Items => "Items", Self::Sources => "Sources", Self::Devices => "Devices", + Self::Guests => "Guests", Self::Filters => "Filters", Self::Audio => "Audio", Self::Output => "Output", @@ -80,7 +82,7 @@ impl Studio { let [top, bottom] = tree.split_below(NodeIndex::root(), 0.62, vec![Tab::Scenes, Tab::History]); tree.split_right(top, 0.5, vec![Tab::Program]); let [_, items] = tree.split_right(bottom, 0.18, vec![Tab::Items]); - let [_, sources] = tree.split_right(items, 0.4, vec![Tab::Sources, Tab::Devices]); + let [_, sources] = tree.split_right(items, 0.4, vec![Tab::Sources, Tab::Devices, Tab::Guests]); tree.split_right(sources, 0.5, vec![Tab::Filters, Tab::Health, Tab::Audio, Tab::Output]); Self { @@ -99,6 +101,8 @@ impl Studio { new_item: None, add: AddSource::default(), devices: Devices::default(), + guest: String::new(), + shown: None, message: (dropped > 0).then(|| Err(dropped_message(dropped))), }, } @@ -139,6 +143,10 @@ struct Panels { new_item: Option, add: AddSource, devices: Devices, + /// The name typed for the next guest. + guest: String, + /// The join link on screen. + shown: Option, /// The outcome of the last action, shown in the status bar. message: Option>, } @@ -167,6 +175,7 @@ impl TabViewer for Panels { Tab::Items => self.items(ui), Tab::Sources => self.sources(ui), Tab::Devices => self.devices(ui), + Tab::Guests => self.guests(ui), Tab::Filters => self.filters(ui), Tab::Audio => self.audio(ui), Tab::Output => self.output(ui), @@ -1073,6 +1082,143 @@ impl Panels { self.report(result.map(|name| format!("added `{name}`"))); } + /// Invite guests, show their links, and see who joined. + fn guests(&mut self, ui: &mut Ui) { + let engine = self.engine.clone(); + let Some(room) = &engine.room else { + ui.label("Start with --room and --room-key to invite guests."); + return; + }; + + ui.horizontal(|ui| { + ui.label("Name"); + let field = ui.text_edit_singleline(&mut self.guest); + let entered = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + if ui.button("Invite").clicked() || entered { + let name = self.guest.trim().to_string(); + match room.invite(&name) { + Ok(invite) => { + self.show(ui.ctx(), name, invite); + self.guest.clear(); + } + Err(err) => self.report(Err(err)), + } + } + }); + + let now = kio::clock::wall(); + if let Some(shown) = &self.shown { + ui.separator(); + ui.horizontal(|ui| { + ui.strong(format!("Join link for {}", shown.name)); + ui.label(expiry(shown.invite.expires, now)); + }); + let mut hide = false; + ui.horizontal(|ui| { + if ui.button("Copy link").clicked() { + ui.ctx().copy_text(shown.invite.link.to_string()); + } + hide = ui.button("Hide").clicked(); + }); + // The link is the guest's credential, so it shows only as a code to scan. + match &shown.qr { + Ok(qr) => { + let side = ui.available_width().min(240.0); + ui.image((qr.id(), egui::vec2(side, side))); + } + Err(err) => { + ui.colored_label(ui.visuals().error_fg_color, format!("{err}; copy the link instead.")); + } + } + if hide { + self.shown = None; + } + } + + ui.separator(); + let participants = room.participants(); + if participants.is_empty() { + ui.weak("Nobody invited yet."); + return; + } + let mut show = None; + let mut invite = None; + let mut remove = None; + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("guests").striped(true).show(ui, |ui| { + for heading in ["Guest", "Camera", "Screen", "Link", ""] { + ui.strong(heading); + } + ui.end_row(); + for (name, participant) in &participants { + ui.label(name); + for online in [participant.camera, participant.screen] { + if online { + ui.colored_label(HEALTHY, "live"); + } else { + ui.weak("offline"); + } + } + match &participant.invite { + Some(link) => ui.label(expiry(link.expires, now)), + None => ui.weak("invited before this session"), + }; + ui.horizontal(|ui| { + if let Some(link) = &participant.invite + && ui.small_button("Show link").clicked() + { + show = Some((name.clone(), link.clone())); + } + if ui.small_button("New link").clicked() { + invite = Some(name.clone()); + } + if ui + .small_button("Remove from scenes") + .on_hover_text( + "Take their camera and screen, and any mask reading them, out of every scene and mute them. They stay connected until their link expires.", + ) + .clicked() + { + remove = Some(name.clone()); + } + }); + ui.end_row(); + } + }); + }); + + if let Some((name, link)) = show { + self.show(ui.ctx(), name, link); + } + if let Some(name) = invite { + match room.invite(&name) { + Ok(link) => self.show(ui.ctx(), name, link), + Err(err) => self.report(Err(err)), + } + } + if let Some(name) = remove { + let mut state = engine.state.lock().unwrap(); + let mut draft = state.collection().draft(format!("Remove `{name}` from scenes")); + let result = match room.take_off(&mut draft, &name) { + Ok(false) => Ok(()), + Ok(true) => { + // Removing items moves the ones after them. + self.deselect_item(); + state.commit(draft, None) + } + Err(err) => Err(err.into()), + }; + drop(state); + self.report(result.map(|()| format!("removed {name} from every scene and muted them"))); + } + } + + /// Put `invite`'s link for `name` on screen as a QR code. + fn show(&mut self, ctx: &egui::Context, name: String, invite: crate::guests::Invite) { + let qr = qr(ctx, invite.link.as_str()); + self.shown = Some(Shown { name, invite, qr }); + } + /// The crop, filters in order, and blend mode of the selected source or item. fn filters(&mut self, ui: &mut Ui) { let engine = self.engine.clone(); @@ -1768,6 +1914,45 @@ fn add( Ok(name) } +/// A join link on screen. +struct Shown { + name: String, + invite: crate::guests::Invite, + /// Or why the link cannot be one, so it can still be copied. + qr: Result, +} + +/// `text` as a QR code, one texel per module, inside the quiet zone scanners need. +fn qr(ctx: &egui::Context, text: &str) -> Result { + const QUIET: i32 = 4; + let code = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::Low) + .map_err(|err| format!("The join link is too long for a QR code ({err:?})"))?; + let side = code.size() + 2 * QUIET; + let pixels = (0..side) + .flat_map(|y| (0..side).map(move |x| (x, y))) + // Outside the code, which is the quiet zone, reads as light. + .map(|(x, y)| match code.get_module(x - QUIET, y - QUIET) { + true => Color32::BLACK, + false => Color32::WHITE, + }) + .collect(); + let image = egui::ColorImage::new([side as usize; 2], pixels); + Ok(ctx.load_texture("join link", image, egui::TextureOptions::NEAREST)) +} + +/// When a join link stops working, from `now`. +fn expiry(expires: kio::clock::Wall, now: kio::clock::Wall) -> String { + match expires.checked_duration_since(now) { + Some(left) if !left.is_zero() => { + // Whole minutes, rounded up, so a link never reads as expired early. + let minutes = left.as_secs().div_ceil(60).max(1); + let left = Duration::from_secs(minutes * 60); + format!("expires in {}", moq_tokio::cli::Duration::from(left)) + } + _ => "expired".into(), + } +} + /// Draw `texture` as large as fits, keeping the canvas's shape. fn picture(ui: &mut Ui, texture: egui::TextureId, canvas: Canvas, sense: egui::Sense) -> egui::Response { let available = ui.available_size();