From ee4e81bcbcd9cd4186a231556c222a5e5b0e3d77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 14:42:16 -0800 Subject: [PATCH 01/12] chore(quest): claim quest/m0/guests.md Quest-Claim: 973a414a79d9c105 From 35ef0564de466697ff57ff031c5de7f51dfd59fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 14:54:37 -0800 Subject: [PATCH 02/12] feat(studio): invite guests into a moq-room and add them as sources Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 21 ++ Cargo.toml | 2 + rs/moq-studio/Cargo.toml | 8 + rs/moq-studio/src/engine.rs | 21 +- rs/moq-studio/src/guests.rs | 541 ++++++++++++++++++++++++++++++++++++ rs/moq-studio/src/main.rs | 14 + rs/moq-studio/src/ui.rs | 176 +++++++++++- 7 files changed, 780 insertions(+), 3 deletions(-) create mode 100644 rs/moq-studio/src/guests.rs diff --git a/Cargo.lock b/Cargo.lock index c3f6f1d3..eb6d8025 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" @@ -3460,13 +3472,16 @@ dependencies = [ "egui_dock", "kio", "moq-audio", + "moq-auth", "moq-mux", "moq-net", "moq-relay", + "moq-room", "moq-scene", "moq-tokio", "moq-video", "pollster", + "qrcodegen", "tempfile", "tokio", "tracing", @@ -4920,6 +4935,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 feecb17b..262594b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,12 @@ 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-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-tokio = { path = "moq/rs/moq-tokio", default-features = false } # Default features off, as in moq: each consumer opts into native codecs and diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 8a0312d7..5825276e 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -44,18 +44,26 @@ egui = "0.36" egui_dock = "0.21" kio = { workspace = true, features = ["runtime"] } moq-audio = { workspace = true, features = ["bus"] } +# Signs guest tokens with the room key. +moq-auth = { 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-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" tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } url = { workspace = true } usage-rs = { workspace = true, features = ["completions", "config"] } [dev-dependencies] +# The auth server a test relay verifies guest tokens with. +moq-auth = { workspace = true, features = ["serve"] } moq-relay = { workspace = true, features = ["test-support"] } tempfile = { workspace = true } diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 9ccecccb..db3ce346 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -181,6 +181,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, } @@ -195,7 +197,8 @@ const AUDIO_PERIOD: Duration = Duration::from_millis(10); impl Engine { /// Open the collection at `path` on `gpu` and start the program clock. /// With `connect`, publish the program at `broadcast` on that relay, - /// encoding its video only while someone watches when `on_demand`. + /// encoding its video only while someone watches when `on_demand`, and add + /// the guests who join `room` as sources. /// /// Call inside the tokio runtime that serves the relay connection. pub async fn start( @@ -205,9 +208,19 @@ impl Engine { connect: Option<(moq_tokio::Client, url::Url)>, broadcast: &str, on_demand: bool, + room: Option, ) -> anyhow::Result> { let bus = moq_audio::bus::Bus::new(Default::default())?; let subscribe = moq_tokio::origin::spawn(); + 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 dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); let sources = Sources::new(source::Context { bus: bus.clone(), @@ -240,8 +253,12 @@ impl Engine { stats: Stats::default(), bus, output, + room, _origin: origin, }); + if let Some(guests) = guests { + tokio::spawn(crate::guests::run(engine.clone(), guests)); + } let (video, audio, clock) = match publish { Some(Publish { @@ -640,7 +657,7 @@ mod tests { let path = dir.path().join("show.toml"); let connect = Some((client, fixture.url.clone())); let on_demand = false; - let engine = Engine::start(gpu, path, collection, connect, "studio", on_demand) + let engine = Engine::start(gpu, path, collection, connect, "studio", on_demand, None) .await .expect("the studio starts"); diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs new file mode 100644 index 00000000..5a0f9477 --- /dev/null +++ b/rs/moq-studio/src/guests.rs @@ -0,0 +1,541 @@ +//! 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 [`Source::Broadcast`] when it is announced. + +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, Source}; +use url::Url; + +use crate::engine::Engine; + +/// 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` and `--join-page`. + #[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. Its link carries the room's relay URL, the + /// guest's name, and its token in the fragment. + #[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, page) = match (&self.room, &self.room_key, &self.join_page) { + (None, None, None) => return Ok(None), + (Some(room), Some(key), Some(page)) => (room, key, page), + _ => anyhow::bail!("guests need all of --room, --room-key, and --join-page"), + }; + 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.clone(), 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. + 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() + ); + 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()); + + 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 `\\`" + ); + 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. + /// + /// Only guests the host invited this session, or whose broadcast is already + /// a source, are tracked: anyone else with a token for the room cannot make + /// the studio hold anything. + fn observe(&self, path: &Path, online: bool, collection: &Collection) -> Option<(String, Source)> { + let parsed = moq_room::parse(path)?; + 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 = match participants.get_mut(&identity) { + Some(participant) => participant, + None if existing => participants.entry(identity.clone()).or_default(), + None => { + tracing::debug!(%path, "ignoring a broadcast from someone the studio did not invite"); + return None; + } + }; + 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"), + }; + let name = std::iter::once(base.clone()) + .chain((2..).map(|n| format!("{base} {n}"))) + .find(|name| !collection.sources.contains_key(name)) + .expect("an unused name"); + Some((name, Source::Broadcast { path: source })) + } + + /// 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() + } +} + +/// 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(); + if let Some((name, source)) = room.observe(&event.path, event.broadcast.is_some(), &state.collection) { + tracing::info!(source = %name, identity = %event.identity, "a guest joined"); + state.edit().sources.insert(name, source); + } + } + tracing::warn!("the room's announcements ended"); +} + +#[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, false), config(true, true, false), config(false, true, true)] { + let err = partial.load(Some(&connect)).err().unwrap(); + assert!(err.to_string().contains("need all of"), "{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"); + } + + #[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_relay_a_browser_cannot_dial() { + let err = room("moqt://relay.example/show", key("show/guests", &["**"], &["**"])) + .err() + .unwrap(); + assert!(err.to_string().contains("browser"), "{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"] { + assert!(room.invite(name).is_err(), "`{name}` was accepted"); + } + assert!(room.participants().is_empty()); + } + + #[test] + fn an_invited_guest_becomes_a_source_once_and_a_stranger_never_does() { + let room = room("https://relay.example/show", key("show/guests", &["**"], &["**"])).unwrap(); + room.invite("alice").unwrap(); + let mut collection = Collection::new(); + let path = |text: &str| Path::new(text).unwrap(); + + assert_eq!(room.observe(&path("mallory/camera.hang"), true, &collection), None); + assert!(!room.participants().contains_key("mallory")); + + let (name, source) = room.observe(&path("alice/camera.hang"), true, &collection).unwrap(); + assert_eq!(name, "alice"); + assert_eq!( + source, + Source::Broadcast { + path: "guests/alice/camera.hang".into() + } + ); + collection.sources.insert(name, source); + assert!(room.participants()["alice"].camera); + + // The same broadcast again, after a restart or a reconnect, is the same source. + assert_eq!(room.observe(&path("alice/camera.hang"), true, &collection), 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).unwrap(); + assert_eq!(name, "alice screen 2"); + + assert_eq!(room.observe(&path("alice/camera.hang"), false, &collection), 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()).unwrap(); + assert_eq!(name, "alice"); + assert_eq!( + source, + Source::Broadcast { + path: "guests/alice/camera.hang".into() + } + ); + } + + #[test] + fn a_guest_already_in_the_collection_is_tracked_without_an_invite() { + 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), 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 95275251..a0a35d47 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use anyhow::Context; mod engine; +mod guests; mod ui; #[derive(usage_rs::Cli, Clone)] @@ -31,6 +32,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)] @@ -66,6 +71,7 @@ fn main() -> anyhow::Result<()> { let _runtime = runtime.enter(); let gpu = pollster::block_on(engine::Gpu::new())?; let collection = engine::load(&config.scenes)?; + let room = config.guests.load(config.client.url.as_ref())?; let connect = match config.client.url.clone() { Some(url) => Some((config.client.clone().init(config.quic.clone())?, url)), None => None, @@ -77,6 +83,7 @@ fn main() -> anyhow::Result<()> { connect, &config.broadcast, config.on_demand, + room, ))?; run(engine, gpu, runtime.handle().clone()) @@ -113,6 +120,8 @@ fn run(engine: Arc, gpu: engine::Gpu, runtime: tokio::runtime::H #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; #[test] @@ -127,6 +136,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 e1c4f92d..0a3b87de 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -21,6 +21,7 @@ enum Tab { Items, Sources, Devices, + Guests, Audio, Output, } @@ -34,6 +35,7 @@ impl Tab { Self::Items => "Items", Self::Sources => "Sources", Self::Devices => "Devices", + Self::Guests => "Guests", Self::Audio => "Audio", Self::Output => "Output", } @@ -77,7 +79,7 @@ impl Studio { let [top, bottom] = tree.split_below(NodeIndex::root(), 0.62, vec![Tab::Scenes]); 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::Audio, Tab::Output]); Ok(Self { @@ -91,6 +93,8 @@ impl Studio { new_item: None, add: AddSource::default(), devices: Devices::default(), + guest: String::new(), + shown: None, message: None, }, }) @@ -125,6 +129,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>, } @@ -152,6 +160,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::Audio => self.audio(ui), Tab::Output => self.output(ui), } @@ -449,6 +458,133 @@ 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, --room-key, and --join-page 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. + let side = ui.available_width().min(240.0); + ui.image((shown.qr.id(), egui::vec2(side, side))); + 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(Color32::from_rgb(80, 200, 120), "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 out of every scene. 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 sources = room.sources_of(&state.collection, &name); + let shown = |scene: &Scene| scene.items.iter().any(|item| sources.contains(&item.source)); + if state.collection.scenes.iter().any(shown) { + for scene in &mut state.edit().scenes { + scene.items.retain(|item| !sources.contains(&item.source)); + } + } + drop(state); + self.report(Ok(format!("removed {name} from every scene"))); + } + } + + /// 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) { + match qr(ctx, invite.link.as_str()) { + Ok(qr) => self.shown = Some(Shown { name, invite, qr }), + Err(err) => self.report(Err(err)), + } + } + fn audio(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { ui.strong("Program"); @@ -585,6 +721,44 @@ fn add(state: &mut State, name: String, source: Source) -> anyhow::Result anyhow::Result { + const QUIET: i32 = 4; + let code = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::Low) + .map_err(|err| anyhow::anyhow!("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); + 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: moq_scene::Canvas) { let available = ui.available_size(); From 4ede99cd062b7951a78772dd0d5c65b7dd99d966 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 14:57:11 -0800 Subject: [PATCH 03/12] feat(join): the page a guest opens to join the studio's room Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + biome.jsonc | 51 +++++++++ bun.lock | 242 +++++++++++++++++++++++++++++++++++++++ flake.nix | 3 + js/join/index.html | 38 ++++++ js/join/package.json | 26 +++++ js/join/src/index.css | 73 ++++++++++++ js/join/src/index.ts | 117 +++++++++++++++++++ js/join/src/link.test.ts | 30 +++++ js/join/src/link.ts | 40 +++++++ js/join/tsconfig.json | 25 ++++ js/join/vite.config.ts | 11 ++ justfile | 13 ++- package.json | 17 +++ 14 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 biome.jsonc create mode 100644 bun.lock create mode 100644 js/join/index.html create mode 100644 js/join/package.json create mode 100644 js/join/src/index.css create mode 100644 js/join/src/index.ts create mode 100644 js/join/src/link.test.ts create mode 100644 js/join/src/link.ts create mode 100644 js/join/tsconfig.json create mode 100644 js/join/vite.config.ts create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 26da5a3d..8fd68f62 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/biome.jsonc b/biome.jsonc new file mode 100644 index 00000000..0ae743ca --- /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 00000000..c6b48543 --- /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 048ecb69..d268027a 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 00000000..7db0bbd3 --- /dev/null +++ b/js/join/index.html @@ -0,0 +1,38 @@ + + + + + + + + + Join the studio + + + + +
+

