Some checks failed
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 5s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 7s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 8s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 10s
wai-web wasm32 / does this change touch it (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
wai-quantum studio and desktop / does this change touch it (pull_request) Has been cancelled
wai-quantum studio and desktop / studio · browser (pull_request) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (pull_request) Has been cancelled
wai-pack / wai-pack · does this change touch it (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Has been cancelled
WAI energy receipts / does this change touch it (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Has been cancelled
WAI conformance / does this change touch it (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (pull_request) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Has been cancelled
standards workspaces build + test / which workspaces this change touches (pull_request) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (pull_request) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
Config and Report derive Debug; the session's opening key is held as OpeningKey, whose Debug is OpeningKey(..). Co-Authored-By: Claude Opus 5.5 <[email protected]>
260 lines
16 KiB
Rust
260 lines
16 KiB
Rust
//! A remote desktop session over loopback: the host agent, a relay and three viewers, every
|
|
//! record of the session then verified whole by `wai-rs` (WAI remote-desktop §11).
|
|
//!
|
|
//! The relay requires grants with their proofs and knows the room's authority and the clock's
|
|
//! token alone. The host keeps a floor and gives it on request. Alice (`remote_control` 3:
|
|
//! pointer and keys) requests it, and her click is applied; bob (no control power) is refused
|
|
//! `input_not_permitted`; carol (pointer) is refused `input_no_floor`; alice types a string and
|
|
//! composes a text, which go sealed to the host's sealing key (remote-desktop §5.5) and are
|
|
//! injected as typed, while bob, who follows her events, receives no trace of them, and her key
|
|
//! sent in the clear is refused `input_unsealed`; the floor is handed to carol, and alice is
|
|
//! refused while carol's move is applied; the host pauses, and carol is refused
|
|
//! `input_screen_paused`; it resumes. Each viewer verifies every frame against its receipt, and
|
|
//! alice and carol sign latency records of the frames their outcomes name. Then the session's
|
|
//! chains, as the host holds them, verify at the commitment level, as a viewer verifies them, and
|
|
//! at the opened level under the host's opening key: every outcome the host signed is what the
|
|
//! rules give, every seal opens to what alice typed, the floor changes as it did, and each
|
|
//! latency's legs are stated.
|
|
//!
|
|
//! One test runs with the pure-function encoder anywhere; on macOS a second runs with the
|
|
//! platform's hardware encoder and checks the units are the Annex B the capability carries.
|
|
//! They take a lock, so they run one at a time on a loaded host.
|
|
|
|
use joule_moq_relay::config::{Authority, Grant as RelayGrant};
|
|
use joule_moq_relay::{RelayConfig, RelayServer};
|
|
use joule_moq_remote::agent::{self, Command, Config, Decided, Room};
|
|
use joule_moq_remote::capture::Pattern;
|
|
use joule_moq_remote::encode::{Encoder, Ipcm};
|
|
use joule_moq_remote::inject::Recorder;
|
|
use joule_moq_remote::room::{new_room, unhex32, Spec};
|
|
use joule_moq_remote::viewer::Viewer;
|
|
use sha2::{Digest, Sha256};
|
|
use std::time::Duration;
|
|
use wai::remote_desktop::{self as rd, class, floor_action, screen_state, Action, Fate, Refusal, SealFact, TimedAction};
|
|
|
|
static ONE_AT_A_TIME: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
const STEP: Duration = Duration::from_secs(20);
|
|
|
|
fn at(dt_us: u32, action: Action) -> TimedAction {
|
|
TimedAction { dt_us, action }
|
|
}
|
|
|
|
async fn session(make_encoder: impl FnOnce(u32, u32) -> Box<dyn Encoder>, check_units: bool) {
|
|
let _one = ONE_AT_A_TIME.lock().await;
|
|
let (w, h) = (320u32, 192u32);
|
|
let t0 = joule_moq_remote::capture::now_unix_us() - 1_000_000;
|
|
let spec = Spec { width: w as u16, height: h as u16, fps: 20, participants: vec![("alice".into(), class::POINTER | class::KEYS), ("bob".into(), 0), ("carol".into(), class::POINTER)], grant_s: 3600 };
|
|
let (rf, keys, _authority, files) = new_room(&[0x5a; 32], t0, &spec).expect("a room");
|
|
let mut c = RelayConfig::loopback();
|
|
c.auth.required = true;
|
|
c.auth.authorities = vec![Authority { key: rf.authority.clone(), rooms: vec![rf.room.clone()] }];
|
|
let token = hex::decode(&keys.clock_token).unwrap();
|
|
c.auth.grants = vec![RelayGrant { prefix: vec![rf.room.clone()], token_sha256: hex::encode(Sha256::digest(&token)), publish: true, subscribe: true, proof: false, unlimited: true, seek: false }];
|
|
let server = RelayServer::start(c).await.expect("the relay starts");
|
|
let url = format!("ws://{}{}", server.websocket_addr.expect("a WebSocket listener"), server.relay().config().path);
|
|
|
|
let pattern = Pattern::new(w, h);
|
|
let recorder = Recorder::new(pattern.drawn.clone());
|
|
let injected = recorder.log.clone();
|
|
let encoder = make_encoder(w, h);
|
|
let identity = encoder.identity();
|
|
let cfg = Config {
|
|
relay_url: url.clone(),
|
|
trust: joule_moq_remote::tls::Trust::system(),
|
|
opening_key: None,
|
|
room: Room::Desk { room: rf.clone(), keys },
|
|
fps: 20,
|
|
max_age_ms: 3_000,
|
|
floor: rd::floor_mode::FLOOR,
|
|
give_on_request: true,
|
|
redactions: vec![rd::Rect { x: 0, y: 0, w: 64, h: 16 }],
|
|
clip_max: 1000,
|
|
pointer: true,
|
|
key_every: Duration::from_secs(2),
|
|
run_for: Some(Duration::from_secs(90)),
|
|
};
|
|
let (cmd, cmds) = tokio::sync::mpsc::unbounded_channel();
|
|
let host = tokio::spawn(agent::run(cfg, Box::new(pattern), encoder, Box::new(recorder), cmds));
|
|
|
|
let file = |n: &str| files.iter().find(|f| f.me == n).cloned().expect("a participant");
|
|
let mut alice = Viewer::join(&file("alice"), &url).await.expect("alice joins");
|
|
let mut bob = Viewer::join(&file("bob"), &url).await.expect("bob joins");
|
|
let mut carol = Viewer::join(&file("carol"), &url).await.expect("carol joins");
|
|
for (n, v) in [("alice", &mut alice), ("bob", &mut bob), ("carol", &mut carol)] {
|
|
assert!(v.pump(STEP, |v| v.joined && v.screen.is_some() && v.frames.len() >= 3).await, "{n}: joined, the screen statement and frames ({} frames, joined {})", v.frames.len(), v.joined);
|
|
}
|
|
let screen = alice.screen.clone().unwrap().1;
|
|
assert_eq!(screen.encoder, identity.encoder, "the screen statement names the encoder");
|
|
assert_eq!((screen.width as u32, screen.height as u32), (w, h));
|
|
|
|
// Alice asks for the floor; the host gives it on request.
|
|
alice.floor(floor_action::REQUEST, [0; 32]).await.unwrap();
|
|
let me_a = alice.me;
|
|
assert!(alice.pump(STEP, |v| v.holder == Some(me_a)).await, "the floor given to alice");
|
|
let a1 = alice.input(vec![at(6_000, Action::Move { x: 100, y: 50 }), at(3_000, Action::Button { button: 0, down: true, x: 100, y: 50 }), at(0, Action::Button { button: 0, down: false, x: 100, y: 50 })]).await.unwrap();
|
|
assert!(alice.pump(STEP, |v| v.outcomes.get(&a1).is_some_and(|o| o.2.is_some_and(|an| v.frames.contains_key(&an.group)))).await, "alice's click applied and its frame held");
|
|
let la = alice.latency(a1, 0).await.expect("alice's latency record");
|
|
assert!(la.latency_us > 0, "a latency is measured");
|
|
assert!(screen.seal.is_some(), "the host states a sealing key");
|
|
|
|
// Alice types a string and composes a text: sealed to the host, injected as typed. Bob
|
|
// follows her events, as any viewer of the room may.
|
|
bob.follow(alice.me).await.expect("bob follows alice's events");
|
|
let mut typed = Vec::new();
|
|
for (k, u) in [0x0Bu16, 0x18, 0x11, 0x17, 0x08, 0x15, 0x1F].iter().enumerate() {
|
|
typed.push(at(20_000 - 2_000 * k as u32, Action::Key { usage: *u, down: true, modifiers: 0 }));
|
|
typed.push(at(19_000 - 2_000 * k as u32, Action::Key { usage: *u, down: false, modifiers: 0 }));
|
|
}
|
|
typed.push(at(0, Action::Text("pässwörd-hunter2".into())));
|
|
let a3 = alice.input(typed.clone()).await.unwrap();
|
|
assert!(alice.pump(STEP, |v| v.outcomes.contains_key(&a3)).await, "alice's typing answered");
|
|
assert_eq!(alice.outcomes[&a3].1.reason, 0, "applied");
|
|
// Her key sent in the clear to a host that states a sealing key: refused, never injected.
|
|
let a4 = alice.input_on(alice.screen.as_ref().unwrap().0, vec![at(0, Action::Text("in the clear".into()))]).await.unwrap();
|
|
assert!(alice.pump(STEP, |v| v.outcomes.contains_key(&a4)).await);
|
|
assert_eq!(alice.outcomes[&a4].1.reason, Refusal::Unsealed.number(), "a text in the clear is refused");
|
|
assert!(bob.pump(STEP, |v| v.received.iter().any(|r| wai::live_events::Event::parse(r).is_ok_and(|e| e.principal == me_a && rd::read_statement(&e.payload).is_some_and(|s| matches!(s, Ok(rd::Statement::Input(i)) if i.sealed_count() > 0))))).await, "bob receives alice's sealed input");
|
|
|
|
// Bob carries no control power; carol has it and not the floor.
|
|
let b1 = bob.input(vec![at(0, Action::Move { x: 10, y: 10 })]).await.unwrap();
|
|
assert!(bob.pump(STEP, |v| v.outcomes.contains_key(&b1)).await, "bob's input answered");
|
|
assert_eq!(bob.outcomes[&b1].1.reason, Refusal::NotPermitted.number());
|
|
let c1 = carol.input(vec![at(0, Action::Move { x: 20, y: 20 })]).await.unwrap();
|
|
assert!(carol.pump(STEP, |v| v.outcomes.contains_key(&c1)).await, "carol's input answered");
|
|
assert_eq!(carol.outcomes[&c1].1.reason, Refusal::NoFloor.number());
|
|
|
|
// The floor handed to carol.
|
|
carol.floor(floor_action::REQUEST, [0; 32]).await.unwrap();
|
|
cmd.send(Command::GiveFloor(carol.me)).unwrap();
|
|
let me_c = carol.me;
|
|
assert!(carol.pump(STEP, |v| v.holder == Some(me_c)).await, "the floor handed to carol");
|
|
assert!(alice.pump(STEP, |v| v.holder == Some(me_c)).await);
|
|
let a2 = alice.input(vec![at(0, Action::Key { usage: 0x04, down: true, modifiers: 0 })]).await.unwrap();
|
|
assert!(alice.pump(STEP, |v| v.outcomes.contains_key(&a2)).await);
|
|
assert_eq!(alice.outcomes[&a2].1.reason, Refusal::NoFloor.number(), "alice, off the floor");
|
|
let c2 = carol.input(vec![at(0, Action::Move { x: 200, y: 120 })]).await.unwrap();
|
|
assert!(carol.pump(STEP, |v| v.outcomes.get(&c2).is_some_and(|o| o.2.is_some_and(|an| v.frames.contains_key(&an.group)))).await, "carol's move applied");
|
|
carol.latency(c2, 0).await.expect("carol's latency record");
|
|
|
|
// Paused, then live again.
|
|
cmd.send(Command::Pause).unwrap();
|
|
assert!(carol.pump(STEP, |v| v.screen.as_ref().is_some_and(|s| s.1.state == screen_state::PAUSED)).await, "the host pauses");
|
|
let c3 = carol.input(vec![at(0, Action::Move { x: 5, y: 5 })]).await.unwrap();
|
|
assert!(carol.pump(STEP, |v| v.outcomes.contains_key(&c3)).await);
|
|
assert_eq!(carol.outcomes[&c3].1.reason, Refusal::ScreenPaused.number());
|
|
cmd.send(Command::Resume).unwrap();
|
|
assert!(carol.pump(STEP, |v| v.screen.as_ref().is_some_and(|s| s.1.state == screen_state::LIVE)).await, "the host resumes");
|
|
// Let every viewer's last records reach the host before it stops.
|
|
for v in [&mut alice, &mut bob, &mut carol] {
|
|
v.pump(Duration::from_millis(700), |_| false).await;
|
|
}
|
|
cmd.send(Command::Stop).unwrap();
|
|
let report = tokio::time::timeout(Duration::from_secs(60), host).await.expect("the host stops").expect("the task").expect("the session");
|
|
for v in [&alice, &bob, &carol] {
|
|
assert_eq!(v.refused_frames, 0, "every frame and receipt verified");
|
|
v.close();
|
|
}
|
|
server.shutdown().await;
|
|
|
|
// What the host decided, and what it injected.
|
|
let decided = |d: &[u8; 32]| report.decided.iter().find(|(x, _)| x == d).map(|(_, o)| o.clone());
|
|
assert!(matches!(decided(&a1), Some(Decided::Applied { .. })), "{:?}", decided(&a1));
|
|
assert_eq!(decided(&b1), Some(Decided::Refused(Refusal::NotPermitted)));
|
|
assert_eq!(decided(&c1), Some(Decided::Refused(Refusal::NoFloor)));
|
|
assert_eq!(decided(&a2), Some(Decided::Refused(Refusal::NoFloor)));
|
|
assert!(matches!(decided(&c2), Some(Decided::Applied { .. })));
|
|
assert_eq!(decided(&c3), Some(Decided::Refused(Refusal::ScreenPaused)));
|
|
let log = injected.lock().unwrap().clone();
|
|
assert_eq!(log.len(), 4 + typed.len(), "alice's three actions, what she typed and carol's move, nothing else: {log:?}");
|
|
let opened: Vec<Action> = typed.iter().map(|a| a.action.clone()).collect();
|
|
assert_eq!(&log[3..3 + typed.len()], &opened[..], "the host injected what alice typed, opened");
|
|
// Bob's received records hold no trace of what alice typed: not the text, not a key's
|
|
// fields as an action in the clear carries them.
|
|
for needle in [&b"hunter2"[..], "pässwörd".as_bytes(), &[action_type_key(), 0x0B, 0x00, 0x01, 0x00]] {
|
|
assert!(!bob.received.iter().any(|r| r.windows(needle.len()).any(|w| w == needle)), "bob's records hold {needle:?}");
|
|
}
|
|
assert!(bob.received.iter().any(|r| r.windows(b"in the clear".len()).any(|w| w == b"in the clear")), "the search finds what was sent in the clear");
|
|
assert!(report.frames >= 20 && report.key_frames >= 1, "{} frames", report.frames);
|
|
|
|
// The session, verified whole from the records the host holds: at the commitment level, and
|
|
// at the opened level under the host's opening key, every seal opened to what was typed.
|
|
let vi = rd::VerifyInput { descriptor: &report.descriptor, grants: &report.grants, head: None, chains: &report.chains };
|
|
let opened = rd::verify_opened(&vi, &[report.opening_key.0]);
|
|
assert_eq!(opened.outcome, rd::Outcome::Accept, "the session verifies at the opened level");
|
|
let fact = opened.inputs.iter().find(|f| f.digest == a3).expect("alice's typing");
|
|
let Some(SealFact::Opened(st)) = &fact.seal else { panic!("its seal opened: {:?}", fact.seal) };
|
|
assert_eq!(rd::read_statement(st), Some(Ok(rd::Statement::Input(rd::Input::clear(rf_host(&rf), alice.sent[&a3].1.screen, typed.clone())))), "it opens to what alice typed");
|
|
let run = rd::verify(&vi);
|
|
assert_eq!(run.outcome, rd::Outcome::Accept, "the session verifies");
|
|
let fate = |d: &[u8; 32]| run.inputs.iter().find(|f| f.digest == *d).map(|f| f.fate);
|
|
assert!(matches!(fate(&a1), Some(Fate::Applied { .. })));
|
|
assert_eq!(fate(&b1), Some(Fate::Refused(Refusal::NotPermitted)));
|
|
assert_eq!(fate(&c1), Some(Fate::Refused(Refusal::NoFloor)));
|
|
assert_eq!(fate(&c3), Some(Fate::Refused(Refusal::ScreenPaused)));
|
|
let holders: Vec<Option<[u8; 32]>> = run.floor.iter().map(|c| c.holder).collect();
|
|
assert!(holders.starts_with(&[Some(alice.me), Some(carol.me)]), "the floor to alice, then carol: {holders:?}");
|
|
assert_eq!(run.screens.len(), 3, "live, paused, live");
|
|
assert_eq!(run.latencies.len(), 2);
|
|
for l in &run.latencies {
|
|
let legs = l.legs.expect("checked");
|
|
assert_eq!(legs.up + legs.host + legs.down, l.latency_us as i64);
|
|
assert_eq!(legs.uncertainty, Some(0), "the host is the room's clock and the viewers read it on one machine");
|
|
eprintln!("latency {} µs: up {} µs, host {} µs, down {} µs ({})", l.latency_us, legs.up, legs.host, legs.down, identity.encoder);
|
|
}
|
|
let host_pk = unhex32(&rf.host.principal).unwrap();
|
|
assert_eq!(run.totals[&host_pk].applied, 3);
|
|
assert_eq!(run.totals[&host_pk].refused[Refusal::Unsealed.number() as usize - 1], 1);
|
|
if check_units {
|
|
assert!(report.bytes > 0);
|
|
}
|
|
}
|
|
|
|
fn action_type_key() -> u8 {
|
|
rd::action_type::KEY
|
|
}
|
|
|
|
fn rf_host(rf: &joule_moq_remote::room::RoomFile) -> [u8; 32] {
|
|
unhex32(&rf.host.principal).unwrap()
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_session_with_the_pure_function_encoder() {
|
|
tokio::time::timeout(Duration::from_secs(240), session(|w, h| Box::new(Ipcm::new(w, h).unwrap()), false)).await.expect("the session finishes in time");
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_session_with_the_platform_hardware_encoder() {
|
|
use joule_moq_remote::adapter_macos_encode::{Codec, PlatformEncoder};
|
|
let e = PlatformEncoder::new(320, 192, 20, 1_000, Codec::Avc).expect("the platform encoder");
|
|
eprintln!("platform encoder: {:?}", e.identity());
|
|
drop(e);
|
|
tokio::time::timeout(Duration::from_secs(240), session(|w, h| Box::new(PlatformEncoder::new(w, h, 20, 1_000, Codec::Avc).unwrap()), true)).await.expect("the session finishes in time");
|
|
}
|
|
|
|
/// The platform encoder's units are what `wai.video.avc` carries: one picture each, Annex B, the
|
|
/// parameter sets before every IDR picture, an IDR picture when asked.
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn the_platform_encoder_writes_annex_b_access_units() {
|
|
use joule_moq_remote::adapter_macos_encode::{Codec, PlatformEncoder};
|
|
use joule_moq_remote::capture::Capture;
|
|
use joule_moq_remote::encode::{avc_types, avc_unit_ok};
|
|
let mut e = PlatformEncoder::new(320, 192, 30, 1_000, Codec::Avc).expect("the platform encoder");
|
|
let mut p = Pattern::new(320, 192);
|
|
let mut keys = 0;
|
|
for i in 0..30 {
|
|
p.drawn.lock().unwrap().pointer = (i * 7, i * 3);
|
|
let u = e.encode(&p.grab().unwrap(), i % 10 == 0).expect("a unit");
|
|
assert!(avc_unit_ok(&u), "picture {i}: {:?}", avc_types(&u.data));
|
|
if i % 10 == 0 {
|
|
assert!(u.key, "picture {i}: an IDR picture when asked");
|
|
}
|
|
keys += u.key as u32;
|
|
}
|
|
assert!(keys >= 3);
|
|
let id = e.identity();
|
|
assert!(rd::is_encoder_name(id.encoder.as_bytes()), "{}", id.encoder);
|
|
eprintln!("{} on the media engine: {}", id.encoder, id.hardware == rd::hardware::MEDIA_ENGINE);
|
|
}
|