open-standards/joule-code/joule-code-rs/crates/joule-moq-remote/src/main.rs
Transaction Science 8f8ba256a8 joule-moq-relay, joule-moq-remote: build on Windows
The release's Windows leg failed to compile joule-moq-relay: the DVR read
and wrote its segments through std::os::unix::fs::FileExt. On Windows the
same two calls now run over seek_read and seek_write. wai-remote-host would
have failed next on its Unix file modes; they now apply on Unix only, as the
room's keys already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:38:14 -04:00

350 lines
17 KiB
Rust

//! `wai-remote-host`: a remote desktop host in a WAI room.
//!
//! wai-remote-host room <dir> [--seed <hex>] [--size WxH] [--fps N] [--participant name:classes]...
//! make a desk, a room the host keeps: room.json, host-keys.json (mode 0600), a file for
//! each participant (mode 0600, for the headless viewer), and relay.toml, the lines the
//! relay's configuration takes for it
//! wai-remote-host key <file>
//! a host key for a room service's rooms (mode 0600, never over a file); prints its public key
//! wai-remote-host run <dir> --relay <ws-url> [options]
//! wai-remote-host run --room <service-url> --key <file> [--invitation <hex>] --relay <ws-url> [options]
//! host the desk in <dir>, or the room the room service at <service-url> keeps (joule-moq-room
//! with [remote]: the service names the host's key, or takes the invitation its authority
//! signed): capture, encode, publish, decide and inject until the time runs out or ^C.
//! The service's URL is http:// or https://, the relay's ws:// or wss://; over TLS each
//! certificate is verified against the system's roots and, with --ca, the certificates in a
//! PEM file. The host makes a sealing key for the session and states it: controllers seal
//! their key, text and clipboard actions to it, and the host opens each before it injects it.
//! Options: [--source display|pattern] [--encoder platform|platform-hevc|ipcm]
//! [--input platform|record] [--floor open|floor] [--give-on-request] [--fps N] [--max-age-ms N]
//! [--redact x,y,w,h]... [--seconds N] [--ca <pem>] [--record <path>] [--opening-key <path>];
//! with --record, the session's records as a verifier takes them (descriptor, grants,
//! chains), which `remote_desktop_verify.py --room` checks; with --opening-key, the
//! session's opening key (mode 0600, never over a file), which that verifier takes to check
//! the session at the opened level; with --input record and --record-injected <path>, what
//! the recorder injected, as counts and digests (no text)
//! wai-remote-host check
//! what this machine offers: the platform encoder and whether it runs on the media engine,
//! and whether the operating system lets this process capture the screen and inject input
//!
//! The room's classes are the control power's bits (WAI remote-desktop §3.1): 1 pointer, 2 keys,
//! 4 touch, 8 clipboard; 0 a viewer.
use joule_moq_remote::agent::{self, Command, Config, Room};
use joule_moq_remote::capture::{Capture, Pattern};
use joule_moq_remote::encode::{Encoder, Ipcm};
use joule_moq_remote::inject::{Injector, Recorder};
use joule_moq_remote::room::{self, HostKeys, RoomFile, Spec};
use joule_moq_remote::tls::Trust;
use std::path::{Path, PathBuf};
use std::time::Duration;
use wai::remote_desktop::{floor_mode, Rect};
fn arg<'a>(a: &'a [String], name: &str) -> Option<&'a str> {
a.iter().position(|x| x == name).and_then(|i| a.get(i + 1)).map(String::as_str)
}
fn args<'a>(a: &'a [String], name: &str) -> Vec<&'a str> {
a.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
}
fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
serde_json::from_slice(&std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?).map_err(|e| format!("{}: {e}", p.display()))
}
fn write(p: &Path, body: &str, private: bool) -> Result<(), String> {
std::fs::write(p, body).map_err(|e| format!("{}: {e}", p.display()))?;
if private {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn make_room(a: &[String]) -> Result<(), String> {
let dir = PathBuf::from(a.get(2).ok_or("a directory")?);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let seed: [u8; 32] = match arg(a, "--seed") {
Some(h) => room::unhex32(h)?,
None => {
let mut s = [0u8; 32];
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut s);
s
}
};
let (w, h) = arg(a, "--size").map_or(Ok((1280, 720)), |s| {
let (w, h) = s.split_once('x').ok_or("--size WxH")?;
Ok::<_, String>((w.parse().map_err(|_| "a width")?, h.parse().map_err(|_| "a height")?))
})?;
let fps = arg(a, "--fps").map_or(Ok(30), |s| s.parse().map_err(|_| "--fps N".to_string()))?;
let mut participants = Vec::new();
for p in args(a, "--participant") {
let (n, c) = p.split_once(':').ok_or("--participant name:classes")?;
participants.push((n.to_string(), c.parse().map_err(|_| "classes 0 to 15")?));
}
let t0 = joule_moq_remote::capture::now_unix_us();
let spec = Spec { width: w, height: h, fps, participants, grant_s: 12 * 3600 };
let (rf, keys, _authority, files) = room::new_room(&seed, t0, &spec)?;
write(&dir.join("room.json"), &serde_json::to_string_pretty(&rf).map_err(|e| e.to_string())?, false)?;
write(&dir.join("host-keys.json"), &serde_json::to_string_pretty(&keys).map_err(|e| e.to_string())?, true)?;
for f in &files {
write(&dir.join(format!("participant-{}.json", f.me)), &serde_json::to_string_pretty(f).map_err(|e| e.to_string())?, true)?;
}
write(&dir.join("relay.toml"), &room::relay_lines(&rf, &keys)?, false)?;
println!("room {} in {}: {} participant file(s); add relay.toml to the relay's configuration", rf.room, dir.display(), files.len());
Ok(())
}
/// What a pattern source draws, shared with the injector that draws on it.
type Drawn = std::sync::Arc<std::sync::Mutex<joule_moq_remote::capture::Drawn>>;
fn source(kind: &str, size: (u32, u32)) -> Result<(Box<dyn Capture>, Option<Drawn>), String> {
match kind {
"pattern" => {
let p = Pattern::new(size.0, size.1);
let d = p.drawn.clone();
Ok((Box::new(p), Some(d)))
}
#[cfg(target_os = "macos")]
"display" => Ok((Box::new(joule_moq_remote::adapter_macos_capture::DisplayCapture::new(size.0, size.1, true)?), None)),
#[cfg(target_os = "linux")]
"display" => Ok((Box::new(joule_moq_remote::adapter_linux_x11::X11Capture::new()?), None)),
other => Err(format!("no source {other} here")),
}
}
fn encoder(kind: &str, w: u32, h: u32, fps: u32) -> Result<Box<dyn Encoder>, String> {
match kind {
"ipcm" => Ok(Box::new(Ipcm::new(w, h)?)),
#[cfg(target_os = "macos")]
"platform" => Ok(Box::new(joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(w, h, fps, 4_000, joule_moq_remote::adapter_macos_encode::Codec::Avc)?)),
#[cfg(target_os = "macos")]
"platform-hevc" => Ok(Box::new(joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(w, h, fps, 4_000, joule_moq_remote::adapter_macos_encode::Codec::Hevc)?)),
other => {
let _ = fps;
Err(format!("no encoder {other} here"))
}
}
}
/// What a recorder injected, shared with the session that hands it actions.
type Injected = std::sync::Arc<std::sync::Mutex<Vec<wai::remote_desktop::Action>>>;
fn injector(kind: &str, drawn: Option<Drawn>, size: (u32, u32), log: &mut Option<Injected>) -> Result<Box<dyn Injector>, String> {
match kind {
"record" => {
let r = Recorder::new(drawn.unwrap_or_default());
*log = Some(r.log.clone());
Ok(Box::new(r))
}
#[cfg(target_os = "macos")]
"platform" => {
let _ = size;
Ok(Box::new(joule_moq_remote::adapter_macos_inject::PlatformInjector::new()?))
}
#[cfg(target_os = "linux")]
"platform" => Ok(Box::new(joule_moq_remote::adapter_linux_uinput::UinputInjector::new(size.0, size.1)?)),
other => {
let _ = size;
Err(format!("no input {other} here"))
}
}
}
/// What a recorder injected, for a test that checks the host injected what was typed without
/// the text written anywhere: the count of each action type, and the SHA-256 of the HID usages of
/// the keys pressed (u16 little-endian each), of the texts and of the clipboard texts, each in
/// order.
fn injected_summary(log: &[wai::remote_desktop::Action]) -> String {
use sha2::{Digest, Sha256};
use wai::remote_desktop::{type_name, Action};
let (mut keys, mut text, mut clip) = (Sha256::new(), Sha256::new(), Sha256::new());
let mut counts = std::collections::BTreeMap::new();
for a in log {
*counts.entry(type_name(a.type_code())).or_insert(0u32) += 1;
match a {
Action::Key { usage, down: true, .. } => keys.update(usage.to_le_bytes()),
Action::Text(t) => text.update(t.as_bytes()),
Action::Clipboard(t) => clip.update(t.as_bytes()),
_ => {}
}
}
serde_json::json!({"counts": counts, "keys_sha256": hex::encode(keys.finalize()), "text_sha256": hex::encode(text.finalize()), "clipboard_sha256": hex::encode(clip.finalize())}).to_string()
}
/// A host key file for a room service's rooms: made with mode 0600, never over a file.
fn make_key(a: &[String]) -> Result<(), String> {
let p = PathBuf::from(a.get(2).ok_or("a key file")?);
let mut seed = [0u8; 32];
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut seed);
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(&p).map_err(|e| format!("{}: {e}", p.display()))?;
std::io::Write::write_all(&mut f, format!("{}\n", hex::encode(seed)).as_bytes()).map_err(|e| e.to_string())?;
println!("{}", hex::encode(ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()));
Ok(())
}
/// A host key file, readable by its owner alone.
fn read_key(p: &Path) -> Result<ed25519_dalek::SigningKey, String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
if m.permissions().mode() & 0o077 != 0 {
return Err(format!("{}: the key must be readable by its owner alone (chmod 600)", p.display()));
}
}
let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
Ok(ed25519_dalek::SigningKey::from_bytes(&room::unhex32(s.trim())?))
}
/// Write a secret to a new file, readable by its owner alone, never over a file.
fn write_private_new(p: &Path, body: &str) -> Result<(), String> {
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(p).map_err(|e| format!("{}: {e}", p.display()))?;
std::io::Write::write_all(&mut f, body.as_bytes()).map_err(|e| e.to_string())
}
async fn run(a: &[String]) -> Result<(), String> {
let relay = arg(a, "--relay").ok_or("--relay ws://…/moq or wss://…/moq")?.to_string();
let trust = match arg(a, "--ca") {
Some(p) => Trust::system_and_pem(Path::new(p))?,
None => Trust::system(),
};
// A desk in a directory, or a room a room service keeps.
let (room, descriptor) = match arg(a, "--room") {
Some(url) => {
let key = read_key(Path::new(arg(a, "--key").ok_or("--key <file> (wai-remote-host key <file>)")?))?;
let v = joule_moq_remote::service::RoomService::new(url)?.trusting(trust.clone()).room().await?;
(Room::Service { url: url.to_string(), key, invitation: arg(a, "--invitation").map(String::from) }, v.descriptor)
}
None => {
let dir = PathBuf::from(a.get(2).ok_or("a room directory, or --room <service-url>")?);
let rf: RoomFile = read_json(&dir.join("room.json"))?;
let keys: HostKeys = read_json(&dir.join("host-keys.json"))?;
let d = rf.descriptor_bytes()?;
(Room::Desk { room: rf, keys }, d)
}
};
let d = wai::live_room::Descriptor::parse(&descriptor).map_err(|e| e.code().to_string())?;
let layer = &d.profiles[0].layers[0];
let fps = arg(a, "--fps").map_or(Ok(layer.fps as u32), |s| s.parse().map_err(|_| "--fps N".to_string()))?;
let (cap, drawn) = source(arg(a, "--source").unwrap_or("display"), (layer.width as u32, layer.height as u32))?;
let (w, h) = cap.size();
let enc = encoder(arg(a, "--encoder").unwrap_or(if cfg!(target_os = "macos") { "platform" } else { "ipcm" }), w, h, fps)?;
let mut injected = None;
let inj = injector(arg(a, "--input").unwrap_or(if drawn.is_some() { "record" } else { "platform" }), drawn, cap.target(), &mut injected)?;
let mut redactions = Vec::new();
for r in args(a, "--redact") {
let v: Vec<u16> = r.split(',').map(|x| x.parse().map_err(|_| "--redact x,y,w,h".to_string())).collect::<Result<_, _>>()?;
let [x, y, w, h] = v[..] else { return Err("--redact x,y,w,h".into()) };
redactions.push(Rect { x, y, w, h });
}
// The session's opening key, written before the session starts when it is asked for; otherwise
// the agent makes one that never leaves it.
let opening_key = match arg(a, "--opening-key") {
Some(p) => {
let mut ikm = [0u8; 32];
rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut ikm);
let (sk, _) = wai::hpke::derive_key_pair(&ikm);
write_private_new(Path::new(p), &format!("{}\n", hex::encode(sk)))?;
Some(agent::OpeningKey(sk))
}
None => None,
};
let cfg = Config {
relay_url: relay,
trust,
opening_key,
room,
fps,
max_age_ms: arg(a, "--max-age-ms").map_or(Ok(1_000), |s| s.parse().map_err(|_| "--max-age-ms N".to_string()))?,
floor: if arg(a, "--floor") == Some("open") { floor_mode::OPEN } else { floor_mode::FLOOR },
give_on_request: a.iter().any(|x| x == "--give-on-request"),
redactions,
clip_max: 4000,
pointer: true,
key_every: Duration::from_secs(2),
run_for: arg(a, "--seconds").map(|s| s.parse().map(Duration::from_secs)).transpose().map_err(|_| "--seconds N".to_string())?,
};
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
let _ = tx.send(Command::Stop);
});
let r = agent::run(cfg, cap, enc, inj, rx).await?;
if let (Some(p), Some(log)) = (arg(a, "--record-injected"), &injected) {
write(Path::new(p), &injected_summary(&log.lock().map_err(|_| "a poisoned lock")?), false)?;
}
if let Some(p) = arg(a, "--record") {
let room = serde_json::json!({
"descriptor": hex::encode(&r.descriptor),
"grants": r.grants.iter().map(hex::encode).collect::<Vec<_>>(),
"key_log": null,
"chains": r.chains.iter().map(|c| c.iter().map(hex::encode).collect::<Vec<_>>()).collect::<Vec<_>>(),
});
write(Path::new(p), &room.to_string(), false)?;
}
if r.ended {
println!("the room's clock ended the session");
}
println!(
"{} frames ({} intra-coded, {} bytes), {} inputs decided; encoder {} (interface {}, hardware {})",
r.frames,
r.key_frames,
r.bytes,
r.decided.len(),
r.identity.encoder,
r.identity.api,
r.identity.hardware
);
Ok(())
}
fn check() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
match joule_moq_remote::adapter_macos_encode::PlatformEncoder::new(640, 360, 30, 2_000, joule_moq_remote::adapter_macos_encode::Codec::Avc) {
Ok(e) => {
let id = e.identity();
println!("encoder: {} ({}, {}), on the media engine: {}", id.encoder, id.capability, id.codec, id.hardware == wai::remote_desktop::hardware::MEDIA_ENGINE);
}
Err(e) => println!("encoder: {e}"),
}
println!("screen capture allowed: {}", joule_moq_remote::adapter_macos_capture::may_capture());
println!("input injection allowed: {}", joule_moq_remote::adapter_macos_inject::may_inject());
}
#[cfg(not(target_os = "macos"))]
{
println!("encoder: ipcm (a pure function); a platform encoder adapter is not built here");
println!("display: {}", std::env::var("DISPLAY").unwrap_or_else(|_| "none".into()));
println!("input device: {}", if std::path::Path::new("/dev/uinput").exists() { "/dev/uinput" } else { "none" });
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_writer(std::io::stderr).init();
let a: Vec<String> = std::env::args().collect();
let r = match a.get(1).map(String::as_str) {
Some("room") => make_room(&a),
Some("run") => run(&a).await,
Some("key") => make_key(&a),
Some("check") => check(),
_ => Err("usage: wai-remote-host room <dir> … | key <file> | run (<dir> | --room <service-url> --key <file>) --relay <url> … | check".into()),
};
if let Err(e) = r {
eprintln!("wai-remote-host: {e}");
std::process::exit(1);
}
}