Join the studio

+ + + + + +
+ + + + + diff --git a/js/join/package.json b/js/join/package.json new file mode 100644 index 00000000..d5ad0d69 --- /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 00000000..0908644e --- /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 00000000..82cb0e84 --- /dev/null +++ b/js/join/src/index.ts @@ -0,0 +1,117 @@ +/** + * 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; +} + +/** The link this page was opened with, moved out of the address bar. */ +function read(): Link { + const fragment = location.hash.slice(1) || sessionStorage.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. + sessionStorage.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()); + + 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("joined").hidden = false; + leave = join(link); + }); + element("leave").addEventListener("click", () => { + leave?.(); + leave = undefined; + sessionStorage.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 00000000..2fb01fc7 --- /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 00000000..455cd40c --- /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 00000000..461eb100 --- /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 00000000..4654c3e7 --- /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 2bd9ae7b..8279be2a 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 00000000..f2ae8495 --- /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" + } +} From 067a074b48ae93cd0e7f7cd739da87c5de272634 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:01:16 -0800 Subject: [PATCH 04/12] docs: guests in the README; finish quest/m0/guests.md Co-Authored-By: Claude Opus 5.5 --- README.md | 44 ++++++++++++++++++++++++++++++++- quest/m0/README.md | 1 - quest/m0/broadcast-sync.md | 1 - quest/m0/guests.md | 27 -------------------- rs/moq-studio/src/guests.rs | 49 +++++++++++++++++++++++++++---------- rs/moq-studio/src/ui.rs | 4 ++- 6 files changed, 82 insertions(+), 44 deletions(-) delete mode 100644 quest/m0/guests.md diff --git a/README.md b/README.md index 6da3900a..6c00847d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Every panel is a tab you can drag, split, or stack. | Items | Place, show, hide, and order the sources the preview scene draws, back to front. | | Sources | Add a source by hand, see whether each is live, and remove one. | | 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. | | Audio | Set each audio source's gain and mute, and watch its meter and the program's. | | Output | See the relay connection, whether anyone is watching, and the encoder's work. | @@ -43,6 +44,45 @@ 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. +- `--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`. +- `--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. + +**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, ready to place from the Items panel. +Only guests invited this session, or whose sources are already in the scene +file, are added. **Remove from scenes** takes a guest out of every scene; 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, @@ -127,9 +167,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/quest/m0/README.md b/quest/m0/README.md index 653a3c05..d9a78e7d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -97,7 +97,6 @@ repository. - [Guided setup](/quest/m0/guided-setup.md) - first launch detects devices, measures the uplink, and proposes settings - [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 - [Audio mixes](/quest/m0/mixes.md) - per-output mixes, meters, monitoring, and sync offsets - [Audio filters](/quest/m0/audio-filters.md) - per-source noise suppression, gate, compressor, limiter, and plugins, configured from the studio - [Recording](/quest/m0/recording.md) - each canvas's program, and optionally each source, recorded to disk alongside live outputs diff --git a/quest/m0/broadcast-sync.md b/quest/m0/broadcast-sync.md index f5650bd8..8ce4fd91 100644 --- a/quest/m0/broadcast-sync.md +++ b/quest/m0/broadcast-sync.md @@ -31,4 +31,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 01a714a4..00000000 --- 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/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index 5a0f9477..9b5cad89 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -185,7 +185,10 @@ impl Room { .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")?; + let token = self + .key + .sign(&claims) + .context("the room key cannot sign a guest's token")?; Ok((token, expires)) } @@ -238,7 +241,9 @@ impl Room { .sources .iter() .filter(|(_, source)| { - let Source::Broadcast { path } = source else { return false }; + 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) @@ -259,11 +264,8 @@ fn check_scope(key: &moq_auth::Key, room: &Path) -> anyhow::Result<()> { ); }; 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)) - }; + 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 \ @@ -330,7 +332,11 @@ mod tests { }; assert!(config(false, false, false).load(Some(&connect)).unwrap().is_none()); - for partial in [config(true, false, false), config(true, true, false), config(false, true, true)] { + for partial in [ + config(true, false, false), + config(true, true, false), + config(false, true, true), + ] { let err = partial.load(Some(&connect)).err().unwrap(); assert!(err.to_string().contains("need all of"), "{err}"); } @@ -358,7 +364,11 @@ mod tests { #[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/show", + key("", &["show/guests/**"], &["show/guests/**"]), + ) + .unwrap(); room("https://relay.example/", key("guests", &["**"], &["**"])).unwrap(); } @@ -381,11 +391,19 @@ mod tests { #[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 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"); + 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"); @@ -461,7 +479,10 @@ mod tests { 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(); + 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())); @@ -490,7 +511,9 @@ mod tests { .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()); + 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); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 0a3b87de..d1830f95 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -543,7 +543,9 @@ impl Panels { } if ui .small_button("Remove from scenes") - .on_hover_text("Take their camera and screen out of every scene. They stay connected until their link expires.") + .on_hover_text( + "Take their camera and screen out of every scene. They stay connected until their link expires.", + ) .clicked() { remove = Some(name.clone()); From 4b8a03b5e2af027239012512bf65d5e502b5e26f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:05:37 -0800 Subject: [PATCH 05/12] fix(guests): show capture failures on the join page; refuse a room a URL would re-spell Co-Authored-By: Claude Opus 5.5 --- js/join/index.html | 1 + js/join/src/index.ts | 12 ++++++++++++ rs/moq-studio/src/guests.rs | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/js/join/index.html b/js/join/index.html index 7db0bbd3..c7cbadff 100644 --- a/js/join/index.html +++ b/js/join/index.html @@ -22,6 +22,7 @@

Join the studio