- commit
- 5a2bab9ce4f6e7da37a7e6f39e94753c55e22dfc
- parent
- a3af7ab2d9a9b1d0411f7af7d9b6b2ade6350d90
- author
- Dami <dami.etoile@gmail.com>
@@ -14,14 +14,19 @@ use iroh_moq::{Moq, MoqSession};
1414 use moq_net::{BroadcastConsumer, GroupConsumer, TrackConsumer};1515 use sigil_protocol::{1616 AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,17- CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,18- GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputAck,19- InputEvent, InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,17+ CONTROL_ALPN_V1, CONTROL_ALPN_V2, Capability, ClientControlEnvelopeV2, ClientHello,18+ ClientHelloV2, ControllerSlot, FocusCommandActionV2, FocusCommandV2, FocusStateV2, FrameFlags,19+ GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN, GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1,20+ INPUT_ALPN_V2, INVITATION_CLOCK_SKEW_SECS, InputAck, InputClientHelloV2, InputEvent,21+ InputEventV2, InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,2022 MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,2123 MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,22- MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError, SignedInvitation,23- decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,24- read_host_hello, read_input_ack, read_media_object_v3, write_client_hello, write_input_event,24+ MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError,25+ ServerControlEnvelopeV2, SignedInvitation, decode_media_frame_object, media_moq_broadcast_name,26+ read_adaptive_bitrate_decision_v1, read_host_hello, read_host_hello_v2, read_input_ack,27+ read_input_ack_v2, read_input_host_hello_v2, read_media_object_v3, read_server_control_v2,28+ write_client_control_v2, write_client_hello, write_client_hello_v2,29+ write_input_client_hello_v2, write_input_event, write_input_event_v2,2530 write_media_control_request_v3, write_media_feedback_report_v1,2631 };2732 @@ -70,6 +75,9 @@ struct Args {
7075 /// for compatibility validation.7176 7277 media_v3: bool,78+ /// Exercise the explicit revisioned control/2 and focus-bound input/2 path.79+ 80+ control_v2: bool,7381 /// Request a configured recovery keyframe after three accepted frames,7482 /// then prove no delta history is delivered before the recovery barrier.7583 @@ -655,6 +663,243 @@ impl MoqProbeReceiver {
655663 }656664 }657665 666+async fn run_control_v2_smoke(667+ endpoint: &Endpoint,668+ address: EndpointAddr,669+ nonce: [u8; 16],670+ invitation: Option<&str>,671+ frames: u32,672+ timeout_seconds: u64,673+ expected_size: Option<(u16, u16)>,674+) -> Result<()> {675+ let control_connection = endpoint676+ .connect(address.clone(), CONTROL_ALPN_V2)677+ .await678+ .context("connecting control v2 protocol")?;679+ let (mut control_send, mut control_recv) = control_connection680+ .open_bi()681+ .await682+ .context("opening control v2 stream")?;683+ let mut hello = ClientHelloV2::new("sigil-probe/0.1.0", nonce, vec![Capability::VideoH264]);684+ if let Some(invitation) = invitation {685+ hello = hello.with_invitation(invitation);686+ }687+ write_client_hello_v2(&mut control_send, &hello)688+ .await689+ .context("writing control v2 hello")?;690+ let host = tokio::time::timeout(691+ Duration::from_secs(timeout_seconds),692+ read_host_hello_v2(&mut control_recv),693+ )694+ .await695+ .context("timed out waiting for control v2 hello")??696+ .context("host closed during control v2 hello")?;697+ if !host.accepted {698+ bail!(699+ "host rejected control v2 stream: {}",700+ host.message.as_deref().unwrap_or("unspecified reason")701+ );702+ }703+ let session_id = host704+ .session_id705+ .context("host omitted control v2 session id")?;706+ let initial = host707+ .snapshot708+ .context("host omitted initial control v2 snapshot")?;709+ ensure!(710+ initial.revision > 0 && matches!(initial.focus, FocusStateV2::Vacant { .. }),711+ "initial control v2 snapshot was not a revisioned vacant slot"712+ );713+714+ let mut media = MoqProbeReceiver::connect(715+ endpoint,716+ address.clone(),717+ session_id,718+ Duration::from_secs(timeout_seconds),719+ )720+ .await?;721+ let focus_request = FocusCommandV2 {722+ request_id: 1,723+ action: FocusCommandActionV2::Request,724+ slot: ControllerSlot::ZERO,725+ expected_revision: initial.revision,726+ expected_focus_generation: None,727+ };728+ write_client_control_v2(729+ &mut control_send,730+ &ClientControlEnvelopeV2::Focus {731+ command: focus_request,732+ },733+ )734+ .await735+ .context("requesting control v2 focus")?;736+ let focused = read_v2_snapshot_after(737+ &mut control_recv,738+ initial.revision,739+ timeout_seconds,740+ |snapshot| snapshot.self_focus_generation().is_some(),741+ )742+ .await?;743+ let focus_generation = focused744+ .self_focus_generation()745+ .context("focus grant did not identify the requesting viewer")?;746+747+ let input_connection = endpoint748+ .connect(address, INPUT_ALPN_V2)749+ .await750+ .context("connecting input v2 protocol")?;751+ let (mut input_send, mut input_recv) = input_connection752+ .open_bi()753+ .await754+ .context("opening input v2 stream")?;755+ let input_hello = InputClientHelloV2::new(756+ "sigil-probe/0.1.0",757+ nonce,758+ session_id,759+ ControllerSlot::ZERO,760+ focus_generation,761+ vec![Capability::InputAck],762+ );763+ write_input_client_hello_v2(&mut input_send, &input_hello)764+ .await765+ .context("writing input v2 hello")?;766+ let input_host = tokio::time::timeout(767+ Duration::from_secs(timeout_seconds),768+ read_input_host_hello_v2(&mut input_recv),769+ )770+ .await771+ .context("timed out waiting for input v2 hello")??772+ .context("host closed during input v2 hello")?;773+ ensure!(input_host.accepted, "host rejected focused input v2 stream");774+ ensure!(775+ input_host.session_id == Some(session_id)776+ && input_host.slot == Some(ControllerSlot::ZERO)777+ && input_host.focus_generation == Some(focus_generation),778+ "input v2 host hello changed its focus binding"779+ );780+ write_input_event_v2(781+ &mut input_send,782+ &InputEventV2 {783+ session_id,784+ slot: ControllerSlot::ZERO,785+ focus_generation,786+ event: InputEvent::Probe,787+ },788+ )789+ .await790+ .context("writing slot-0 input v2 probe")?;791+ let input_ack = tokio::time::timeout(792+ Duration::from_secs(timeout_seconds.min(5)),793+ read_input_ack_v2(&mut input_recv),794+ )795+ .await796+ .context("timed out waiting for input v2 acknowledgement")??797+ .context("host closed before input v2 acknowledgement")?;798+ ensure!(799+ input_ack.session_id == session_id800+ && input_ack.slot == ControllerSlot::ZERO801+ && input_ack.focus_generation == focus_generation802+ && input_ack.ack.sequence == 1,803+ "input v2 acknowledgement changed its focus binding"804+ );805+806+ let mut accepted = 0_u32;807+ let mut last_sequence = None;808+ while accepted < frames {809+ let outcome = tokio::time::timeout(Duration::from_secs(timeout_seconds), media.next())810+ .await811+ .context("timed out waiting for control v2 media")??812+ .context("control v2 media ended before requested frames")?;813+ let MoqProbeOutcome::Frame { frame, .. } = outcome else {814+ continue;815+ };816+ if let Some((width, height)) = expected_size {817+ ensure!(818+ frame.header.width == width && frame.header.height == height,819+ "control v2 media dimensions did not match {width}x{height}"820+ );821+ }822+ if let Some(previous) = last_sequence {823+ ensure!(824+ frame.header.sequence > previous,825+ "control v2 media sequence regressed"826+ );827+ }828+ last_sequence = Some(frame.header.sequence);829+ accepted += 1;830+ }831+832+ let release = FocusCommandV2 {833+ request_id: 2,834+ action: FocusCommandActionV2::Release,835+ slot: ControllerSlot::ZERO,836+ expected_revision: focused.revision,837+ expected_focus_generation: Some(focus_generation),838+ };839+ write_client_control_v2(840+ &mut control_send,841+ &ClientControlEnvelopeV2::Focus { command: release },842+ )843+ .await844+ .context("releasing control v2 focus")?;845+ let released = read_v2_snapshot_after(846+ &mut control_recv,847+ focused.revision,848+ timeout_seconds,849+ |snapshot| matches!(snapshot.focus, FocusStateV2::Vacant { .. }),850+ )851+ .await?;852+ input_send.finish().context("finishing input v2 stream")?;853+ input_connection.close(0_u32.into(), b"focus released");854+ control_connection.close(0_u32.into(), b"probe complete");855+856+ println!("probe=ok");857+ println!("transport=iroh-moq");858+ println!("control_alpn=sigil/control/2");859+ println!("input_alpn=sigil/input/2");860+ println!("frames={accepted}");861+ println!("initial_snapshot_revision={}", initial.revision);862+ println!("focus_grant_revision={}", focused.revision);863+ println!("focus_generation={focus_generation}");864+ println!("slot_0_input=ok");865+ println!("focus_release_revision={}", released.revision);866+ println!("focus_release=ok");867+ Ok(())868+}869+870+async fn read_v2_snapshot_after(871+ recv: &mut iroh::endpoint::RecvStream,872+ previous_revision: u64,873+ timeout_seconds: u64,874+ predicate: impl Fn(&sigil_protocol::SessionSnapshotV2) -> bool,875+) -> Result<sigil_protocol::SessionSnapshotV2> {876+ let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds);877+ loop {878+ let envelope = tokio::time::timeout_at(deadline, read_server_control_v2(recv))879+ .await880+ .context("timed out waiting for control v2 state")??881+ .context("host closed before control v2 state arrived")?;882+ match envelope {883+ ServerControlEnvelopeV2::Snapshot { snapshot } => {884+ ensure!(885+ snapshot.revision > previous_revision,886+ "control v2 snapshot revision did not advance"887+ );888+ if predicate(&snapshot) {889+ return Ok(snapshot);890+ }891+ }892+ ServerControlEnvelopeV2::FocusResult { result } => {893+ ensure!(894+ result.accepted,895+ "host rejected control v2 focus command: {}",896+ result.message.as_deref().unwrap_or("unspecified reason")897+ );898+ }899+ }900+ }901+}902+658903 fn classify_moq_probe_group(previous: Option<u64>, current: u64) -> Result<bool> {659904 let Some(previous) = previous else {660905 return Ok(false);@@ -1243,6 +1488,18 @@ async fn main() -> Result<()> {
12431488 .context("binding probe endpoint")?;12441489 let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;12451490 let address = EndpointAddr::new(args.node_id);1491+ if args.control_v2 {1492+ return run_control_v2_smoke(1493+ &endpoint,1494+ address,1495+ nonce,1496+ invitation.as_deref(),1497+ args.frames,1498+ args.timeout_seconds,1499+ args.expect_size,1500+ )1501+ .await;1502+ }12461503 let media_transport = if args.media_v3 {12471504 MediaTransport::GroupedV312481505 } else {@@ -29,8 +29,9 @@ use iroh::protocol::Router;
2929 use iroh::{Endpoint, EndpointId};3030 use moq_net::Origin;3131 use sigil_protocol::{32- AUDIO_ALPN_V1, CONTROL_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS,33- MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,32+ AUDIO_ALPN_V1, CONTROL_ALPN_V1, CONTROL_ALPN_V2, INPUT_ALPN_V1, INPUT_ALPN_V2,33+ InvitationGrants, MAX_INVITATION_TTL_SECS, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,34+ SignedInvitation,3435 };3536 use tracing::{info, warn};3637 use tracing_subscriber::EnvFilter;@@ -42,8 +43,8 @@ use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};
4243 use crate::cursor::PointerPositionTracker;4344 use crate::input::InputBackend;4445 use crate::server::{45- AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,46- MediaV3Handler, SessionRegistry,46+ AudioHandler, AuthorizedMoqHandler, ControlHandler, ControlV2Handler, InputHandler,47+ InputOperations, InputV2Handler, MediaFeedbackHandler, MediaV3Handler, SessionRegistry,4748 };4849 4950 const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);@@ -1001,7 +1002,17 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10011002 }10021003 10031004 let moq_origin = Origin::random();1005+ let input_operations = Arc::new(InputOperations::new(input_backend.clone()));10041006 let router = Router::builder(endpoint)1007+ .accept(1008+ CONTROL_ALPN_V2,1009+ ControlV2Handler {1010+ config: config.clone(),1011+ sessions: Arc::clone(&sessions),1012+ authorization: authorization.clone(),1013+ input_operations: Arc::clone(&input_operations),1014+ },1015+ )10051016 .accept(10061017 CONTROL_ALPN_V1,10071018 ControlHandler {@@ -1030,7 +1041,15 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10301041 MediaFeedbackHandler {10311042 config: config.clone(),10321043 sessions: Arc::clone(&sessions),1033- authorization,1044+ authorization: authorization.clone(),1045+ },1046+ )1047+ .accept(1048+ INPUT_ALPN_V2,1049+ InputV2Handler {1050+ operations: input_operations,1051+ pointer_positions: pointer_positions.clone(),1052+ sessions: Arc::clone(&sessions),10341053 },10351054 )10361055 .accept(@@ -1,4 +1,5 @@
1-use super::session::InputLease;1+use std::sync::Mutex;2+23 use super::*;34 45 pub(super) const MEDIA_CAPABILITIES: &[Capability] = &[Capability::VideoH264];@@ -8,19 +9,19 @@ const INPUT_ACK_TIMEOUT: Duration = Duration::from_secs(1);
89 const REJECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);910 1011 11-struct InputSessionGuard<F>12+struct InputSessionGuard<L, F>1213 where1314 F: FnOnce() -> Result<()>,1415 {15- _lease: InputLease,16+ _lease: L,1617 reset: Option<F>,1718 }1819 19-impl<F> InputSessionGuard<F>20+impl<L, F> InputSessionGuard<L, F>2021 where2122 F: FnOnce() -> Result<()>,2223 {23- fn new(lease: InputLease, reset: F) -> Self {24+ fn new(lease: L, reset: F) -> Self {2425 Self {2526 _lease: lease,2627 reset: Some(reset),@@ -39,7 +40,7 @@ where
3940 }4041 }4142 42-impl<F> Drop for InputSessionGuard<F>43+impl<L, F> Drop for InputSessionGuard<L, F>4344 where4445 F: FnOnce() -> Result<()>,4546 {@@ -64,6 +65,14 @@ pub struct ControlHandler {
6465 pub authorization: AuthorizationPolicy,6566 }6667 68+69+pub struct ControlV2Handler {70+ pub config: HostConfig,71+ pub sessions: Arc<SessionRegistry>,72+ pub authorization: AuthorizationPolicy,73+ pub input_operations: Arc<InputOperations>,74+}75+6776 6877 pub struct MediaFeedbackHandler {6978 pub config: HostConfig,@@ -159,6 +168,66 @@ pub struct InputHandler {
159168 pub sessions: Arc<SessionRegistry>,160169 }161170 171+172+pub struct InputOperations {173+ backend: InputBackend,174+ operation: Mutex<()>,175+}176+177+impl InputOperations {178+ pub fn new(backend: InputBackend) -> Self {179+ Self {180+ backend,181+ operation: Mutex::new(()),182+ }183+ }184+185+ fn capabilities(&self) -> &'static [Capability] {186+ self.backend.capabilities()187+ }188+189+ fn apply_v2(190+ &self,191+ sessions: &SessionRegistry,192+ remote: EndpointId,193+ binding: &InputEventV2,194+ negotiated: &[Capability],195+ ) -> Result<InputDisposition> {196+ let _operation = self197+ .operation198+ .lock()199+ .map_err(|_| anyhow::anyhow!("input operation lock poisoned"))?;200+ ensure!(201+ sessions.is_v2_focus_owner(202+ remote,203+ binding.session_id,204+ binding.slot,205+ binding.focus_generation,206+ ),207+ "input focus changed before backend application"208+ );209+ self.backend.apply(&binding.event, negotiated)210+ }211+212+ pub(super) fn reset(&self) -> Result<()> {213+ let _operation = match self.operation.lock() {214+ Ok(operation) => operation,215+ Err(poisoned) => {216+ warn!("recovering poisoned input operation lock for reset");217+ poisoned.into_inner()218+ }219+ };220+ self.backend.reset_session()221+ }222+}223+224+225+pub struct InputV2Handler {226+ pub operations: Arc<InputOperations>,227+ pub pointer_positions: Option<PointerPositionTracker>,228+ pub sessions: Arc<SessionRegistry>,229+}230+162231 163232 pub struct AudioHandler {164233 pub config: HostConfig,@@ -192,6 +261,41 @@ impl ProtocolHandler for InputHandler {
192261 }193262 }194263 264+impl ProtocolHandler for ControlV2Handler {265+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {266+ let remote = connection.remote_id();267+ if let Err(error) = serve_control_moq_v2(268+ connection,269+ self.config.clone(),270+ &self.sessions,271+ &self.authorization,272+ &self.input_operations,273+ )274+ .await275+ {276+ warn!(%remote, %error, "MoQ control v2 connection ended");277+ }278+ Ok(())279+ }280+}281+282+impl ProtocolHandler for InputV2Handler {283+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {284+ let remote = connection.remote_id();285+ if let Err(error) = serve_input_v2(286+ connection,287+ &self.operations,288+ self.pointer_positions.as_ref(),289+ &self.sessions,290+ )291+ .await292+ {293+ warn!(%remote, %error, "input v2 connection ended");294+ }295+ Ok(())296+ }297+}298+195299 async fn serve_input(196300 connection: Connection,197301 backend: &InputBackend,@@ -366,6 +470,173 @@ async fn serve_input(
366470 result367471 }368472 473+async fn serve_input_v2(474+ connection: Connection,475+ operations: &InputOperations,476+ pointer_positions: Option<&PointerPositionTracker>,477+ sessions: &Arc<SessionRegistry>,478+) -> Result<()> {479+ let remote = connection.remote_id();480+ let handshake_permit = sessions481+ .pending_handshakes482+ .try_acquire()483+ .context("too many pending handshakes")?;484+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())485+ .await486+ .context("timed out accepting input v2 stream")?487+ .context("accepting input v2 stream")?;488+ let hello = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_input_client_hello_v2(&mut recv))489+ .await490+ .context("timed out waiting for input v2 hello")??491+ .context("client closed before input v2 hello")?;492+ drop(handshake_permit);493+ debug!(%remote, agent = %hello.agent, session_id = hello.session_id, focus_generation = hello.focus_generation, "input v2 hello received");494+ let lease = match sessions.claim_input_v2(495+ remote,496+ hello.nonce,497+ hello.session_id,498+ hello.slot,499+ hello.focus_generation,500+ ) {501+ Ok(lease) => lease,502+ Err(error) => {503+ write_input_host_hello_v2(&mut send, &InputHostHelloV2::rejected(error.to_string()))504+ .await?;505+ send.finish()?;506+ return Err(error);507+ }508+ };509+ let supported =510+ supported_input_capabilities(operations.capabilities(), pointer_positions.is_some());511+ let negotiated = negotiated_input_capabilities_v2(&hello, &supported, lease.grants);512+ let ack_enabled = negotiated.contains(&Capability::InputAck);513+ let feedback_enabled = negotiated.contains(&Capability::PointerPositionFeedback);514+ let visibility_feedback_enabled = negotiated.contains(&Capability::PointerVisibilityFeedback);515+ let mut pointer_positions = pointer_positions516+ .filter(|_| feedback_enabled)517+ .map(PointerPositionTracker::subscribe);518+ write_input_host_hello_v2(519+ &mut send,520+ &InputHostHelloV2::accepted(521+ lease.session_id,522+ lease.slot,523+ lease.focus_generation,524+ negotiated.clone(),525+ ),526+ )527+ .await?;528+ info!(%remote, session_id = lease.session_id, focus_generation = lease.focus_generation, "input v2 client accepted");529+530+ let session_id = lease.session_id;531+ let slot = lease.slot;532+ let focus_generation = lease.focus_generation;533+ let guard = InputSessionGuard::new(lease, || operations.reset());534+ let session_result: Result<()> = async {535+ let mut received_events = 0_u64;536+ if let Some(pointer_positions) = pointer_positions.as_ref() {537+ let (pointer_position, pointer_visible) = pointer_feedback_fields(538+ Some(*pointer_positions.borrow()),539+ visibility_feedback_enabled,540+ );541+ write_input_ack_v2(542+ &mut send,543+ &InputAckV2 {544+ session_id,545+ slot,546+ focus_generation,547+ ack: InputAck {548+ sequence: 0,549+ pointer_position,550+ pointer_visible,551+ },552+ },553+ )554+ .await?;555+ }556+ loop {557+ if !sessions.is_v2_focus_owner(remote, session_id, slot, focus_generation) {558+ break;559+ }560+ tokio::select! {561+ _ = sessions.session_changed.notified() => continue,562+ changed = async {563+ pointer_positions564+ .as_mut()565+ .expect("feedback branch is guarded")566+ .changed()567+ .await568+ }, if pointer_positions.is_some() => {569+ changed.context("Xwayland pointer tracker stopped")?;570+ let state = *pointer_positions571+ .as_mut()572+ .expect("feedback branch is guarded")573+ .borrow_and_update();574+ let (pointer_position, pointer_visible) =575+ pointer_feedback_fields(Some(state), visibility_feedback_enabled);576+ write_input_ack_v2(577+ &mut send,578+ &InputAckV2 {579+ session_id,580+ slot,581+ focus_generation,582+ ack: InputAck {583+ sequence: received_events,584+ pointer_position,585+ pointer_visible,586+ },587+ },588+ )589+ .await?;590+ }591+ event = read_input_event_v2(&mut recv) => {592+ let Some(event) = event? else { break; };593+ ensure!(594+ event.session_id == session_id595+ && event.slot == slot596+ && event.focus_generation == focus_generation,597+ "input event changed its accepted focus binding"598+ );599+ let disposition = operations.apply_v2(sessions, remote, &event, &negotiated)?;600+ match disposition {601+ InputDisposition::Probed => debug!(%remote, "input v2 liveness probe acknowledged"),602+ InputDisposition::Observed => info!(%remote, event_type = input_event_type(&event.event), "input v2 event observed"),603+ InputDisposition::Disabled => debug!(%remote, event_type = input_event_type(&event.event), "input v2 event ignored because injection is disabled"),604+ 605+ InputDisposition::Injected => debug!(%remote, event_type = input_event_type(&event.event), "input v2 event injected"),606+ InputDisposition::TextIgnored => debug!(%remote, "input v2 text event ignored"),607+ }608+ received_events = received_events.saturating_add(1);609+ if ack_enabled {610+ let pointer_state = pointer_positions.as_ref().map(|positions| *positions.borrow());611+ let (pointer_position, pointer_visible) =612+ pointer_feedback_fields(pointer_state, visibility_feedback_enabled);613+ write_input_ack_v2(614+ &mut send,615+ &InputAckV2 {616+ session_id,617+ slot,618+ focus_generation,619+ ack: InputAck {620+ sequence: received_events,621+ pointer_position,622+ pointer_visible,623+ },624+ },625+ )626+ .await?;627+ }628+ }629+ }630+ }631+ Ok(())632+ }633+ .await;634+ let reset_result = guard.finish().context("neutralizing input v2 session");635+ let result = session_result.and(reset_result);636+ info!(%remote, session_id, focus_generation, "input v2 client released");637+ result638+}639+369640 async fn serve_audio(370641 connection: Connection,371642 config: HostConfig,@@ -495,6 +766,21 @@ pub(super) async fn send_rejection(
495766 Ok(())496767 }497768 769+pub(super) async fn send_rejection_v2(770+ send: &mut iroh::endpoint::SendStream,771+ message: impl Into<String>,772+) -> Result<()> {773+ write_host_hello_v2(send, &HostHelloV2::rejected(message)).await?;774+ send.finish()?;775+ if tokio::time::timeout(REJECTION_DRAIN_TIMEOUT, send.stopped())776+ .await777+ .is_err()778+ {779+ debug!("timed out waiting for peer to acknowledge control v2 rejection");780+ }781+ Ok(())782+}783+498784 pub(super) fn negotiated_capabilities(499785 hello: &ClientHello,500786 supported: &[Capability],@@ -519,6 +805,23 @@ fn negotiated_input_capabilities(
519805 negotiated520806 }521807 808+fn negotiated_input_capabilities_v2(809+ hello: &InputClientHelloV2,810+ supported: &[Capability],811+ grants: InvitationGrants,812+) -> Vec<Capability> {813+ let mut negotiated = supported814+ .iter()815+ .copied()816+ .filter(|capability| hello.capabilities.contains(capability))817+ .collect::<Vec<_>>();818+ negotiated.retain(|capability| input_capability_authorized(*capability, grants));819+ if !negotiated.contains(&Capability::PointerPositionFeedback) {820+ negotiated.retain(|capability| *capability != Capability::PointerVisibilityFeedback);821+ }822+ negotiated823+}824+522825 fn input_capability_authorized(capability: Capability, grants: InvitationGrants) -> bool {523826 match capability {524827 Capability::AbsolutePointer@@ -12,15 +12,19 @@ use moq_net::{
1212 };1313 use sigil_protocol::{1414 AUDIO_HEADER_LEN, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1,15- AdaptiveBitrateStateV1, AudioFlags, AudioPacket, AudioPacketHeader, Capability, ClientHello,16- FrameFlags, HostHello, InputAck, InvitationGrants, KeyframeRequestReasonV3,17- MAX_AUDIO_PAYLOAD_LEN, MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS,18- MAX_MEDIA_OBJECT_ID_V3, MIN_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MOQ_VIDEO_H264_TRACK,19- MOQ_VIDEO_TRACK_PRIORITY, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1,20- MediaFrame, MediaFrameHeader, MediaObjectHeaderV3, MediaObjectV3, encode_media_frame_object,21- media_moq_broadcast_name, read_client_hello, read_input_event, read_media_control_request_v3,22- read_media_feedback_report_v1, write_adaptive_bitrate_decision_v1, write_host_hello,23- write_input_ack, write_media_object_v3,15+ AdaptiveBitrateStateV1, AudioFlags, AudioPacket, AudioPacketHeader, Capability,16+ ClientControlEnvelopeV2, ClientHello, FocusCommandResultV2, FocusTransitionReasonV2,17+ FrameFlags, HostHello, HostHelloV2, InputAck, InputAckV2, InputClientHelloV2, InputEventV2,18+ InputHostHelloV2, InvitationGrants, KeyframeRequestReasonV3, MAX_AUDIO_PAYLOAD_LEN,19+ MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MAX_MEDIA_OBJECT_ID_V3,20+ MIN_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MOQ_VIDEO_H264_TRACK, MOQ_VIDEO_TRACK_PRIORITY,21+ MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame, MediaFrameHeader,22+ MediaObjectHeaderV3, MediaObjectV3, ServerControlEnvelopeV2, encode_media_frame_object,23+ media_moq_broadcast_name, read_client_control_v2, read_client_hello, read_client_hello_v2,24+ read_input_client_hello_v2, read_input_event, read_input_event_v2,25+ read_media_control_request_v3, read_media_feedback_report_v1,26+ write_adaptive_bitrate_decision_v1, write_host_hello, write_host_hello_v2, write_input_ack,27+ write_input_ack_v2, write_input_host_hello_v2, write_media_object_v3, write_server_control_v2,2428 };2529 use tracing::{debug, error, info, warn};2630 @@ -52,16 +56,17 @@ mod startup;
5256 pub(crate) use adaptive::VideoDimensions;5357 use adaptive::serve_media_feedback;5458 pub use handlers::{55- AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,56- MediaV3Handler,59+ AudioHandler, AuthorizedMoqHandler, ControlHandler, ControlV2Handler, InputHandler,60+ InputOperations, InputV2Handler, MediaFeedbackHandler, MediaV3Handler,5761 };5862 use handlers::{5963 HANDSHAKE_TIMEOUT, MEDIA_CAPABILITIES, negotiated_capabilities, receive_hello, send_rejection,64+ send_rejection_v2,6065 };6166 use media_v3::{6267 MediaV3GroupCursor, forward_media_v3_control_requests, new_current_gop_frames, serve_media_v3,6368 };64-use moq::{MOQ_REJECT_CODE, serve_authorized_moq, serve_control_moq};69+use moq::{MOQ_REJECT_CODE, serve_authorized_moq, serve_control_moq, serve_control_moq_v2};6570 6671 pub use session::SessionRegistry;6772 use session::{@@ -324,7 +324,7 @@ pub(super) async fn serve_control_moq(
324324 &connection,325325 &config,326326 &mut current_gop_receiver,327- recv,327+ MoqControlReader::V1(recv),328328 remote,329329 closed,330330 track,@@ -346,12 +346,258 @@ pub(super) async fn serve_control_moq(
346346 }347347 }348348 349+pub(super) async fn serve_control_moq_v2(350+ connection: Connection,351+ config: HostConfig,352+ sessions: &Arc<SessionRegistry>,353+ authorization: &AuthorizationPolicy,354+ input_operations: &Arc<InputOperations>,355+) -> Result<()> {356+ let remote = connection.remote_id();357+ let handshake_permit = sessions358+ .pending_handshakes359+ .try_acquire()360+ .context("too many pending handshakes")?;361+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())362+ .await363+ .context("timed out accepting MoQ control v2 stream")?364+ .context("accepting MoQ control v2 stream")?;365+ let hello = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello_v2(&mut recv))366+ .await367+ .context("timed out waiting for control v2 hello")??368+ .context("client closed before control v2 hello")?;369+ drop(handshake_permit);370+ ensure!(371+ hello.capabilities.contains(&Capability::VideoH264),372+ "client did not offer required capability VideoH264"373+ );374+ debug!(%remote, agent = %hello.agent, "MoQ control v2 hello received");375+376+ let grants = match authorization.authorize_or_redeem(377+ remote,378+ hello.invitation.as_deref(),379+ unix_timestamp_now()?,380+ ) {381+ Ok(grants) => grants,382+ Err(error) => {383+ send_rejection_v2(&mut send, "Portal peer is not authorized").await?;384+ return Err(error.context("authorizing MoQ control v2 peer"));385+ }386+ };387+ ensure!(388+ grants.contains(InvitationGrants::VIEW),389+ "authorized MoQ control v2 peer lacks view permission"390+ );391+ let lease = match sessions.claim_v2(remote, hello.nonce, grants) {392+ Ok(lease) => lease,393+ Err(error) => {394+ send_rejection_v2(&mut send, "host already has an active client").await?;395+ return Err(error);396+ }397+ };398+399+ let source = match config.source {400+ VideoSource::TestPattern => Ok(spawn_test_pattern(config.clone(), lease.session_clock)),401+ VideoSource::GamescopePipewire => {402+ let primary = spawn_gamescope_pipewire_after_static_preflight(403+ config.clone(),404+ lease.session_clock,405+ )406+ .await?;407+ select_gamescope_startup_source(config.clone(), lease.session_clock, primary).await408+ }409+ };410+ let EncodedSource {411+ frames: frame_receiver,412+ current_gop: mut current_gop_receiver,413+ task: source_task,414+ pointer_surface_dimensions,415+ encoder_control,416+ } = match source {417+ Ok(source) => source,418+ Err(error) => {419+ send_rejection_v2(&mut send, "video source is unavailable").await?;420+ return Err(error);421+ }422+ };423+ let source_task = SourceTaskGuard::new(source_task);424+ sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;425+426+ let mut broadcast = Broadcast::new().produce();427+ let track = broadcast428+ .create_track(Track {429+ name: MOQ_VIDEO_H264_TRACK.to_owned(),430+ priority: MOQ_VIDEO_TRACK_PRIORITY,431+ })432+ .context("creating static MoQ H.264 track for control v2")?;433+ let catalog = publish_goq_catalog(&mut broadcast)?;434+ let broadcast_name = media_moq_broadcast_name(lease.session_id)?;435+ let attachment = sessions.expect_moq(436+ remote,437+ lease.session_id,438+ broadcast_name.clone(),439+ broadcast.consume(),440+ )?;441+442+ let negotiated = MEDIA_CAPABILITIES443+ .iter()444+ .copied()445+ .filter(|capability| hello.capabilities.contains(capability))446+ .collect();447+ let mut control_hello =448+ HostHelloV2::accepted(lease.session_id, negotiated, lease.initial_snapshot.clone());449+ if let Some(dimensions) = pointer_surface_dimensions {450+ control_hello = control_hello.with_pointer_surface_dimensions(dimensions);451+ }452+ write_host_hello_v2(&mut send, &control_hello).await?;453+ info!(454+ %remote,455+ session_id = lease.session_id,456+ %broadcast_name,457+ "MoQ control v2 client accepted; awaiting authorized media attachment"458+ );459+460+ let MoqAttachmentWait {461+ mut attached,462+ closed,463+ } = attachment;464+ tokio::time::timeout(MOQ_ATTACHMENT_TIMEOUT, async {465+ tokio::select! {466+ result = &mut attached => result.context("authorized MoQ handler ended before v2 attachment"),467+ reason = connection.closed() => Err(anyhow::anyhow!("control v2 connection closed before MoQ attachment: {reason:?}")),468+ }469+ })470+ .await471+ .context("timed out waiting for authorized MoQ v2 attachment")??;472+473+ let session_result = run_control_moq_session(474+ &connection,475+ &config,476+ &mut current_gop_receiver,477+ MoqControlReader::V2 {478+ send,479+ recv,480+ sessions: Arc::clone(sessions),481+ input_operations: Arc::clone(input_operations),482+ session_id: lease.session_id,483+ },484+ remote,485+ closed,486+ track,487+ &mut broadcast,488+ encoder_control,489+ Arc::clone(&lease.media_v3_telemetry),490+ )491+ .await;492+ let catalog_result = catalog.finish();493+494+ if sessions.invalidate_v2_focus(495+ remote,496+ lease.session_id,497+ FocusTransitionReasonV2::Disconnected,498+ )? {499+ input_operations.reset()?;500+ }501+ drop(current_gop_receiver);502+ drop(frame_receiver);503+ source_task.wait_or_abort(SOURCE_REAP_GRACE_TIMEOUT).await;504+ drop(lease);505+ info!(%remote, "MoQ control v2 client released");506+ match session_result {507+ Err(error) => Err(error),508+ Ok(()) => catalog_result,509+ }510+}511+512+enum MoqControlReader {513+ V1(iroh::endpoint::RecvStream),514+ V2 {515+ send: iroh::endpoint::SendStream,516+ recv: iroh::endpoint::RecvStream,517+ sessions: Arc<SessionRegistry>,518+ input_operations: Arc<InputOperations>,519+ session_id: u64,520+ },521+}522+523+async fn forward_control_v2_requests(524+ mut send: iroh::endpoint::SendStream,525+ mut recv: iroh::endpoint::RecvStream,526+ sessions: Arc<SessionRegistry>,527+ input_operations: Arc<InputOperations>,528+ remote: EndpointId,529+ session_id: u64,530+ keyframes: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,531+) -> Result<()> {532+ let mut snapshots = sessions.subscribe_v2_snapshots(remote, session_id)?;533+ loop {534+ tokio::select! {535+ changed = snapshots.changed() => {536+ changed.context("v2 session snapshot publisher stopped")?;537+ let Some(snapshot) = snapshots.borrow_and_update().clone() else {538+ break;539+ };540+ write_server_control_v2(541+ &mut send,542+ &ServerControlEnvelopeV2::Snapshot { snapshot },543+ )544+ .await?;545+ }546+ command = read_client_control_v2(&mut recv) => {547+ let Some(command) = command? else { break; };548+ match command {549+ ClientControlEnvelopeV2::Focus { command } => {550+ let request_id = command.request_id;551+ let outcome = sessions.apply_focus_command(remote, session_id, &command);552+ let result = match outcome {553+ Ok(effect) => {554+ if effect.neutralize {555+ input_operations.reset()?;556+ }557+ FocusCommandResultV2 {558+ request_id,559+ accepted: true,560+ revision: effect.snapshot.revision,561+ message: None,562+ }563+ }564+ Err(error) => FocusCommandResultV2 {565+ request_id,566+ accepted: false,567+ revision: sessions.v2_revision(remote, session_id)?,568+ message: Some(error.to_string()),569+ },570+ };571+ write_server_control_v2(572+ &mut send,573+ &ServerControlEnvelopeV2::FocusResult { result },574+ )575+ .await?;576+ }577+ ClientControlEnvelopeV2::Keyframe {578+ request_id,579+ last_sequence,580+ reason,581+ } => {582+ keyframes.send_replace(Some(MediaControlRequestV3::request_keyframe(583+ request_id,584+ last_sequence,585+ reason.into(),586+ )));587+ }588+ }589+ }590+ }591+ }592+ Ok(())593+}594+349595 350596 async fn run_control_moq_session(351597 connection: &Connection,352598 config: &HostConfig,353599 current_gop_receiver: &mut tokio::sync::watch::Receiver<Option<EncodedGop>>,354- control_recv: iroh::endpoint::RecvStream,600+ control_reader: MoqControlReader,355601 remote: EndpointId,356602 mut moq_closed: tokio::sync::oneshot::Receiver<()>,357603 track: TrackProducer,@@ -363,10 +609,27 @@ async fn run_control_moq_session(
363609 let mut replay_cursor = MediaReplayCursor::default();364610 let mut publisher = MoqGroupPublisher::new(track);365611 let (control_sender, mut control_requests) = tokio::sync::watch::channel(None);366- let mut control_task = tokio::spawn(forward_media_v3_control_requests(367- control_recv,368- control_sender,369- ));612+ let mut control_task = match control_reader {613+ MoqControlReader::V1(control_recv) => tokio::spawn(forward_media_v3_control_requests(614+ control_recv,615+ control_sender,616+ )),617+ MoqControlReader::V2 {618+ send,619+ recv,620+ sessions,621+ input_operations,622+ session_id,623+ } => tokio::spawn(forward_control_v2_requests(624+ send,625+ recv,626+ sessions,627+ input_operations,628+ remote,629+ session_id,630+ control_sender,631+ )),632+ };370633 let mut control_task_finished = false;371634 let mut control_receiver_open = true;372635 let mut forced_idr = ForcedIdrCoordinator::new(encoder_control, Arc::clone(&telemetry));@@ -6,7 +6,11 @@ use anyhow::{Context, Result, bail, ensure};
66 use iroh::EndpointId;77 use iroh::endpoint::Connection;88 use moq_net::BroadcastConsumer;9-use sigil_protocol::{InvitationGrants, KeyframeRequestReasonV3};9+use sigil_protocol::{10+ ControllerSlot, FocusCommandActionV2, FocusCommandV2, FocusStateV2, FocusTransitionReasonV2,11+ InvitationGrants, KeyframeRequestReasonV3, MediaGenerationDescriptorV2, SessionSnapshotV2,12+ ViewerPresenceId, ViewerPresenceV2, media_moq_broadcast_name,13+};1014 use tracing::{debug, warn};1115 1216 use super::{ENCODER_CONTROL_COMMIT_TIMEOUT, VideoDimensions};@@ -20,6 +24,9 @@ pub struct SessionRegistry {
2024 active: Mutex<Option<ActiveSession>>,2125 pending_moq: Mutex<Option<PendingMoqAttachment>>,2226 next_session_id: AtomicU64,27+ next_focus_generation: AtomicU64,28+ v2_state: Mutex<Option<V2SessionState>>,29+ v2_snapshot: tokio::sync::watch::Sender<Option<SessionSnapshotV2>>,2330 pub(super) session_changed: tokio::sync::Notify,2431 pub(super) pending_handshakes: tokio::sync::Semaphore,2532 }@@ -72,6 +79,27 @@ struct ActiveSession {
7279 feedback_claimed: bool,7380 media_v3_telemetry: Arc<MediaV3Telemetry>,7481 encoder_control: Option<EncoderControl>,82+ mode: SessionMode,83+}84+85+86+enum SessionMode {87+ LegacyExclusive,88+ V2Single,89+}90+91+92+struct V2SessionState {93+ presence_id: ViewerPresenceId,94+ revision: u64,95+ focus: FocusStateV2,96+ transition_reason: FocusTransitionReasonV2,97+}98+99+100+pub(super) struct FocusCommandEffect {101+ pub(super) snapshot: SessionSnapshotV2,102+ pub(super) neutralize: bool,75103 }76104 77105 @@ -316,10 +344,14 @@ impl MediaV3Telemetry {
316344 317345 impl Default for SessionRegistry {318346 fn default() -> Self {347+ let (v2_snapshot, _receiver) = tokio::sync::watch::channel(None);319348 Self {320349 active: Mutex::new(None),321350 pending_moq: Mutex::new(None),322351 next_session_id: AtomicU64::new(0),352+ next_focus_generation: AtomicU64::new(0),353+ v2_state: Mutex::new(None),354+ v2_snapshot,323355 session_changed: tokio::sync::Notify::new(),324356 pending_handshakes: tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES),325357 }@@ -359,6 +391,7 @@ impl SessionRegistry {
359391 feedback_claimed: false,360392 media_v3_telemetry: Arc::clone(&media_v3_telemetry),361393 encoder_control: None,394+ mode: SessionMode::LegacyExclusive,362395 });363396 Ok(SessionLease {364397 registry: Arc::clone(self),@@ -369,6 +402,59 @@ impl SessionRegistry {
369402 })370403 }371404 405+ pub(super) fn claim_v2(406+ self: &Arc<Self>,407+ remote: EndpointId,408+ nonce: [u8; 16],409+ grants: InvitationGrants,410+ ) -> Result<V2SessionLease> {411+ let mut active = self.active.lock().expect("session registry poisoned");412+ if let Some(current) = active.as_ref() {413+ bail!("host already has active client {}", current.remote);414+ }415+ let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed) + 1;416+ let session_clock = SessionClock::start();417+ let media_v3_telemetry = Arc::new(MediaV3Telemetry::default());418+ let presence_id = ViewerPresenceId::new(format!("viewer-{session_id:016x}"))?;419+ let v2 = V2SessionState {420+ presence_id,421+ revision: 1,422+ focus: FocusStateV2::Vacant {423+ slot: ControllerSlot::ZERO,424+ },425+ transition_reason: FocusTransitionReasonV2::Initial,426+ };427+ let session = ActiveSession {428+ remote,429+ session_id,430+ nonce,431+ session_clock,432+ grants,433+ media_active: true,434+ input_claimed: false,435+ audio_claimed: false,436+ feedback_claimed: false,437+ media_v3_telemetry: Arc::clone(&media_v3_telemetry),438+ encoder_control: None,439+ mode: SessionMode::V2Single,440+ };441+ let snapshot = v2_snapshot_for(&session, &v2)?;442+ *active = Some(session);443+ self.v2_state444+ .lock()445+ .expect("v2 session state poisoned")446+ .replace(v2);447+ self.v2_snapshot.send_replace(Some(snapshot.clone()));448+ Ok(V2SessionLease {449+ registry: Arc::clone(self),450+ remote,451+ session_id,452+ session_clock,453+ media_v3_telemetry,454+ initial_snapshot: snapshot,455+ })456+ }457+372458 pub(super) fn claim_input(373459 self: &Arc<Self>,374460 remote: EndpointId,@@ -378,7 +464,10 @@ impl SessionRegistry {
378464 let session = active379465 .as_mut()380466 .filter(|session| {381- session.media_active && session.remote == remote && session.nonce == nonce467+ session.mode == SessionMode::LegacyExclusive468+ && session.media_active469+ && session.remote == remote470+ && session.nonce == nonce382471 })383472 .context("input connection does not match the active media session")?;384473 ensure!(@@ -394,6 +483,251 @@ impl SessionRegistry {
394483 })395484 }396485 486+ pub(super) fn subscribe_v2_snapshots(487+ &self,488+ remote: EndpointId,489+ session_id: u64,490+ ) -> Result<tokio::sync::watch::Receiver<Option<SessionSnapshotV2>>> {491+ let active = self.active.lock().expect("session registry poisoned");492+ active493+ .as_ref()494+ .filter(|session| {495+ session.mode == SessionMode::V2Single496+ && session.media_active497+ && session.remote == remote498+ && session.session_id == session_id499+ })500+ .context("snapshot subscription does not match the active v2 session")?;501+ Ok(self.v2_snapshot.subscribe())502+ }503+504+ pub(super) fn v2_revision(&self, remote: EndpointId, session_id: u64) -> Result<u64> {505+ let active = self.active.lock().expect("session registry poisoned");506+ active507+ .as_ref()508+ .filter(|session| {509+ session.mode == SessionMode::V2Single510+ && session.media_active511+ && session.remote == remote512+ && session.session_id == session_id513+ })514+ .context("revision request does not match the active v2 session")?;515+ self.v2_state516+ .lock()517+ .expect("v2 session state poisoned")518+ .as_ref()519+ .map(|state| state.revision)520+ .context("active v2 session is missing revision state")521+ }522+523+ pub(super) fn apply_focus_command(524+ &self,525+ remote: EndpointId,526+ session_id: u64,527+ command: &FocusCommandV2,528+ ) -> Result<FocusCommandEffect> {529+ command.validate()?;530+ let active = self.active.lock().expect("session registry poisoned");531+ let session = active532+ .as_ref()533+ .filter(|session| {534+ session.mode == SessionMode::V2Single535+ && session.media_active536+ && session.remote == remote537+ && session.session_id == session_id538+ })539+ .context("focus command does not match the active v2 session")?;540+ let mut v2_state = self.v2_state.lock().expect("v2 session state poisoned");541+ let v2 = v2_state542+ .as_mut()543+ .context("active v2 session is missing focus state")?;544+ ensure!(545+ command.expected_revision == v2.revision,546+ "focus command expected stale revision {} instead of {}",547+ command.expected_revision,548+ v2.revision549+ );550+551+ let neutralize = match command.action {552+ FocusCommandActionV2::Request => {553+ ensure!(554+ session.grants.contains(InvitationGrants::POINTER_KEYBOARD)555+ || session.grants.contains(InvitationGrants::GAMEPAD),556+ "viewer is not input-capable"557+ );558+ ensure!(559+ matches!(v2.focus, FocusStateV2::Vacant { .. }),560+ "controller slot 0 is already occupied"561+ );562+ let focus_generation =563+ self.next_focus_generation.fetch_add(1, Ordering::Relaxed) + 1;564+ v2.focus = FocusStateV2::Held {565+ slot: command.slot,566+ holder: v2.presence_id.clone(),567+ session_id,568+ focus_generation,569+ };570+ v2.transition_reason = FocusTransitionReasonV2::Requested;571+ false572+ }573+ FocusCommandActionV2::Release => {574+ let expected_focus_generation = command575+ .expected_focus_generation576+ .expect("validated focus release carries a generation");577+ ensure!(578+ matches!(579+ &v2.focus,580+ FocusStateV2::Held {581+ holder,582+ session_id: holder_session_id,583+ focus_generation,584+ ..585+ } if holder == &v2.presence_id586+ && *holder_session_id == session_id587+ && *focus_generation == expected_focus_generation588+ ),589+ "focus release does not match the current holder generation"590+ );591+ v2.focus = FocusStateV2::Vacant { slot: command.slot };592+ v2.transition_reason = FocusTransitionReasonV2::Released;593+ true594+ }595+ };596+ v2.revision = v2597+ .revision598+ .checked_add(1)599+ .context("v2 snapshot revision overflowed")?;600+ let snapshot = v2_snapshot_for(session, v2)?;601+ self.v2_snapshot.send_replace(Some(snapshot.clone()));602+ self.session_changed.notify_waiters();603+ Ok(FocusCommandEffect {604+ snapshot,605+ neutralize,606+ })607+ }608+609+ pub(super) fn invalidate_v2_focus(610+ &self,611+ remote: EndpointId,612+ session_id: u64,613+ reason: FocusTransitionReasonV2,614+ ) -> Result<bool> {615+ let active = self.active.lock().expect("session registry poisoned");616+ let session = active617+ .as_ref()618+ .filter(|session| {619+ session.mode == SessionMode::V2Single620+ && session.remote == remote621+ && session.session_id == session_id622+ })623+ .context("focus invalidation does not match the active v2 session")?;624+ let mut v2_state = self.v2_state.lock().expect("v2 session state poisoned");625+ let v2 = v2_state626+ .as_mut()627+ .context("active v2 session is missing focus state")?;628+ if !matches!(v2.focus, FocusStateV2::Held { .. }) {629+ return Ok(false);630+ }631+ v2.focus = FocusStateV2::Vacant {632+ slot: ControllerSlot::ZERO,633+ };634+ v2.transition_reason = reason;635+ v2.revision = v2636+ .revision637+ .checked_add(1)638+ .context("v2 snapshot revision overflowed")?;639+ self.v2_snapshot640+ .send_replace(Some(v2_snapshot_for(session, v2)?));641+ self.session_changed.notify_waiters();642+ Ok(true)643+ }644+645+ pub(super) fn claim_input_v2(646+ self: &Arc<Self>,647+ remote: EndpointId,648+ nonce: [u8; 16],649+ session_id: u64,650+ slot: ControllerSlot,651+ focus_generation: u64,652+ ) -> Result<InputV2Lease> {653+ let mut active = self.active.lock().expect("session registry poisoned");654+ let session = active655+ .as_mut()656+ .filter(|session| {657+ session.mode == SessionMode::V2Single658+ && session.media_active659+ && session.remote == remote660+ && session.nonce == nonce661+ && session.session_id == session_id662+ })663+ .context("input v2 connection does not match the active session")?;664+ let v2_state = self.v2_state.lock().expect("v2 session state poisoned");665+ let v2 = v2_state666+ .as_ref()667+ .context("active v2 session is missing focus state")?;668+ ensure!(669+ matches!(670+ &v2.focus,671+ FocusStateV2::Held {672+ holder,673+ session_id: holder_session_id,674+ slot: holder_slot,675+ focus_generation: holder_generation,676+ } if holder == &v2.presence_id677+ && *holder_session_id == session_id678+ && *holder_slot == slot679+ && *holder_generation == focus_generation680+ ),681+ "input v2 connection does not own the authoritative focus generation"682+ );683+ ensure!(684+ !session.input_claimed,685+ "active viewer already has an input stream"686+ );687+ session.input_claimed = true;688+ Ok(InputV2Lease {689+ registry: Arc::clone(self),690+ remote,691+ session_id,692+ slot,693+ focus_generation,694+ grants: session.grants,695+ })696+ }697+698+ pub(super) fn is_v2_focus_owner(699+ &self,700+ remote: EndpointId,701+ session_id: u64,702+ slot: ControllerSlot,703+ focus_generation: u64,704+ ) -> bool {705+ let active = self.active.lock().expect("session registry poisoned");706+ let Some(session) = active.as_ref().filter(|session| {707+ session.mode == SessionMode::V2Single708+ && session.media_active709+ && session.remote == remote710+ && session.session_id == session_id711+ }) else {712+ return false;713+ };714+ let v2_state = self.v2_state.lock().expect("v2 session state poisoned");715+ v2_state.as_ref().is_some_and(|v2| {716+ matches!(717+ &v2.focus,718+ FocusStateV2::Held {719+ holder,720+ session_id: holder_session_id,721+ slot: holder_slot,722+ focus_generation: holder_generation,723+ } if holder == &v2.presence_id724+ && *holder_session_id == session.session_id725+ && *holder_slot == slot726+ && *holder_generation == focus_generation727+ )728+ })729+ }730+397731 pub(super) fn install_encoder_control(398732 &self,399733 remote: EndpointId,@@ -606,6 +940,10 @@ impl SessionRegistry {
606940 // predecessor session.607941 session.media_active = false;608942 session.encoder_control = None;943+ if session.mode == SessionMode::V2Single {944+ *self.v2_state.lock().expect("v2 session state poisoned") = None;945+ self.v2_snapshot.send_replace(None);946+ }609947 if !session.input_claimed && !session.audio_claimed && !session.feedback_claimed {610948 *active = None;611949 }@@ -664,6 +1002,30 @@ impl SessionRegistry {
6641002 }6651003 }6661004 1005+fn v2_snapshot_for(session: &ActiveSession, v2: &V2SessionState) -> Result<SessionSnapshotV2> {1006+ debug_assert_eq!(session.mode, SessionMode::V2Single);1007+ let input_capable = session.grants.contains(InvitationGrants::POINTER_KEYBOARD)1008+ || session.grants.contains(InvitationGrants::GAMEPAD);1009+ let snapshot = SessionSnapshotV2 {1010+ revision: v2.revision,1011+ self_presence_id: v2.presence_id.clone(),1012+ viewers: vec![ViewerPresenceV2 {1013+ presence_id: v2.presence_id.clone(),1014+ session_id: session.session_id,1015+ input_capable,1016+ you: true,1017+ }],1018+ focus: v2.focus.clone(),1019+ transition_reason: v2.transition_reason,1020+ media: MediaGenerationDescriptorV2 {1021+ generation_id: session.session_id,1022+ broadcast_name: media_moq_broadcast_name(session.session_id)?,1023+ },1024+ };1025+ snapshot.validate()?;1026+ Ok(snapshot)1027+}1028+6671029 6681030 pub(super) struct SessionLease {6691031 registry: Arc<SessionRegistry>,@@ -673,6 +1035,22 @@ pub(super) struct SessionLease {
6731035 pub(super) media_v3_telemetry: Arc<MediaV3Telemetry>,6741036 }6751037 1038+1039+pub(super) struct V2SessionLease {1040+ registry: Arc<SessionRegistry>,1041+ remote: EndpointId,1042+ pub(super) session_id: u64,1043+ pub(super) session_clock: SessionClock,1044+ pub(super) media_v3_telemetry: Arc<MediaV3Telemetry>,1045+ pub(super) initial_snapshot: SessionSnapshotV2,1046+}1047+1048+impl Drop for V2SessionLease {1049+ fn drop(&mut self) {1050+ self.registry.release(self.remote, self.session_id);1051+ }1052+}1053+6761054 impl Drop for SessionLease {6771055 fn drop(&mut self) {6781056 self.registry.release(self.remote, self.session_id);@@ -687,6 +1065,16 @@ pub(super) struct InputLease {
6871065 pub(super) grants: InvitationGrants,6881066 }6891067 1068+1069+pub(super) struct InputV2Lease {1070+ registry: Arc<SessionRegistry>,1071+ remote: EndpointId,1072+ pub(super) session_id: u64,1073+ pub(super) slot: ControllerSlot,1074+ pub(super) focus_generation: u64,1075+ pub(super) grants: InvitationGrants,1076+}1077+6901078 6911079 pub(super) struct AudioLease {6921080 registry: Arc<SessionRegistry>,@@ -748,6 +1136,12 @@ impl Drop for InputLease {
7481136 }7491137 }7501138 1139+impl Drop for InputV2Lease {1140+ fn drop(&mut self) {1141+ self.registry.release_input(self.remote, self.session_id);1142+ }1143+}1144+7511145 impl Drop for AudioLease {7521146 fn drop(&mut self) {7531147 self.registry.release_audio(self.remote, self.session_id);@@ -871,6 +1265,112 @@ mod tests {
8711265 );8721266 }8731267 1268+ 1269+ fn v2_single_viewer_mode_excludes_legacy_and_publishes_revisioned_focus() {1270+ let sessions = Arc::new(SessionRegistry::default());1271+ let remote = endpoint(1);1272+ let nonce = [6; 16];1273+ let media = sessions1274+ .claim_v2(remote, nonce, InvitationGrants::ALL)1275+ .unwrap();1276+ assert_eq!(media.initial_snapshot.revision, 1);1277+ assert!(matches!(1278+ media.initial_snapshot.focus,1279+ FocusStateV2::Vacant { .. }1280+ ));1281+ assert!(1282+ sessions1283+ .claim(endpoint(2), nonce, InvitationGrants::ALL)1284+ .is_err()1285+ );1286+1287+ let request = FocusCommandV2 {1288+ request_id: 1,1289+ action: FocusCommandActionV2::Request,1290+ slot: ControllerSlot::ZERO,1291+ expected_revision: 1,1292+ expected_focus_generation: None,1293+ };1294+ let granted = sessions1295+ .apply_focus_command(remote, media.session_id, &request)1296+ .unwrap();1297+ assert_eq!(granted.snapshot.revision, 2);1298+ assert!(!granted.neutralize);1299+ let focus_generation = granted.snapshot.self_focus_generation().unwrap();1300+ assert!(sessions.is_v2_focus_owner(1301+ remote,1302+ media.session_id,1303+ ControllerSlot::ZERO,1304+ focus_generation1305+ ));1306+ assert!(1307+ sessions1308+ .claim_input_v2(1309+ remote,1310+ nonce,1311+ media.session_id,1312+ ControllerSlot::ZERO,1313+ focus_generation + 1,1314+ )1315+ .is_err()1316+ );1317+ let input = sessions1318+ .claim_input_v2(1319+ remote,1320+ nonce,1321+ media.session_id,1322+ ControllerSlot::ZERO,1323+ focus_generation,1324+ )1325+ .unwrap();1326+1327+ let release = FocusCommandV2 {1328+ request_id: 2,1329+ action: FocusCommandActionV2::Release,1330+ slot: ControllerSlot::ZERO,1331+ expected_revision: 2,1332+ expected_focus_generation: Some(focus_generation),1333+ };1334+ let released = sessions1335+ .apply_focus_command(remote, media.session_id, &release)1336+ .unwrap();1337+ assert_eq!(released.snapshot.revision, 3);1338+ assert!(released.neutralize);1339+ assert!(!sessions.is_v2_focus_owner(1340+ remote,1341+ media.session_id,1342+ ControllerSlot::ZERO,1343+ focus_generation1344+ ));1345+ assert!(1346+ sessions1347+ .apply_focus_command(remote, media.session_id, &release)1348+ .is_err(),1349+ "a stale release must not mutate the newer snapshot revision"1350+ );1351+ drop(input);1352+ drop(media);1353+ }1354+1355+ 1356+ fn legacy_mode_rejects_v2_coexistence() {1357+ let sessions = Arc::new(SessionRegistry::default());1358+ let legacy = sessions1359+ .claim(endpoint(1), [1; 16], InvitationGrants::ALL)1360+ .unwrap();1361+ assert!(1362+ sessions1363+ .claim_v2(endpoint(2), [2; 16], InvitationGrants::ALL)1364+ .is_err()1365+ );1366+ drop(legacy);1367+ assert!(1368+ sessions1369+ .claim_v2(endpoint(2), [2; 16], InvitationGrants::ALL)1370+ .is_ok()1371+ );1372+ }1373+8741374 fn test_moq_broadcast() -> (BroadcastProducer, BroadcastConsumer) {8751375 let producer = Broadcast::new().produce();8761376 let consumer = producer.consume();@@ -0,0 +1,735 @@
1+use std::collections::BTreeSet;2+3+use serde::{Deserialize, Serialize};4+use tokio::io::{AsyncRead, AsyncWrite};5+6+use crate::framing::{read_json, write_json};7+use crate::{8+ Capability, ControllerSlot, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,9+ PROTOCOL_VERSION_V2, PointerSurfaceDimensions, ProtocolError, Result, SignedInvitation,10+};11+12+const MAX_AGENT_LEN: usize = 128;13+const MAX_REJECTION_MESSAGE_LEN: usize = 512;14+const MAX_CAPABILITIES: usize = 16;15+const MAX_PRESENCE_ID_LEN: usize = 32;16+const MAX_BROADCAST_NAME_LEN: usize = 128;17+18+pub const MAX_SESSION_ROSTER_VIEWERS: usize = 8;19+pub const MAX_CONTROL_V2_MESSAGE_LEN: usize = 32 * 1024;20+21+22+23+pub struct ClientHelloV2 {24+ pub version: u16,25+ pub agent: String,26+ pub nonce: [u8; 16],27+ pub capabilities: Vec<Capability>,28+ 29+ pub invitation: Option<String>,30+}31+32+impl ClientHelloV2 {33+ pub fn new(agent: impl Into<String>, nonce: [u8; 16], capabilities: Vec<Capability>) -> Self {34+ Self {35+ version: PROTOCOL_VERSION_V2,36+ agent: agent.into(),37+ nonce,38+ capabilities,39+ invitation: None,40+ }41+ }42+43+ pub fn with_invitation(mut self, invitation: impl Into<String>) -> Self {44+ self.invitation = Some(invitation.into());45+ self46+ }47+48+ pub fn validate(&self) -> Result<()> {49+ validate_version(self.version)?;50+ if self.agent.is_empty() || self.agent.len() > MAX_AGENT_LEN {51+ return invalid("client hello v2", "agent must contain 1..=128 UTF-8 bytes");52+ }53+ validate_capabilities(&self.capabilities)?;54+ if let Some(invitation) = &self.invitation {55+ if invitation.len() > MAX_INVITATION_TOKEN_LEN {56+ return invalid(57+ "client hello v2",58+ "invitation exceeds the bounded token length",59+ );60+ }61+ SignedInvitation::decode(invitation)?;62+ }63+ Ok(())64+ }65+}66+67+68+69+pub struct ViewerPresenceId(String);70+71+impl ViewerPresenceId {72+ pub fn new(value: impl Into<String>) -> Result<Self> {73+ let value = value.into();74+ if value.is_empty()75+ || value.len() > MAX_PRESENCE_ID_LEN76+ || !value77+ .bytes()78+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')79+ {80+ return invalid(81+ "viewer presence id",82+ "presence id must contain 1..=32 ASCII letters, digits, or hyphens",83+ );84+ }85+ Ok(Self(value))86+ }87+88+ pub fn as_str(&self) -> &str {89+ &self.090+ }91+92+ pub fn validate(&self) -> Result<()> {93+ Self::new(self.0.clone()).map(|_| ())94+ }95+}96+97+98+99+pub struct ViewerPresenceV2 {100+ pub presence_id: ViewerPresenceId,101+ pub session_id: u64,102+ pub input_capable: bool,103+ pub you: bool,104+}105+106+impl ViewerPresenceV2 {107+ pub fn validate(&self) -> Result<()> {108+ self.presence_id.validate()?;109+ if self.session_id == 0 {110+ return invalid("viewer presence", "session id must be non-zero");111+ }112+ Ok(())113+ }114+}115+116+117+118+pub enum FocusTransitionReasonV2 {119+ Initial,120+ Requested,121+ Released,122+ Disconnected,123+ Replaced,124+ Revoked,125+}126+127+128+129+pub enum FocusStateV2 {130+ Vacant {131+ slot: ControllerSlot,132+ },133+ Held {134+ slot: ControllerSlot,135+ holder: ViewerPresenceId,136+ session_id: u64,137+ focus_generation: u64,138+ },139+ Neutralizing {140+ slot: ControllerSlot,141+ former_holder: ViewerPresenceId,142+ former_session_id: u64,143+ former_focus_generation: u64,144+ transition_id: u64,145+ },146+}147+148+impl FocusStateV2 {149+ pub fn slot(&self) -> ControllerSlot {150+ match self {151+ Self::Vacant { slot } | Self::Held { slot, .. } | Self::Neutralizing { slot, .. } => {152+ *slot153+ }154+ }155+ }156+157+ pub fn validate(&self) -> Result<()> {158+ self.slot().validate()?;159+ match self {160+ Self::Vacant { .. } => Ok(()),161+ Self::Held {162+ holder,163+ session_id,164+ focus_generation,165+ ..166+ } => {167+ holder.validate()?;168+ if *session_id == 0 || *focus_generation == 0 {169+ return invalid(170+ "focus state",171+ "held focus requires non-zero session and focus generations",172+ );173+ }174+ Ok(())175+ }176+ Self::Neutralizing {177+ former_holder,178+ former_session_id,179+ former_focus_generation,180+ transition_id,181+ ..182+ } => {183+ former_holder.validate()?;184+ if *former_session_id == 0 || *former_focus_generation == 0 || *transition_id == 0 {185+ return invalid(186+ "focus state",187+ "neutralizing focus requires non-zero generations and transition id",188+ );189+ }190+ Ok(())191+ }192+ }193+ }194+}195+196+197+198+pub struct MediaGenerationDescriptorV2 {199+ pub generation_id: u64,200+ pub broadcast_name: String,201+}202+203+impl MediaGenerationDescriptorV2 {204+ pub fn validate(&self) -> Result<()> {205+ if self.generation_id == 0 {206+ return invalid("media generation", "generation id must be non-zero");207+ }208+ if self.broadcast_name.is_empty()209+ || self.broadcast_name.len() > MAX_BROADCAST_NAME_LEN210+ || !self.broadcast_name.is_ascii()211+ {212+ return invalid(213+ "media generation",214+ "broadcast name must contain 1..=128 ASCII bytes",215+ );216+ }217+ Ok(())218+ }219+}220+221+222+223+pub struct SessionSnapshotV2 {224+ pub revision: u64,225+ pub self_presence_id: ViewerPresenceId,226+ pub viewers: Vec<ViewerPresenceV2>,227+ pub focus: FocusStateV2,228+ pub transition_reason: FocusTransitionReasonV2,229+ pub media: MediaGenerationDescriptorV2,230+}231+232+impl SessionSnapshotV2 {233+ pub fn validate(&self) -> Result<()> {234+ if self.revision == 0 {235+ return invalid("session snapshot", "revision must be non-zero");236+ }237+ self.self_presence_id.validate()?;238+ if self.viewers.is_empty() || self.viewers.len() > MAX_SESSION_ROSTER_VIEWERS {239+ return invalid("session snapshot", "roster must contain 1..=8 viewers");240+ }241+ let mut identities = BTreeSet::new();242+ let mut self_entries = 0;243+ for viewer in &self.viewers {244+ viewer.validate()?;245+ if !identities.insert(viewer.presence_id.clone()) {246+ return invalid("session snapshot", "roster contains a duplicate identity");247+ }248+ if viewer.you {249+ self_entries += 1;250+ if viewer.presence_id != self.self_presence_id {251+ return invalid(252+ "session snapshot",253+ "the you marker does not match self identity",254+ );255+ }256+ }257+ }258+ if self_entries != 1 || !identities.contains(&self.self_presence_id) {259+ return invalid(260+ "session snapshot",261+ "roster requires exactly one matching you entry",262+ );263+ }264+ self.focus.validate()?;265+ if let FocusStateV2::Held {266+ holder, session_id, ..267+ } = &self.focus268+ && !self269+ .viewers270+ .iter()271+ .any(|viewer| &viewer.presence_id == holder && viewer.session_id == *session_id)272+ {273+ return invalid("session snapshot", "focus holder is absent from the roster");274+ }275+ self.media.validate()276+ }277+278+ pub fn self_viewer(&self) -> &ViewerPresenceV2 {279+ self.viewers280+ .iter()281+ .find(|viewer| viewer.presence_id == self.self_presence_id)282+ .expect("validated snapshots contain the self viewer")283+ }284+285+ pub fn self_focus_generation(&self) -> Option<u64> {286+ match &self.focus {287+ FocusStateV2::Held {288+ holder,289+ session_id,290+ focus_generation,291+ ..292+ } if holder == &self.self_presence_id293+ && *session_id == self.self_viewer().session_id =>294+ {295+ Some(*focus_generation)296+ }297+ _ => None,298+ }299+ }300+}301+302+303+304+pub enum FocusCommandActionV2 {305+ Request,306+ Release,307+}308+309+310+311+pub struct FocusCommandV2 {312+ pub request_id: u64,313+ pub action: FocusCommandActionV2,314+ pub slot: ControllerSlot,315+ pub expected_revision: u64,316+ 317+ pub expected_focus_generation: Option<u64>,318+}319+320+impl FocusCommandV2 {321+ pub fn validate(&self) -> Result<()> {322+ if self.request_id == 0 || self.expected_revision == 0 {323+ return invalid(324+ "focus command",325+ "request id and expected revision must be non-zero",326+ );327+ }328+ self.slot.validate()?;329+ match self.action {330+ FocusCommandActionV2::Request if self.expected_focus_generation.is_some() => invalid(331+ "focus command",332+ "focus requests must not carry an expected focus generation",333+ ),334+ FocusCommandActionV2::Release335+ if self336+ .expected_focus_generation337+ .is_none_or(|generation| generation == 0) =>338+ {339+ invalid(340+ "focus command",341+ "focus release requires a non-zero expected focus generation",342+ )343+ }344+ _ => Ok(()),345+ }346+ }347+}348+349+350+351+pub struct FocusCommandResultV2 {352+ pub request_id: u64,353+ pub accepted: bool,354+ pub revision: u64,355+ 356+ pub message: Option<String>,357+}358+359+impl FocusCommandResultV2 {360+ pub fn validate(&self) -> Result<()> {361+ if self.request_id == 0 || self.revision == 0 {362+ return invalid(363+ "focus command result",364+ "request id and revision must be non-zero",365+ );366+ }367+ if let Some(message) = &self.message368+ && (message.is_empty() || message.len() > MAX_REJECTION_MESSAGE_LEN)369+ {370+ return invalid(371+ "focus command result",372+ "message must contain 1..=512 UTF-8 bytes",373+ );374+ }375+ if self.accepted && self.message.is_some() {376+ return invalid(377+ "focus command result",378+ "accepted results must not carry a message",379+ );380+ }381+ if !self.accepted && self.message.is_none() {382+ return invalid("focus command result", "rejected results require a message");383+ }384+ Ok(())385+ }386+}387+388+389+390+pub enum ClientControlEnvelopeV2 {391+ Focus {392+ command: FocusCommandV2,393+ },394+ Keyframe {395+ request_id: u64,396+ 397+ last_sequence: Option<u64>,398+ reason: ControlKeyframeReasonV2,399+ },400+}401+402+403+404+pub enum ControlKeyframeReasonV2 {405+ Join,406+ DecoderReset,407+ TransportGap,408+ DeliveryTimeout,409+ FrontendBackpressure,410+}411+412+impl From<ControlKeyframeReasonV2> for KeyframeRequestReasonV3 {413+ fn from(value: ControlKeyframeReasonV2) -> Self {414+ match value {415+ ControlKeyframeReasonV2::Join => Self::Join,416+ ControlKeyframeReasonV2::DecoderReset => Self::DecoderReset,417+ ControlKeyframeReasonV2::TransportGap => Self::TransportGap,418+ ControlKeyframeReasonV2::DeliveryTimeout => Self::DeliveryTimeout,419+ ControlKeyframeReasonV2::FrontendBackpressure => Self::FrontendBackpressure,420+ }421+ }422+}423+424+impl From<KeyframeRequestReasonV3> for ControlKeyframeReasonV2 {425+ fn from(value: KeyframeRequestReasonV3) -> Self {426+ match value {427+ KeyframeRequestReasonV3::Join => Self::Join,428+ KeyframeRequestReasonV3::DecoderReset => Self::DecoderReset,429+ KeyframeRequestReasonV3::TransportGap => Self::TransportGap,430+ KeyframeRequestReasonV3::DeliveryTimeout => Self::DeliveryTimeout,431+ KeyframeRequestReasonV3::FrontendBackpressure => Self::FrontendBackpressure,432+ }433+ }434+}435+436+impl ClientControlEnvelopeV2 {437+ pub fn validate(&self) -> Result<()> {438+ match self {439+ Self::Focus { command } => command.validate(),440+ Self::Keyframe { request_id, .. } if *request_id == 0 => {441+ invalid("client control envelope v2", "request id must be non-zero")442+ }443+ Self::Keyframe { .. } => Ok(()),444+ }445+ }446+}447+448+449+450+pub enum ServerControlEnvelopeV2 {451+ Snapshot { snapshot: SessionSnapshotV2 },452+ FocusResult { result: FocusCommandResultV2 },453+}454+455+impl ServerControlEnvelopeV2 {456+ pub fn validate(&self) -> Result<()> {457+ match self {458+ Self::Snapshot { snapshot } => snapshot.validate(),459+ Self::FocusResult { result } => result.validate(),460+ }461+ }462+}463+464+465+466+pub struct HostHelloV2 {467+ pub version: u16,468+ pub accepted: bool,469+ pub session_id: Option<u64>,470+ pub capabilities: Vec<Capability>,471+ pub message: Option<String>,472+ 473+ pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,474+ 475+ pub snapshot: Option<SessionSnapshotV2>,476+}477+478+impl HostHelloV2 {479+ pub fn accepted(480+ session_id: u64,481+ capabilities: Vec<Capability>,482+ snapshot: SessionSnapshotV2,483+ ) -> Self {484+ Self {485+ version: PROTOCOL_VERSION_V2,486+ accepted: true,487+ session_id: Some(session_id),488+ capabilities,489+ message: None,490+ pointer_surface_dimensions: None,491+ snapshot: Some(snapshot),492+ }493+ }494+495+ pub fn rejected(message: impl Into<String>) -> Self {496+ Self {497+ version: PROTOCOL_VERSION_V2,498+ accepted: false,499+ session_id: None,500+ capabilities: Vec::new(),501+ message: Some(message.into()),502+ pointer_surface_dimensions: None,503+ snapshot: None,504+ }505+ }506+507+ pub fn with_pointer_surface_dimensions(mut self, dimensions: PointerSurfaceDimensions) -> Self {508+ self.pointer_surface_dimensions = Some(dimensions);509+ self510+ }511+512+ pub fn validate(&self) -> Result<()> {513+ validate_version(self.version)?;514+ validate_capabilities(&self.capabilities)?;515+ if self.accepted {516+ let session_id = self.session_id.filter(|id| *id != 0).ok_or_else(|| {517+ ProtocolError::InvalidMessage {518+ message_type: "host hello v2",519+ reason: "accepted responses require a non-zero session id",520+ }521+ })?;522+ if self.message.is_some() {523+ return invalid("host hello v2", "accepted responses forbid a message");524+ }525+ let snapshot = self526+ .snapshot527+ .as_ref()528+ .ok_or(ProtocolError::InvalidMessage {529+ message_type: "host hello v2",530+ reason: "accepted responses require an initial snapshot",531+ })?;532+ snapshot.validate()?;533+ if snapshot.self_viewer().session_id != session_id {534+ return invalid(535+ "host hello v2",536+ "snapshot session does not match host hello",537+ );538+ }539+ } else if self.session_id.is_some()540+ || self.snapshot.is_some()541+ || self.message.as_ref().is_none_or(|message| {542+ message.is_empty() || message.len() > MAX_REJECTION_MESSAGE_LEN543+ })544+ {545+ return invalid(546+ "host hello v2",547+ "rejections require a bounded message and forbid session state",548+ );549+ }550+ if let Some(dimensions) = self.pointer_surface_dimensions {551+ dimensions.validate()?;552+ if !self.accepted || !self.capabilities.contains(&Capability::VideoH264) {553+ return invalid(554+ "host hello v2",555+ "pointer dimensions require accepted H.264 media",556+ );557+ }558+ }559+ Ok(())560+ }561+}562+563+macro_rules! json_io {564+ ($read:ident, $write:ident, $ty:ty) => {565+ pub async fn $read<R>(reader: &mut R) -> Result<Option<$ty>>566+ where567+ R: AsyncRead + Unpin,568+ {569+ let value: Option<$ty> = read_json(reader, MAX_CONTROL_V2_MESSAGE_LEN).await?;570+ if let Some(value) = &value {571+ value.validate()?;572+ }573+ Ok(value)574+ }575+576+ pub async fn $write<W>(writer: &mut W, value: &$ty) -> Result<()>577+ where578+ W: AsyncWrite + Unpin,579+ {580+ value.validate()?;581+ write_json(writer, value, MAX_CONTROL_V2_MESSAGE_LEN).await582+ }583+ };584+}585+586+json_io!(read_client_hello_v2, write_client_hello_v2, ClientHelloV2);587+json_io!(read_host_hello_v2, write_host_hello_v2, HostHelloV2);588+json_io!(589+ read_client_control_v2,590+ write_client_control_v2,591+ ClientControlEnvelopeV2592+);593+json_io!(594+ read_server_control_v2,595+ write_server_control_v2,596+ ServerControlEnvelopeV2597+);598+599+fn validate_version(version: u16) -> Result<()> {600+ if version != PROTOCOL_VERSION_V2 {601+ return Err(ProtocolError::UnsupportedVersion {602+ expected: PROTOCOL_VERSION_V2,603+ actual: version,604+ });605+ }606+ Ok(())607+}608+609+fn validate_capabilities(capabilities: &[Capability]) -> Result<()> {610+ if capabilities.len() > MAX_CAPABILITIES {611+ return invalid("hello v2", "too many capabilities");612+ }613+ for (index, capability) in capabilities.iter().enumerate() {614+ if capabilities[..index].contains(capability) {615+ return invalid("hello v2", "duplicate capability");616+ }617+ }618+ Ok(())619+}620+621+fn invalid<T>(message_type: &'static str, reason: &'static str) -> Result<T> {622+ Err(ProtocolError::InvalidMessage {623+ message_type,624+ reason,625+ })626+}627+628+629+mod tests {630+ use tokio::io::{AsyncWriteExt, duplex};631+632+ use super::*;633+ use crate::media_moq_broadcast_name;634+635+ fn snapshot(revision: u64) -> SessionSnapshotV2 {636+ let presence = ViewerPresenceId::new("viewer-0001").unwrap();637+ SessionSnapshotV2 {638+ revision,639+ self_presence_id: presence.clone(),640+ viewers: vec![ViewerPresenceV2 {641+ presence_id: presence,642+ session_id: 7,643+ input_capable: true,644+ you: true,645+ }],646+ focus: FocusStateV2::Vacant {647+ slot: ControllerSlot::ZERO,648+ },649+ transition_reason: FocusTransitionReasonV2::Initial,650+ media: MediaGenerationDescriptorV2 {651+ generation_id: 7,652+ broadcast_name: media_moq_broadcast_name(7).unwrap(),653+ },654+ }655+ }656+657+ 658+ fn control_v2_snapshot_json_is_a_golden_vector() {659+ assert_eq!(660+ serde_json::to_string(&snapshot(1)).unwrap(),661+ r#"{"revision":1,"self_presence_id":"viewer-0001","viewers":[{"presence_id":"viewer-0001","session_id":7,"input_capable":true,"you":true}],"focus":{"state":"vacant","slot":0},"transition_reason":"initial","media":{"generation_id":7,"broadcast_name":"sigil/session/7/video"}}"#662+ );663+ }664+665+ 666+ fn snapshots_reject_zero_revision_duplicate_identity_and_unsupported_slot() {667+ assert!(snapshot(0).validate().is_err());668+ let mut duplicate = snapshot(1);669+ duplicate.viewers.push(duplicate.viewers[0].clone());670+ assert!(duplicate.validate().is_err());671+ assert!(serde_json::from_str::<ControllerSlot>("1").is_err());672+ }673+674+ 675+ fn focus_commands_are_revision_and_generation_bound() {676+ let request = FocusCommandV2 {677+ request_id: 1,678+ action: FocusCommandActionV2::Request,679+ slot: ControllerSlot::ZERO,680+ expected_revision: 1,681+ expected_focus_generation: None,682+ };683+ request.validate().unwrap();684+ let mut release = request.clone();685+ release.action = FocusCommandActionV2::Release;686+ assert!(release.validate().is_err());687+ release.expected_focus_generation = Some(2);688+ release.validate().unwrap();689+ }690+691+ 692+ async fn v2_hello_and_envelopes_round_trip_with_clean_eof() {693+ let hello = ClientHelloV2::new("portal/0.1.0", [4; 16], vec![Capability::VideoH264]);694+ let host = HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1));695+ let command = ClientControlEnvelopeV2::Focus {696+ command: FocusCommandV2 {697+ request_id: 2,698+ action: FocusCommandActionV2::Request,699+ slot: ControllerSlot::ZERO,700+ expected_revision: 1,701+ expected_focus_generation: None,702+ },703+ };704+ let (mut sender, mut receiver) = duplex(4096);705+ write_client_hello_v2(&mut sender, &hello).await.unwrap();706+ write_host_hello_v2(&mut sender, &host).await.unwrap();707+ write_client_control_v2(&mut sender, &command)708+ .await709+ .unwrap();710+ sender.shutdown().await.unwrap();711+ assert_eq!(712+ read_client_hello_v2(&mut receiver).await.unwrap(),713+ Some(hello)714+ );715+ assert_eq!(read_host_hello_v2(&mut receiver).await.unwrap(), Some(host));716+ assert_eq!(717+ read_client_control_v2(&mut receiver).await.unwrap(),718+ Some(command)719+ );720+ assert_eq!(read_client_control_v2(&mut receiver).await.unwrap(), None);721+ }722+723+ 724+ async fn peer_controlled_v2_length_is_bounded_before_allocation() {725+ let (mut sender, mut receiver) = duplex(16);726+ sender727+ .write_all(&((MAX_CONTROL_V2_MESSAGE_LEN as u32) + 1).to_be_bytes())728+ .await729+ .unwrap();730+ assert!(matches!(731+ read_client_control_v2(&mut receiver).await,732+ Err(ProtocolError::InvalidMessageLength { .. })733+ ));734+ }735+}@@ -0,0 +1,376 @@
1+use serde::{Deserialize, Deserializer, Serialize, Serializer};2+use tokio::io::{AsyncRead, AsyncWrite};3+4+use crate::framing::{read_json, write_json};5+use crate::{6+ Capability, InputAck, InputEvent, MAX_INPUT_MESSAGE_LEN, PROTOCOL_VERSION_V2, ProtocolError,7+ Result,8+};9+10+const MAX_AGENT_LEN: usize = 128;11+const MAX_MESSAGE_LEN: usize = 512;12+const MAX_CAPABILITIES: usize = 16;13+14+15+pub struct ControllerSlot(u8);16+17+impl ControllerSlot {18+ pub const ZERO: Self = Self(0);19+20+ pub fn new(value: u8) -> Result<Self> {21+ if value != 0 {22+ return Err(ProtocolError::InvalidMessage {23+ message_type: "controller slot",24+ reason: "only controller slot 0 is supported",25+ });26+ }27+ Ok(Self(value))28+ }29+30+ pub const fn get(self) -> u8 {31+ self.032+ }33+34+ pub fn validate(self) -> Result<()> {35+ Self::new(self.0).map(|_| ())36+ }37+}38+39+impl Serialize for ControllerSlot {40+ fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>41+ where42+ S: Serializer,43+ {44+ serializer.serialize_u8(self.0)45+ }46+}47+48+impl<'de> Deserialize<'de> for ControllerSlot {49+ fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>50+ where51+ D: Deserializer<'de>,52+ {53+ let value = u8::deserialize(deserializer)?;54+ Self::new(value).map_err(serde::de::Error::custom)55+ }56+}57+58+59+60+pub struct InputClientHelloV2 {61+ pub version: u16,62+ pub agent: String,63+ pub nonce: [u8; 16],64+ pub session_id: u64,65+ pub slot: ControllerSlot,66+ pub focus_generation: u64,67+ pub capabilities: Vec<Capability>,68+}69+70+impl InputClientHelloV2 {71+ pub fn new(72+ agent: impl Into<String>,73+ nonce: [u8; 16],74+ session_id: u64,75+ slot: ControllerSlot,76+ focus_generation: u64,77+ capabilities: Vec<Capability>,78+ ) -> Self {79+ Self {80+ version: PROTOCOL_VERSION_V2,81+ agent: agent.into(),82+ nonce,83+ session_id,84+ slot,85+ focus_generation,86+ capabilities,87+ }88+ }89+90+ pub fn validate(&self) -> Result<()> {91+ validate_version(self.version)?;92+ if self.agent.is_empty() || self.agent.len() > MAX_AGENT_LEN {93+ return invalid(94+ "input client hello v2",95+ "agent must contain 1..=128 UTF-8 bytes",96+ );97+ }98+ validate_binding(self.session_id, self.slot, self.focus_generation)?;99+ validate_capabilities(&self.capabilities)100+ }101+}102+103+104+105+pub struct InputHostHelloV2 {106+ pub version: u16,107+ pub accepted: bool,108+ pub session_id: Option<u64>,109+ pub slot: Option<ControllerSlot>,110+ pub focus_generation: Option<u64>,111+ pub capabilities: Vec<Capability>,112+ pub message: Option<String>,113+}114+115+impl InputHostHelloV2 {116+ pub fn accepted(117+ session_id: u64,118+ slot: ControllerSlot,119+ focus_generation: u64,120+ capabilities: Vec<Capability>,121+ ) -> Self {122+ Self {123+ version: PROTOCOL_VERSION_V2,124+ accepted: true,125+ session_id: Some(session_id),126+ slot: Some(slot),127+ focus_generation: Some(focus_generation),128+ capabilities,129+ message: None,130+ }131+ }132+133+ pub fn rejected(message: impl Into<String>) -> Self {134+ Self {135+ version: PROTOCOL_VERSION_V2,136+ accepted: false,137+ session_id: None,138+ slot: None,139+ focus_generation: None,140+ capabilities: Vec::new(),141+ message: Some(message.into()),142+ }143+ }144+145+ pub fn validate(&self) -> Result<()> {146+ validate_version(self.version)?;147+ validate_capabilities(&self.capabilities)?;148+ if self.accepted {149+ let session_id = self.session_id.unwrap_or(0);150+ let focus_generation = self.focus_generation.unwrap_or(0);151+ let slot = self.slot.ok_or(ProtocolError::InvalidMessage {152+ message_type: "input host hello v2",153+ reason: "accepted responses require a controller slot",154+ })?;155+ validate_binding(session_id, slot, focus_generation)?;156+ if self.message.is_some() {157+ return invalid("input host hello v2", "accepted responses forbid a message");158+ }159+ } else if self.session_id.is_some()160+ || self.slot.is_some()161+ || self.focus_generation.is_some()162+ || self163+ .message164+ .as_ref()165+ .is_none_or(|message| message.is_empty() || message.len() > MAX_MESSAGE_LEN)166+ {167+ return invalid(168+ "input host hello v2",169+ "rejections require a bounded message and forbid focus binding",170+ );171+ }172+ Ok(())173+ }174+}175+176+177+178+pub struct InputEventV2 {179+ pub session_id: u64,180+ pub slot: ControllerSlot,181+ pub focus_generation: u64,182+ pub event: InputEvent,183+}184+185+impl InputEventV2 {186+ pub fn validate(&self) -> Result<()> {187+ validate_binding(self.session_id, self.slot, self.focus_generation)?;188+ self.event.validate()189+ }190+}191+192+193+194+pub struct InputAckV2 {195+ pub session_id: u64,196+ pub slot: ControllerSlot,197+ pub focus_generation: u64,198+ pub ack: InputAck,199+}200+201+impl InputAckV2 {202+ pub fn validate(&self) -> Result<()> {203+ validate_binding(self.session_id, self.slot, self.focus_generation)?;204+ self.ack.validate()205+ }206+}207+208+macro_rules! json_io {209+ ($read:ident, $write:ident, $ty:ty) => {210+ pub async fn $read<R>(reader: &mut R) -> Result<Option<$ty>>211+ where212+ R: AsyncRead + Unpin,213+ {214+ let value: Option<$ty> = read_json(reader, MAX_INPUT_MESSAGE_LEN).await?;215+ if let Some(value) = &value {216+ value.validate()?;217+ }218+ Ok(value)219+ }220+221+ pub async fn $write<W>(writer: &mut W, value: &$ty) -> Result<()>222+ where223+ W: AsyncWrite + Unpin,224+ {225+ value.validate()?;226+ write_json(writer, value, MAX_INPUT_MESSAGE_LEN).await227+ }228+ };229+}230+231+json_io!(232+ read_input_client_hello_v2,233+ write_input_client_hello_v2,234+ InputClientHelloV2235+);236+json_io!(237+ read_input_host_hello_v2,238+ write_input_host_hello_v2,239+ InputHostHelloV2240+);241+json_io!(read_input_event_v2, write_input_event_v2, InputEventV2);242+json_io!(read_input_ack_v2, write_input_ack_v2, InputAckV2);243+244+fn validate_binding(session_id: u64, slot: ControllerSlot, focus_generation: u64) -> Result<()> {245+ if session_id == 0 || focus_generation == 0 {246+ return invalid(247+ "input v2 focus binding",248+ "session and focus generations must be non-zero",249+ );250+ }251+ slot.validate()252+}253+254+fn validate_version(version: u16) -> Result<()> {255+ if version != PROTOCOL_VERSION_V2 {256+ return Err(ProtocolError::UnsupportedVersion {257+ expected: PROTOCOL_VERSION_V2,258+ actual: version,259+ });260+ }261+ Ok(())262+}263+264+fn validate_capabilities(capabilities: &[Capability]) -> Result<()> {265+ if capabilities.len() > MAX_CAPABILITIES {266+ return invalid("input hello v2", "too many capabilities");267+ }268+ for (index, capability) in capabilities.iter().enumerate() {269+ if capabilities[..index].contains(capability) {270+ return invalid("input hello v2", "duplicate capability");271+ }272+ if matches!(capability, Capability::VideoH264 | Capability::AudioOpus) {273+ return invalid("input hello v2", "media capability is invalid on input v2");274+ }275+ }276+ Ok(())277+}278+279+fn invalid<T>(message_type: &'static str, reason: &'static str) -> Result<T> {280+ Err(ProtocolError::InvalidMessage {281+ message_type,282+ reason,283+ })284+}285+286+287+mod tests {288+ use tokio::io::{AsyncWriteExt, duplex};289+290+ use super::*;291+ use crate::GamepadState;292+293+ 294+ fn controller_slot_accepts_only_slot_zero() {295+ assert_eq!(ControllerSlot::new(0).unwrap(), ControllerSlot::ZERO);296+ assert!(ControllerSlot::new(1).is_err());297+ assert!(serde_json::from_str::<ControllerSlot>("1").is_err());298+ }299+300+ 301+ fn input_v2_gamepad_json_carries_slot_and_focus_generation() {302+ let event = InputEventV2 {303+ session_id: 7,304+ slot: ControllerSlot::ZERO,305+ focus_generation: 9,306+ event: InputEvent::Gamepad {307+ state: GamepadState::default(),308+ },309+ };310+ let json = serde_json::to_string(&event).unwrap();311+ assert!(312+ json.starts_with(r#"{"session_id":7,"slot":0,"focus_generation":9,"event":{"t":"gp""#)313+ );314+ event.validate().unwrap();315+ }316+317+ 318+ async fn input_v2_hello_event_and_ack_round_trip() {319+ let hello = InputClientHelloV2::new(320+ "portal/0.1.0",321+ [3; 16],322+ 7,323+ ControllerSlot::ZERO,324+ 9,325+ vec![Capability::Gamepad, Capability::InputAck],326+ );327+ let event = InputEventV2 {328+ session_id: 7,329+ slot: ControllerSlot::ZERO,330+ focus_generation: 9,331+ event: InputEvent::Probe,332+ };333+ let ack = InputAckV2 {334+ session_id: 7,335+ slot: ControllerSlot::ZERO,336+ focus_generation: 9,337+ ack: InputAck {338+ sequence: 1,339+ pointer_position: None,340+ pointer_visible: None,341+ },342+ };343+ let (mut sender, mut receiver) = duplex(2048);344+ write_input_client_hello_v2(&mut sender, &hello)345+ .await346+ .unwrap();347+ write_input_event_v2(&mut sender, &event).await.unwrap();348+ write_input_ack_v2(&mut sender, &ack).await.unwrap();349+ sender.shutdown().await.unwrap();350+ assert_eq!(351+ read_input_client_hello_v2(&mut receiver).await.unwrap(),352+ Some(hello)353+ );354+ assert_eq!(355+ read_input_event_v2(&mut receiver).await.unwrap(),356+ Some(event)357+ );358+ assert_eq!(read_input_ack_v2(&mut receiver).await.unwrap(), Some(ack));359+ }360+361+ 362+ fn input_v2_rejects_zero_generations_unknown_fields_and_media_capabilities() {363+ let mut hello =364+ InputClientHelloV2::new("portal", [0; 16], 0, ControllerSlot::ZERO, 1, vec![]);365+ assert!(hello.validate().is_err());366+ hello.session_id = 1;367+ hello.capabilities = vec![Capability::VideoH264];368+ assert!(hello.validate().is_err());369+ assert!(370+ serde_json::from_str::<InputEventV2>(371+ r#"{"session_id":1,"slot":0,"focus_generation":1,"event":{"t":"ip"},"extra":true}"#372+ )373+ .is_err()374+ );375+ }376+}@@ -6,17 +6,27 @@
66 //! fail closed.77 88 mod audio;9+mod control_v2;910 mod error;1011 mod feedback;1112 mod framing;1213 mod handshake;1314 mod input;15+mod input_v2;1416 mod invitation;1517 mod media;1618 mod media_v3;1719 mod moq_catalog;1820 1921 pub use audio::{AUDIO_HEADER_LEN, AudioCodec, AudioFlags, AudioPacket, AudioPacketHeader};22+pub use control_v2::{23+ ClientControlEnvelopeV2, ClientHelloV2, ControlKeyframeReasonV2, FocusCommandActionV2,24+ FocusCommandResultV2, FocusCommandV2, FocusStateV2, FocusTransitionReasonV2, HostHelloV2,25+ MAX_CONTROL_V2_MESSAGE_LEN, MAX_SESSION_ROSTER_VIEWERS, MediaGenerationDescriptorV2,26+ ServerControlEnvelopeV2, SessionSnapshotV2, ViewerPresenceId, ViewerPresenceV2,27+ read_client_control_v2, read_client_hello_v2, read_host_hello_v2, read_server_control_v2,28+ write_client_control_v2, write_client_hello_v2, write_host_hello_v2, write_server_control_v2,29+};2030 pub use error::{ProtocolError, Result};2131 pub use feedback::{2232 ADAPTIVE_BITRATE_DECISION_V1_LEN, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1,@@ -35,6 +45,12 @@ pub use input::{
3545 RELATIVE_POINTER_DELTA_MIN, read_input_ack, read_input_event, write_input_ack,3646 write_input_event,3747 };48+pub use input_v2::{49+ ControllerSlot, InputAckV2, InputClientHelloV2, InputEventV2, InputHostHelloV2,50+ read_input_ack_v2, read_input_client_hello_v2, read_input_event_v2, read_input_host_hello_v2,51+ write_input_ack_v2, write_input_client_hello_v2, write_input_event_v2,52+ write_input_host_hello_v2,53+};3854 pub use invitation::{3955 INVITATION_CLOCK_SKEW_SECS, INVITATION_TOKEN_PREFIX, InvitationClaims, InvitationGrants,4056 MAX_INVITATION_TOKEN_LEN, MAX_INVITATION_TTL_SECS, SignedInvitation,@@ -58,6 +74,8 @@ pub use moq_catalog::{
5874 5975 /// Protocol version encoded in v1 messages.6076 pub const PROTOCOL_VERSION: u16 = 1;77+/// Protocol version encoded in v2 control and input messages.78+pub const PROTOCOL_VERSION_V2: u16 = 2;6179 6280 /// ALPN for custom MoQ-style grouped media objects and keyframe control.6381 ///@@ -67,8 +85,12 @@ pub const MEDIA_ALPN_V3: &[u8] = b"sigil/media/3";
6785 pub const MEDIA_FEEDBACK_ALPN_V1: &[u8] = b"sigil/media-feedback/1";6886 /// ALPN for the v1 latency-independent input stream.6987 pub const INPUT_ALPN_V1: &[u8] = b"sigil/input/1";88+/// ALPN for focus-generation-bound v2 input.89+pub const INPUT_ALPN_V2: &[u8] = b"sigil/input/2";7090 /// ALPN for the v1 session-control stream.7191 pub const CONTROL_ALPN_V1: &[u8] = b"sigil/control/1";92+/// ALPN for revisioned multi-viewer session control.93+pub const CONTROL_ALPN_V2: &[u8] = b"sigil/control/2";7294 /// Static upstream MoQ track carrying bounded H.264 access-unit objects.7395 pub const MOQ_VIDEO_H264_TRACK: &str = "video/h264";7496 /// ALPN for the v1 low-latency Opus datagram connection.@@ -114,7 +136,9 @@ mod tests {
114136 assert_eq!(MEDIA_ALPN_V3, b"sigil/media/3");115137 assert_eq!(MEDIA_FEEDBACK_ALPN_V1, b"sigil/media-feedback/1");116138 assert_eq!(INPUT_ALPN_V1, b"sigil/input/1");139+ assert_eq!(INPUT_ALPN_V2, b"sigil/input/2");117140 assert_eq!(CONTROL_ALPN_V1, b"sigil/control/1");141+ assert_eq!(CONTROL_ALPN_V2, b"sigil/control/2");118142 assert_eq!(AUDIO_ALPN_V1, b"sigil/audio/1");119143 }120144 @@ -96,6 +96,7 @@ export function newConnectionState({
9696 developmentMode: false,9797 inputCapabilities: emptyInputCapabilities(),9898 mediaTransport: 'unknown',99+ controlProtocol: 'unknown',99100 };100101 101102 const snapshot = () => ({@@ -105,6 +106,7 @@ export function newConnectionState({
105106 developmentMode: state.developmentMode,106107 inputCapabilities: { ...state.inputCapabilities },107108 mediaTransport: state.mediaTransport,109+ controlProtocol: state.controlProtocol,108110 });109111 110112 async function connect({ pin = '' } = {}) {@@ -143,6 +145,7 @@ export function newConnectionState({
143145 state.disconnecting = false;144146 state.inputCapabilities = normalizeInputCapabilities(result);145147 state.mediaTransport = normalizeMediaTransport(result.media_transport);148+ state.controlProtocol = result.control_protocol === 'control-v2' ? 'control-v2' : 'legacy-v1';146149 onStatus('ok', connectionStatus(result, state.inputCapabilities), {147150 result,148151 attempt,@@ -182,6 +185,7 @@ export function newConnectionState({
182185 state.connected = false;183186 state.inputCapabilities = emptyInputCapabilities();184187 state.mediaTransport = 'unknown';188+ state.controlProtocol = 'unknown';185189 await onDisconnected({ state: snapshot() });186190 state.disconnecting = false;187191 return true;@@ -194,10 +198,23 @@ export function newConnectionState({
194198 get developmentMode() { return state.developmentMode; },195199 get inputCapabilities() { return { ...state.inputCapabilities }; },196200 get mediaTransport() { return state.mediaTransport; },201+ get controlProtocol() { return state.controlProtocol; },197202 snapshot,198203 setDevelopmentMode(enabled) {199204 state.developmentMode = enabled === true;200205 },206+ updateInputCapabilities(result) {207+ state.inputCapabilities = normalizeInputCapabilities({208+ relative_pointer_available: result.relative_pointer,209+ pointer_position_feedback_available: result.pointer_position_feedback,210+ absolute_pointer_available: result.absolute_pointer,211+ keyboard_available: result.keyboard,212+ text_available: result.text,213+ gamepad_available: result.gamepad,214+ control_available: result.control,215+ });216+ return { ...state.inputCapabilities };217+ },201218 connect,202219 disconnect,203220 };@@ -12,6 +12,7 @@ export function createControlRuntime({
1212 publishController,1313 resetControllerEscape,1414 onChange,15+ onExit = () => {},1516 onReleaseFailure,1617 pointerLockTimeoutMs = BROWSER_POINTER_LOCK_TIMEOUT_MS,1718 scheduleTimeout = globalThis.setTimeout,@@ -44,6 +45,7 @@ export function createControlRuntime({
4445 throw new TypeError('resetControllerEscape must be a function');4546 }4647 if (typeof onChange !== 'function') throw new TypeError('onChange must be a function');48+ if (typeof onExit !== 'function') throw new TypeError('onExit must be a function');4749 if (typeof onReleaseFailure !== 'function') {4850 throw new TypeError('onReleaseFailure must be a function');4951 }@@ -134,7 +136,7 @@ export function createControlRuntime({
134136 try { pointerLock.exit(); } catch (_) {}135137 }136138 137- function exit({ resetEscape = false } = {}) {139+ function exit({ resetEscape = false, releaseHostFocus = true } = {}) {138140 controlTransitionGeneration += 1;139141 controlTransitionInProgress = false;140142 controllerActivationGate.reset();@@ -144,6 +146,7 @@ export function createControlRuntime({
144146 controlMode = false;145147 releasePointerLock();146148 onChange();149+ if (releaseHostFocus) onExit();147150 }148151 149152 function exitAfterBrowserPointerLockFailure() {@@ -0,0 +1,81 @@
1+export function createFocusRuntime({2+ invokeCommand,3+ getSession,4+ startFocusedInput,5+ activateLocalControl,6+ onChange = () => {},7+} = {}) {8+ if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');9+ if (typeof getSession !== 'function') throw new TypeError('getSession must be a function');10+ if (typeof startFocusedInput !== 'function') throw new TypeError('startFocusedInput must be a function');11+ if (typeof activateLocalControl !== 'function') throw new TypeError('activateLocalControl must be a function');12+ let nextRequestId = 1;13+ let pending = null;14+15+ async function queue(command, args, controllerInitiated = false) {16+ if (pending !== null) return false;17+ const requestId = nextRequestId++;18+ pending = { requestId, command, controllerInitiated };19+ onChange(snapshot());20+ try {21+ const queued = await invokeCommand(command, { requestId, ...args });22+ if (!queued && pending?.requestId === requestId) pending = null;23+ onChange(snapshot());24+ return queued === true;25+ } catch (error) {26+ if (pending?.requestId === requestId) pending = null;27+ onChange(snapshot());28+ throw error;29+ }30+ }31+32+ async function request({ controllerInitiated = false } = {}) {33+ const session = getSession();34+ if (session.mode !== 'control-v2' || !session.eligible || session.focused) return false;35+ return queue('iroh_client_request_focus', {36+ generation: session.nativeGeneration,37+ expectedRevision: session.revision,38+ }, controllerInitiated);39+ }40+41+ async function release() {42+ const session = getSession();43+ if (session.mode !== 'control-v2' || !session.focused) return false;44+ return queue('iroh_client_release_focus', {45+ generation: session.nativeGeneration,46+ expectedRevision: session.revision,47+ expectedFocusGeneration: session.focusGeneration,48+ });49+ }50+51+ async function observeSession() {52+ const session = getSession();53+ const request = pending;54+ if (!request || request.command !== 'iroh_client_request_focus' || !session.focused) return false;55+ pending = null;56+ onChange(snapshot());57+ await startFocusedInput();58+ await activateLocalControl({ controllerInitiated: request.controllerInitiated });59+ return true;60+ }61+62+ function observeResult(result) {63+ if (!pending || result?.request_id !== pending.requestId) return false;64+ if (result.accepted === false) {65+ pending = null;66+ onChange(snapshot());67+ }68+ return true;69+ }70+71+ function reset() {72+ pending = null;73+ onChange(snapshot());74+ }75+76+ function snapshot() {77+ return { pending: pending !== null, requestId: pending?.requestId ?? null };78+ }79+80+ return Object.freeze({ request, release, observeSession, observeResult, reset, snapshot });81+}@@ -0,0 +1,56 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+4+import { createFocusRuntime } from './focus-runtime.mjs';5+6+test('focus request is non-optimistic and enforces one in-flight command', async () => {7+ const calls = [];8+ const session = {9+ mode: 'control-v2', eligible: true, focused: false, nativeGeneration: 3, revision: 4,10+ };11+ const runtime = createFocusRuntime({12+ invokeCommand: async (command, args) => { calls.push([command, args]); return true; },13+ getSession: () => ({ ...session }),14+ startFocusedInput: async () => {},15+ activateLocalControl: async () => {},16+ });17+ assert.equal(await runtime.request({ controllerInitiated: true }), true);18+ assert.equal(await runtime.request(), false);19+ assert.equal(session.focused, false);20+ assert.equal(calls[0][1].expectedRevision, 4);21+});22+23+test('authoritative grant starts input before activation and preserves controller provenance', async () => {24+ const order = [];25+ const session = {26+ mode: 'control-v2', eligible: true, focused: false, nativeGeneration: 3, revision: 4,27+ };28+ const runtime = createFocusRuntime({29+ invokeCommand: async () => true,30+ getSession: () => ({ ...session }),31+ startFocusedInput: async () => order.push('input'),32+ activateLocalControl: async ({ controllerInitiated }) => order.push(`activate:${controllerInitiated}`),33+ });34+ await runtime.request({ controllerInitiated: true });35+ session.focused = true;36+ session.focusGeneration = 8;37+ session.revision = 5;38+ assert.equal(await runtime.observeSession(), true);39+ assert.deepEqual(order, ['input', 'activate:true']);40+});41+42+test('release carries authoritative revision and focus generation', async () => {43+ let call;44+ const runtime = createFocusRuntime({45+ invokeCommand: async (command, args) => { call = [command, args]; return true; },46+ getSession: () => ({47+ mode: 'control-v2', focused: true, nativeGeneration: 6, revision: 9, focusGeneration: 12,48+ }),49+ startFocusedInput: async () => {},50+ activateLocalControl: async () => {},51+ });52+ assert.equal(await runtime.release(), true);53+ assert.equal(call[0], 'iroh_client_release_focus');54+ assert.equal(call[1].expectedRevision, 9);55+ assert.equal(call[1].expectedFocusGeneration, 12);56+});@@ -9,6 +9,8 @@ import {
99 } from './input-state.mjs';1010 import { createInputRuntime } from './input-runtime.mjs';1111 import { createControlRuntime } from './control-runtime.mjs';12+import { createSessionState } from './session-state.mjs';13+import { createFocusRuntime } from './focus-runtime.mjs';1214 import { formatVideoDiscardTelemetry } from './frame-stats.mjs';1315 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';1416 import {@@ -50,6 +52,8 @@ let activePointerSession = null;
5052 let controllerActivationInProgress = false;5153 let controlRuntime = null;5254 let controllerRuntime = null;55+let sessionStateRuntime = null;56+let focusRuntime = null;5357 5458 const invoke = (...args) => window.__TAURI__.core.invoke(...args);5559 const listen = (...args) => window.__TAURI__.event.listen(...args);@@ -440,7 +444,7 @@ async function teardownConnectionAttempt(attempt) {
440444 await audioPipeline.teardown();441445 }442446 443-function updateAcceptedConnectionState({ attempt, state }) {447+function updateAcceptedConnectionState({ result, attempt, state }) {444448 const session = attempt.pointerSession;445449 if (!state.inputCapabilities.pointerPositionFeedback) {446450 session.remotePosition = null;@@ -449,6 +453,13 @@ function updateAcceptedConnectionState({ attempt, state }) {
449453 session.remotePosition = attempt.initialPointerFeedback.position;450454 session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;451455 }456+ sessionStateRuntime.acceptConnection({457+ nativeGeneration: result.media_generation,458+ controlProtocol: connectionState.controlProtocol,459+ initialSnapshot: result.session_snapshot ?? null,460+ eligible: state.inputCapabilities.control,461+ });462+ focusRuntime.reset();452463 controlRuntime.resetAcceptedConnection();453464 }454465 @@ -492,6 +503,8 @@ async function teardownConnectedResources() {
492503 }493504 494505 async function showDisconnectedState() {506+ focusRuntime.reset();507+ sessionStateRuntime.reset();495508 controlRuntime.resetDisconnected();496509 updatePanelVisibility();497510 setStatus('', 'idle');@@ -599,7 +612,11 @@ controlRuntime = createControlRuntime({
599612 getConnection: () => ({600613 connected: connectionState.connected,601614 disconnecting: connectionState.disconnecting,602- capabilities: connectionState.inputCapabilities,615+ capabilities: {616+ ...connectionState.inputCapabilities,617+ control: connectionState.inputCapabilities.control618+ && (connectionState.controlProtocol !== 'control-v2' || sessionStateRuntime?.focused === true),619+ },603620 }),604621 inputRuntime,605622 invokeCursorGrab: (grab) => invoke('set_client_cursor_grab', { grab }),@@ -619,9 +636,31 @@ controlRuntime = createControlRuntime({
619636 },620637 publishController: () => controllerRuntime?.publishCurrentState(),621638 resetControllerEscape: () => controllerRuntime?.resetEscape(),622- onChange: () => updateControlUI(),639+ onChange: () => {640+ sessionStateRuntime?.setLocallyActive(controlRuntime.active);641+ updateControlUI();642+ },643+ onExit: () => {644+ if (connectionState.controlProtocol === 'control-v2') {645+ void focusRuntime?.release().catch((error) => console.error('focus release failed:', error));646+ }647+ },623648 onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),624649 });650+sessionStateRuntime = createSessionState({651+ onFocusLoss: () => controlRuntime.exit({ releaseHostFocus: false, resetEscape: true }),652+ onChange: () => updateControlUI(),653+});654+focusRuntime = createFocusRuntime({655+ invokeCommand: invoke,656+ getSession: () => sessionStateRuntime.snapshot(),657+ startFocusedInput: async () => {658+ const capabilities = await invoke('iroh_client_start_focused_input');659+ connectionState.updateInputCapabilities(capabilities);660+ },661+ activateLocalControl: ({ controllerInitiated }) => controlRuntime.toggle({ controllerInitiated }),662+ onChange: () => updateControlUI(),663+});625664 videoPipeline = createVideoPipelineSession({626665 canvas,627666 context: ctx,@@ -788,6 +827,11 @@ listen('audio-stats', (event) => {
788827 async function toggleControl() {789828 // Capture synchronous controller provenance before cursor acquisition yields.790829 // The DOM click dispatcher does not await this async handler.830+ if (connectionState.controlProtocol === 'control-v2' && !sessionStateRuntime.focused) {831+ await focusRuntime.request({ controllerInitiated: controllerActivationInProgress });832+ updateControlUI();833+ return;834+ }791835 await controlRuntime.toggle({ controllerInitiated: controllerActivationInProgress });792836 }793837 @@ -838,10 +882,15 @@ function renderRemotePointer() {
838882 function updateControlUI() {839883 const el = document.getElementById('control-toggle');840884 const capabilities = connectionState.inputCapabilities;841- const available = connectionState.connected && capabilities.control;885+ const available = connectionState.connected886+ && capabilities.control887+ && (connectionState.controlProtocol !== 'control-v2' || sessionStateRuntime.eligible);842888 const controlling = controlRuntime.setInactiveIfUnavailable(available);889+ const controlAction = connectionState.controlProtocol === 'control-v2' && !sessionStateRuntime.focused890+ ? 'request control'891+ : controlling ? 'controlling' : 'activate control';843892 el.textContent = available844- ? `${controlling ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`893+ ? `${controlAction} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`845894 : 'view only · input unavailable';846895 el.classList.toggle('active', controlling);847896 el.classList.toggle('disabled', !available);@@ -1188,6 +1237,24 @@ listen('dev-connect-routing', (event) => {
11881237 console.warn('[development direct-node routing]', event.payload.warning, event.payload.host_node_id);11891238 });11901239 1240+listen('session-state', (event) => {1241+ try {1242+ if (sessionStateRuntime.applyNativeSnapshot(event.payload)) {1243+ void focusRuntime.observeSession().catch((error) => {1244+ console.error('focused input activation failed:', error);1245+ controlRuntime.exit();1246+ });1247+ }1248+ } catch (error) {1249+ console.error('invalid session-state payload:', error);1250+ void disconnect();1251+ }1252+});1253+1254+listen('focus-command-result', (event) => {1255+ focusRuntime.observeResult(event.payload);1256+});1257+11911258 listen('invitation-pending', (event) => {11921259 try { showInvitationSummary(event.payload); }11931260 catch (error) { console.error('invalid native invitation summary:', error); }@@ -0,0 +1,120 @@
1+function emptySnapshotState() {2+ return {3+ mode: 'disconnected',4+ nativeGeneration: 0,5+ revision: 0,6+ snapshot: null,7+ eligible: false,8+ focused: false,9+ focusGeneration: null,10+ locallyActive: false,11+ };12+}13+14+function normalizeSnapshot(snapshot) {15+ if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision <= 0) {16+ throw new Error('session snapshot revision must be a positive safe integer');17+ }18+ if (!Array.isArray(snapshot.viewers) || snapshot.viewers.length < 1 || snapshot.viewers.length > 8) {19+ throw new Error('session snapshot roster must contain 1..=8 viewers');20+ }21+ const self = snapshot.viewers.find((viewer) => viewer.you === true);22+ if (!self || self.presence_id !== snapshot.self_presence_id) {23+ throw new Error('session snapshot does not contain the self viewer');24+ }25+ const focus = snapshot.focus;26+ const focused = focus?.state === 'held'27+ && focus.holder === self.presence_id28+ && focus.session_id === self.session_id29+ && Number.isSafeInteger(focus.focus_generation)30+ && focus.focus_generation > 0;31+ return {32+ snapshot,33+ revision: snapshot.revision,34+ eligible: self.input_capable === true,35+ focused,36+ focusGeneration: focused ? focus.focus_generation : null,37+ };38+}39+40+export function createSessionState({ onFocusLoss = () => {}, onChange = () => {} } = {}) {41+ if (typeof onFocusLoss !== 'function' || typeof onChange !== 'function') {42+ throw new TypeError('session state callbacks must be functions');43+ }44+ let state = emptySnapshotState();45+46+ function publish(next) {47+ const lostFocus = state.focused && !next.focused;48+ if (lostFocus) onFocusLoss({ previous: snapshot(), next: { ...next } });49+ state = next;50+ onChange(snapshot());51+ return true;52+ }53+54+ function acceptConnection({ nativeGeneration, controlProtocol, initialSnapshot, eligible }) {55+ if (!Number.isSafeInteger(nativeGeneration) || nativeGeneration <= 0) {56+ throw new Error('native generation must be a positive safe integer');57+ }58+ if (controlProtocol !== 'control-v2') {59+ return publish({60+ ...emptySnapshotState(),61+ mode: 'legacy-v1',62+ nativeGeneration,63+ eligible: eligible === true,64+ focused: eligible === true,65+ });66+ }67+ const normalized = normalizeSnapshot(initialSnapshot);68+ return publish({69+ ...emptySnapshotState(),70+ ...normalized,71+ mode: 'control-v2',72+ nativeGeneration,73+ });74+ }75+76+ function applyNativeSnapshot(payload) {77+ if (state.mode !== 'control-v2') return false;78+ if (payload?.native_generation !== state.nativeGeneration) return false;79+ const normalized = normalizeSnapshot(payload.snapshot);80+ if (normalized.revision <= state.revision) return false;81+ return publish({82+ ...state,83+ ...normalized,84+ locallyActive: normalized.focused ? state.locallyActive : false,85+ });86+ }87+88+ function setLocallyActive(active) {89+ const next = active === true && state.focused;90+ if (next === state.locallyActive) return false;91+ state = { ...state, locallyActive: next };92+ onChange(snapshot());93+ return true;94+ }95+96+ function reset() {97+ if (state.focused) onFocusLoss({ previous: snapshot(), next: emptySnapshotState() });98+ state = emptySnapshotState();99+ onChange(snapshot());100+ }101+102+ function snapshot() {103+ return { ...state, snapshot: state.snapshot };104+ }105+106+ return Object.freeze({107+ acceptConnection,108+ applyNativeSnapshot,109+ reset,110+ setLocallyActive,111+ snapshot,112+ get mode() { return state.mode; },113+ get eligible() { return state.eligible; },114+ get focused() { return state.focused; },115+ get focusGeneration() { return state.focusGeneration; },116+ get locallyActive() { return state.locallyActive; },117+ get revision() { return state.revision; },118+ get nativeGeneration() { return state.nativeGeneration; },119+ });120+}@@ -0,0 +1,80 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+4+import { createSessionState } from './session-state.mjs';5+6+function snapshot(revision, focus = { state: 'vacant', slot: 0 }) {7+ return {8+ revision,9+ self_presence_id: 'viewer-1',10+ viewers: [{ presence_id: 'viewer-1', session_id: 7, input_capable: true, you: true }],11+ focus,12+ transition_reason: 'initial',13+ media: { generation_id: 7, broadcast_name: 'sigil/session/7/video' },14+ };15+}16+17+test('accepts revision gaps and ignores stale or native-generation-mismatched snapshots', () => {18+ const runtime = createSessionState();19+ runtime.acceptConnection({20+ nativeGeneration: 3,21+ controlProtocol: 'control-v2',22+ initialSnapshot: snapshot(1),23+ });24+ assert.equal(runtime.applyNativeSnapshot({ native_generation: 4, snapshot: snapshot(2) }), false);25+ assert.equal(runtime.applyNativeSnapshot({ native_generation: 3, snapshot: snapshot(8) }), true);26+ assert.equal(runtime.revision, 8);27+ assert.equal(runtime.applyNativeSnapshot({ native_generation: 3, snapshot: snapshot(7) }), false);28+});29+30+test('publishes focus teardown before exposing self-focus loss', () => {31+ const order = [];32+ const runtime = createSessionState({33+ onFocusLoss: () => order.push(`teardown:${runtime.focused}`),34+ onChange: (state) => order.push(`publish:${state.focused}`),35+ });36+ runtime.acceptConnection({37+ nativeGeneration: 2,38+ controlProtocol: 'control-v2',39+ initialSnapshot: snapshot(1, {40+ state: 'held', slot: 0, holder: 'viewer-1', session_id: 7, focus_generation: 4,41+ }),42+ });43+ order.length = 0;44+ runtime.applyNativeSnapshot({ native_generation: 2, snapshot: snapshot(2) });45+ assert.deepEqual(order, ['teardown:true', 'publish:false']);46+});47+48+test('separates eligibility, host focus possession, and local activation', () => {49+ const runtime = createSessionState();50+ runtime.acceptConnection({51+ nativeGeneration: 1,52+ controlProtocol: 'control-v2',53+ initialSnapshot: snapshot(1),54+ });55+ assert.equal(runtime.eligible, true);56+ assert.equal(runtime.focused, false);57+ assert.equal(runtime.setLocallyActive(true), false);58+ runtime.applyNativeSnapshot({59+ native_generation: 1,60+ snapshot: snapshot(2, {61+ state: 'held', slot: 0, holder: 'viewer-1', session_id: 7, focus_generation: 9,62+ }),63+ });64+ assert.equal(runtime.focused, true);65+ assert.equal(runtime.locallyActive, false);66+ assert.equal(runtime.setLocallyActive(true), true);67+ assert.equal(runtime.locallyActive, true);68+});69+70+test('legacy mode retains explicit implicit-focus behavior', () => {71+ const runtime = createSessionState();72+ runtime.acceptConnection({73+ nativeGeneration: 5,74+ controlProtocol: 'legacy-v1',75+ initialSnapshot: null,76+ eligible: true,77+ });78+ assert.equal(runtime.mode, 'legacy-v1');79+ assert.equal(runtime.focused, true);80+});@@ -23,6 +23,8 @@ keyframe_recovery="ok"
2323 media_v3=false2424 relay_only=false2525 expected_path_mode=direct26+control_v2=false27+viewers=12628 2729 usage() {2830 cat <<'EOF'@@ -43,6 +45,8 @@ Options:
4345 instead of upstream MoQ4446 --relay-only Remove direct probe transports and require the4547 control, media, and input paths to stay relayed48+ --control-v2 Exercise explicit control/2 and input/2 focus49+ --viewers COUNT Viewer count for control-v2 (Phase 1 requires 1)4650 --help Show this help4751 EOF4852 }@@ -105,6 +109,18 @@ while [[ $# -gt 0 ]]; do
105109 media_v3=true106110 shift107111 ;;112+ --control-v2)113+ control_v2=true114+ media_accept_log="MoQ control v2 client accepted"115+ media_release_log="MoQ control v2 client released"116+ session_gate_name="control v2"117+ shift118+ ;;119+ --viewers)120+ [[ $# -ge 2 ]] || die "--viewers requires a value"121+ viewers="$2"122+ shift 2123+ ;;108124 --help|-h)109125 usage110126 exit 0@@ -128,6 +144,13 @@ require_positive_integer "--primary-frames" "$primary_frames"
128144 require_positive_integer "--reconnect-cycles" "$reconnect_cycles"129145 require_positive_integer "--reconnect-frames" "$reconnect_frames"130146 require_positive_integer "--probe-timeout-seconds" "$probe_timeout_seconds"147+require_positive_integer "--viewers" "$viewers"148+if [[ "$control_v2" == true && "$viewers" -ne 1 ]]; then149+ die "Phase 1 control-v2 proof supports exactly --viewers 1"150+fi151+if [[ "$control_v2" == true && "$media_v3" == true ]]; then152+ die "--control-v2 conflicts with --media-v3"153+fi131154 [[ "$primary_frames" -ge 4 ]] \132155 || die "--primary-frames must be at least 4 for keyframe recovery"133156 [[ "$reconnect_frames" -ge 4 ]] \@@ -504,6 +527,56 @@ wait_for_log_line "$host_log" 'status=ready' "$host_pid" 'sigil' || die "host di
504527 host_node_id="$(sed -n 's/^node_id=//p' "$host_log" | tail -n 1)"505528 [[ "$host_node_id" == "$node_id" ]] || die "ready host node ID does not match its identity"506529 530+if [[ "$control_v2" == true ]]; then531+ v2_log="$tmp_root/control-v2.log"532+ v1_log="$tmp_root/control-v1-compatibility.log"533+ if ! run_bounded "$command_timeout_seconds" "$v2_log" \534+ "$probe_bin" --control-v2 --node-id "$node_id" --frames "$primary_frames" \535+ --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800; then536+ sed -n '1,240p' "$v2_log" >&2 || true537+ die "control-v2 probe failed"538+ fi539+ grep -Fxq 'probe=ok' "$v2_log" || die "control-v2 probe did not complete"540+ grep -Fxq 'control_alpn=sigil/control/2' "$v2_log" || die "control-v2 ALPN evidence is missing"541+ grep -Fxq 'input_alpn=sigil/input/2' "$v2_log" || die "input-v2 ALPN evidence is missing"542+ grep -Fxq 'slot_0_input=ok' "$v2_log" || die "slot-0 input evidence is missing"543+ grep -Fxq 'focus_release=ok' "$v2_log" || die "focus release evidence is missing"544+ awk -F= '$1 == "initial_snapshot_revision" && $2 ~ /^[1-9][0-9]*$/ { found=1 } END { exit !found }' \545+ "$v2_log" || die "initial snapshot evidence is missing"546+ awk -F= '$1 == "focus_generation" && $2 ~ /^[1-9][0-9]*$/ { found=1 } END { exit !found }' \547+ "$v2_log" || die "focus generation evidence is missing"548+ wait_for_log_count "$host_log" 'MoQ control v2 client released' 1 "$host_pid" 'sigil' \549+ || die "control-v2 session was not released"550+ wait_for_log_count "$host_log" 'input v2 client released' 1 "$host_pid" 'sigil' \551+ || die "input-v2 session was not neutralized and released"552+553+ # The unchanged v1 path remains explicitly usable after a v2 generation.554+ if ! run_bounded "$command_timeout_seconds" "$v1_log" \555+ "$probe_bin" --node-id "$node_id" --frames 4 \556+ --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800; then557+ sed -n '1,160p' "$v1_log" >&2 || true558+ die "v1 compatibility probe failed after control-v2"559+ fi560+ grep -Fxq 'probe=ok' "$v1_log" || die "v1 compatibility probe did not complete"561+ grep -Fxq 'control_alpn=sigil/control/1' "$v1_log" || die "v1 compatibility ALPN evidence is missing"562+563+ kill -TERM "$host_pid" 2>/dev/null || true564+ if wait "$host_pid"; then host_status=0; else host_status=$?; fi565+ host_pid=""566+ wait "$host_watchdog_pid" 2>/dev/null || true567+ host_watchdog_pid=""568+ [[ "$host_status" -eq 0 ]] || die "host exited with status $host_status"569+ printf 'loopback_proof=ok\n'570+ printf 'control_v2=ok\n'571+ printf 'viewers=1\n'572+ printf 'initial_snapshot=ok\n'573+ printf 'slot_0_focus=grant-release\n'574+ printf 'input_v2=ok\n'575+ printf 'neutralization=reset-before-input-lease-release\n'576+ printf 'v1_compatibility=ok\n'577+ exit 0578+fi579+507580 primary_probe_args=(508581 --node-id "$node_id"509582 --frames "$primary_frames"@@ -7,8 +7,10 @@ use crate::media::connect::{connect_client, disconnect_client};
77 use crate::media::frame_channel::acknowledge_frame_delivery;88 use crate::media::input_delivery::PointerFeedbackPayload;99 use crate::media::input_delivery::send_input;10+use crate::media::input_delivery::{InputAvailability, start_focused_input_v2};1011 use crate::media::media_control::request_keyframe;11-use sigil_protocol::InputEvent;12+use crate::media::session_control::send_focus_command;13+use sigil_protocol::{FocusCommandActionV2, InputEvent};1214 use tauri::{1315 AppHandle, State,1416 ipc::{Channel, Response},@@ -46,6 +48,50 @@ pub async fn iroh_client_send_input(
4648 send_input(&state, event).await4749 }4850 51+52+pub async fn iroh_client_request_focus(53+ state: State<'_, AppState>,54+ generation: u64,55+ request_id: u64,56+ expected_revision: u64,57+) -> Result<bool, String> {58+ send_focus_command(59+ &state.session_control,60+ generation,61+ request_id,62+ FocusCommandActionV2::Request,63+ expected_revision,64+ None,65+ )66+ .await67+}68+69+70+pub async fn iroh_client_release_focus(71+ state: State<'_, AppState>,72+ generation: u64,73+ request_id: u64,74+ expected_revision: u64,75+ expected_focus_generation: u64,76+) -> Result<bool, String> {77+ send_focus_command(78+ &state.session_control,79+ generation,80+ request_id,81+ FocusCommandActionV2::Release,82+ expected_revision,83+ Some(expected_focus_generation),84+ )85+ .await86+}87+88+89+pub async fn iroh_client_start_focused_input(90+ state: State<'_, AppState>,91+) -> Result<InputAvailability, String> {92+ start_focused_input_v2(&state).await93+}94+4995 5096 pub async fn iroh_client_request_keyframe(5197 state: State<'_, AppState>,@@ -1,5 +1,7 @@
11 use serde::Serialize;2-use sigil_protocol::{KeyframeRequestReasonV3, MediaFeedbackReportV1};2+use sigil_protocol::{3+ ClientControlEnvelopeV2, KeyframeRequestReasonV3, MediaFeedbackReportV1, SessionSnapshotV2,4+};35 use std::collections::BTreeSet;46 use std::ffi::OsString;57 use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};@@ -345,11 +347,16 @@ where
345347 pub struct AppState {346348 pub enrollment: super::enrollment::EnrollmentState,347349 pub input_send: TokioMutex<Option<tokio::sync::mpsc::Sender<sigil_protocol::InputEvent>>>,350+ pub input_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,351+ pub v2_input_context: TokioMutex<Option<crate::media::input_delivery::V2InputContext>>,348352 pub client_endpoint: TokioMutex<Option<iroh::Endpoint>>,349353 pub media_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,350354 pub media_control: TokioMutex<Option<(u64, MediaControlRequestSender)>>,355+ pub session_control: TokioMutex<Option<(u64, SessionControlSender)>>,356+ pub session_snapshot: Arc<TokioMutex<Option<SessionSnapshotState>>>,351357 pub media_feedback: TokioMutex<Option<(u64, iroh::endpoint::Connection, MediaFeedbackSender)>>,352358 pub media_feedback_report_id: Arc<AtomicU64>,359+ pub control_request_id: Arc<AtomicU64>,353360 pub frame_delivery: TokioMutex<Option<(u64, Arc<AtomicUsize>)>>,354361 pub client_media_generation: Arc<AtomicU64>,355362 pub audio_deliveries: Arc<StdMutex<AudioDeliveryState>>,@@ -364,6 +371,8 @@ pub struct AppState {
364371 pub type MediaControlRequestSender =365372 tokio::sync::mpsc::Sender<(KeyframeRequestReasonV3, Option<u64>)>;366373 pub type MediaFeedbackSender = tokio::sync::watch::Sender<Option<AccumulatedMediaFeedback>>;374+pub type SessionControlSender = tokio::sync::mpsc::Sender<ClientControlEnvelopeV2>;375+pub type SessionSnapshotState = (u64, SessionSnapshotV2);367376 368377 const MAX_MEDIA_FEEDBACK_INTERVAL_MS: u64 = 5_000;369378 @@ -467,11 +476,16 @@ impl AppState {
467476 Self {468477 enrollment: super::enrollment::EnrollmentState::default(),469478 input_send: TokioMutex::new(None),479+ input_connection: TokioMutex::new(None),480+ v2_input_context: TokioMutex::new(None),470481 client_endpoint: TokioMutex::new(None),471482 media_connection: TokioMutex::new(None),472483 media_control: TokioMutex::new(None),484+ session_control: TokioMutex::new(None),485+ session_snapshot: Arc::new(TokioMutex::new(None)),473486 media_feedback: TokioMutex::new(None),474487 media_feedback_report_id: Arc::new(AtomicU64::new(0)),488+ control_request_id: Arc::new(AtomicU64::new(0)),475489 frame_delivery: TokioMutex::new(None),476490 client_media_generation: Arc::new(AtomicU64::new(0)),477491 audio_deliveries: Arc::new(StdMutex::new(AudioDeliveryState::default())),@@ -697,8 +711,12 @@ mod tests {
697711 let state = AppState::default();698712 699713 assert!(state.client_endpoint.try_lock().unwrap().is_none());714+ assert!(state.input_connection.try_lock().unwrap().is_none());715+ assert!(state.v2_input_context.try_lock().unwrap().is_none());700716 assert!(state.media_connection.try_lock().unwrap().is_none());701717 assert!(state.media_control.try_lock().unwrap().is_none());718+ assert!(state.session_control.try_lock().unwrap().is_none());719+ assert!(state.session_snapshot.try_lock().unwrap().is_none());702720 assert!(state.media_feedback.try_lock().unwrap().is_none());703721 assert!(state.audio_connection.try_lock().unwrap().is_none());704722 }@@ -105,6 +105,9 @@ fn main() {
105105 commands::network::iroh_client_ack_audio,106106 commands::network::iroh_client_stop_audio,107107 commands::network::iroh_client_send_input,108+ commands::network::iroh_client_request_focus,109+ commands::network::iroh_client_release_focus,110+ commands::network::iroh_client_start_focused_input,108111 ])109112 .build(tauri::generate_context!())110113 .expect("error while building Portal application");@@ -24,14 +24,18 @@ use crate::media::audio_delivery::{
2424 };2525 use crate::media::frame_channel::{close_generation_connection, next_media_generation};2626 use crate::media::input_delivery::{27- CLIENT_INPUT_QUEUE_CAPACITY, InputSession, PointerFeedbackPayload, open_input_session,28- run_input_feedback, run_input_forwarder,27+ CLIENT_INPUT_QUEUE_CAPACITY, InputSession, PointerFeedbackPayload, V2InputContext,28+ open_input_session, run_input_feedback, run_input_forwarder, v2_input_eligibility,2929 };3030 use crate::media::media_control::run_media_control_writer_v3;3131 use crate::media::moq_receiver::open_upstream_moq_media;3232 use crate::media::network_diagnostics::NetworkSessionDiagnostics;33+use crate::media::session_control::{34+ SESSION_CONTROL_COMMAND_CAPACITY, install_initial_snapshot, run_session_control,35+};3336 use crate::media::transport::{34- CLIENT_ENDPOINT_CLOSE_TIMEOUT, MediaTransport, open_negotiated_media_stream,37+ CLIENT_ENDPOINT_CLOSE_TIMEOUT, ControlProtocol, MediaTransport, NegotiatedMediaStream,38+ open_negotiated_media_stream,3539 };3640 use crate::media::video_delivery::{VideoDeliveryRequest, run_video_delivery};3741 @@ -70,6 +74,8 @@ pub struct ConnectResult {
7074 pub host_node_id: Option<String>,7175 pub development_mode: bool,7276 pub media_transport: &'static str,77+ pub control_protocol: &'static str,78+ pub session_snapshot: Option<sigil_protocol::SessionSnapshotV2>,7379 pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,7480 pub relative_pointer_available: bool,7581 pub pointer_position_feedback_available: bool,@@ -176,29 +182,41 @@ pub(crate) async fn connect_client(
176182 let first_attempt =177183 open_negotiated_media_stream(&endpoint, &addr, handshake_nonce, invitation.as_deref())178184 .await;179- let (frame_conn, frame_recv, media_control_stream, media_negotiation, media_transport) =180- match first_attempt {181- Ok(result) => result,182- Err(invitation_error)183- if invitation.is_some()184- && invitation_error.contains("Portal peer is not authorized") =>185- {186- // Recover only the narrow crash window where Sigil durably187- // consumed the invitation but Portal did not durably clear it.188- // The replay itself remains rejected; a second, ticket-free189- // connection can succeed only as the already-enrolled Iroh190- // peer authenticated by the exact invited host.191- open_negotiated_media_stream(&endpoint, &addr, handshake_nonce, None)185+ let (186+ frame_conn,187+ frame_recv,188+ media_control_stream,189+ media_negotiation,190+ media_transport,191+ control_protocol,192+ ) = match first_attempt {193+ Ok(result) => result,194+ Err(invitation_error)195+ if invitation.is_some()196+ && invitation_error.contains("Portal peer is not authorized") =>197+ {198+ // Recover only the narrow crash window where Sigil durably199+ // consumed the invitation but Portal did not durably clear it.200+ // The replay itself remains rejected; a second, ticket-free201+ // connection can succeed only as the already-enrolled Iroh202+ // peer authenticated by the exact invited host.203+ open_negotiated_media_stream(&endpoint, &addr, handshake_nonce, None)192204 .await193205 .map_err(|retry_error| {194206 format!(195207 "{invitation_error}; ticket-free enrollment recovery also failed: {retry_error}"196208 )197209 })?198- }199- Err(error) => return Err(error),200- };201- let media_session_id = media_negotiation.session_id;210+ }211+ Err(error) => return Err(error),212+ };213+ let media_session_id = media_negotiation.session_id();214+ let pointer_surface_dimensions = media_negotiation.pointer_surface_dimensions();215+ let initial_session_snapshot = match &media_negotiation {216+ NegotiatedMediaStream::V2(negotiation) => Some(negotiation.initial_snapshot.clone()),217+ NegotiatedMediaStream::V1(_) => None,218+ };219+ let media_generation = next_media_generation(&state.client_media_generation)?;202220 let (upstream_moq_media, frame_connection_for_stats) = if media_transport203221 == MediaTransport::UpstreamMoq204222 {@@ -220,7 +238,6 @@ pub(crate) async fn connect_client(
220238 } else {221239 (None, frame_conn.clone())222240 };223- let pointer_surface_dimensions = media_negotiation.pointer_surface_dimensions;224241 if !development_mode && let Some(expected_invitation) = invitation.as_deref() {225242 // An accepted media hello means Sigil durably committed the one-time226243 // enrollment before returning. Future PIN/tap/play sessions send no@@ -250,33 +267,49 @@ pub(crate) async fn connect_client(
250267 }251268 };252269 253- let InputSession {254- connection: input_connection,255- send: input_send,256- recv: input_recv,257- capabilities: input_capabilities,258- availability: input_availability,259- } = open_input_session(&endpoint, &addr, handshake_nonce, media_session_id, grants).await?;260-261- let network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(270+ let mut network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(262271 Instant::now(),263- input_availability.input_ack,272+ false,264273 )));265-266- if input_availability.pointer_position_feedback || input_availability.input_ack {267- let feedback_diagnostics = Arc::clone(&network_diagnostics);268- let pointer_feedback_enabled = input_availability.pointer_position_feedback;269- let input_ack_enabled = input_availability.input_ack;270- tokio::spawn(run_input_feedback(271- input_recv,272- feedback_diagnostics,273- pointer_feedback_enabled,274- input_ack_enabled,275- pointer_channel,276- ));274+ let mut input_connection_for_stats = None;275+ let (input_availability, v1_input_forwarder) = if control_protocol == ControlProtocol::V1 {276+ let InputSession {277+ connection: input_connection,278+ send: input_send,279+ recv: input_recv,280+ capabilities: input_capabilities,281+ availability: input_availability,282+ } = open_input_session(&endpoint, &addr, handshake_nonce, media_session_id, grants).await?;283+ network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(284+ Instant::now(),285+ input_availability.input_ack,286+ )));287+ input_connection_for_stats = Some(input_connection.clone());288+ if input_availability.pointer_position_feedback || input_availability.input_ack {289+ tokio::spawn(run_input_feedback(290+ input_recv,291+ Arc::clone(&network_diagnostics),292+ input_availability.pointer_position_feedback,293+ input_availability.input_ack,294+ pointer_channel,295+ ));296+ } else {297+ drop(input_recv);298+ }299+ (input_availability, Some((input_send, input_capabilities)))277300 } else {278- drop(input_recv);279- }301+ *state.v2_input_context.lock().await = Some(V2InputContext {302+ endpoint: endpoint.clone(),303+ address: addr.clone(),304+ nonce: handshake_nonce,305+ media_session_id,306+ native_generation: media_generation,307+ grants,308+ pointer_channel,309+ network_diagnostics: Arc::clone(&network_diagnostics),310+ });311+ (v2_input_eligibility(grants), None)312+ };280313 281314 let audio_generation = next_audio_generation(&state.audio_connection_generation)?;282315 lock_audio_deliveries(&state.audio_deliveries).begin_generation(audio_generation)?;@@ -307,12 +340,11 @@ pub(crate) async fn connect_client(
307340 (false, None, Some(error))308341 }309342 };310- let media_generation = next_media_generation(&state.client_media_generation)?;311-312- let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);313- {314- let mut input_send_guard = state.input_send.lock().await;315- *input_send_guard = Some(tx);343+ let mut v1_input_receiver = None;344+ if let Some((input_send, input_capabilities)) = v1_input_forwarder {345+ let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);346+ *state.input_send.lock().await = Some(tx);347+ v1_input_receiver = Some((input_send, rx, input_capabilities));316348 }317349 318350 {@@ -336,7 +368,7 @@ pub(crate) async fn connect_client(
336368 } else {337369 *state.media_feedback.lock().await = None;338370 }339- let media_control_requests = {371+ let media_control_requests = if control_protocol == ControlProtocol::V1 {340372 let (control_tx, control_rx) = tokio::sync::mpsc::channel(1);341373 *state.media_control.lock().await = Some((media_generation, control_tx.clone()));342374 tokio::spawn(run_media_control_writer_v3(@@ -347,6 +379,29 @@ pub(crate) async fn connect_client(
347379 let _ = control_tx.try_send((KeyframeRequestReasonV3::Join, None));348380 }349381 Some(control_tx)382+ } else {383+ let initial_snapshot = initial_session_snapshot384+ .clone()385+ .ok_or_else(|| "Control v2 omitted its initial session snapshot".to_string())?;386+ install_initial_snapshot(387+ &app,388+ &state.session_snapshot,389+ media_generation,390+ initial_snapshot,391+ )392+ .await?;393+ let (control_tx, control_rx) = tokio::sync::mpsc::channel(SESSION_CONTROL_COMMAND_CAPACITY);394+ *state.session_control.lock().await = Some((media_generation, control_tx));395+ tokio::spawn(run_session_control(396+ app.clone(),397+ media_generation,398+ media_session_id,399+ media_control_stream,400+ frame_recv,401+ control_rx,402+ Arc::clone(&state.session_snapshot),403+ ));404+ None350405 };351406 let frame_events_in_flight = Arc::new(AtomicUsize::new(0));352407 *state.frame_delivery.lock().await =@@ -356,19 +411,20 @@ pub(crate) async fn connect_client(
356411 // dropped at the 60 Hz boundary. Relative motion is displacement, so it357412 // owns a separate accumulator and timer that coalesces rather than drops.358413 let input_send_diagnostics = Arc::clone(&network_diagnostics);359- tokio::spawn(run_input_forwarder(360- input_send,361- rx,362- input_capabilities,363- input_send_diagnostics,364- ));414+ if let Some((input_send, rx, input_capabilities)) = v1_input_receiver {415+ tokio::spawn(run_input_forwarder(416+ input_send,417+ rx,418+ input_capabilities,419+ input_send_diagnostics,420+ ));421+ }365422 366423 tokio::spawn(run_video_delivery(VideoDeliveryRequest {367424 app,368425 endpoint,369- frame_recv,370426 frame_connection: frame_connection_for_stats,371- input_connection,427+ input_connection: input_connection_for_stats,372428 audio_connection: audio_connection_for_stats,373429 network_diagnostics,374430 media_control_requests,@@ -385,6 +441,8 @@ pub(crate) async fn connect_client(
385441 host_node_id: Some(host_node_id.to_string()),386442 development_mode,387443 media_transport: media_transport.diagnostic_name(),444+ control_protocol: control_protocol.diagnostic_name(),445+ session_snapshot: initial_session_snapshot,388446 pointer_surface_dimensions,389447 relative_pointer_available: input_availability.relative_pointer,390448 pointer_position_feedback_available: input_availability.pointer_position_feedback,@@ -410,6 +468,9 @@ pub(crate) async fn disconnect_client(state: &AppState) -> Result<bool, String>
410468 next_audio_generation(&state.audio_connection_generation)?;411469 lock_audio_deliveries(&state.audio_deliveries).clear();412470 *state.media_control.lock().await = None;471+ *state.session_control.lock().await = None;472+ *state.session_snapshot.lock().await = None;473+ *state.v2_input_context.lock().await = None;413474 if let Some((_generation, connection, _sender)) = state.media_feedback.lock().await.take() {414475 connection.close(0_u32.into(), b"client disconnected");415476 }@@ -424,6 +485,9 @@ pub(crate) async fn disconnect_client(state: &AppState) -> Result<bool, String>
424485 close_generation_connection(state.audio_connection.lock().await.take(), |connection| {425486 connection.close(0_u32.into(), b"client disconnected");426487 });488+ close_generation_connection(state.input_connection.lock().await.take(), |connection| {489+ connection.close(0_u32.into(), b"client disconnected");490+ });427491 {428492 let mut ce = state.client_endpoint.lock().await;429493 if let Some(endpoint) = ce.take()@@ -4,8 +4,10 @@ use std::time::{Duration, Instant};
44 use iroh::Endpoint;55 use serde::Serialize;66 use sigil_protocol::{7- Capability, INPUT_ALPN_V1, InputEvent, InvitationGrants, PointerPosition,8- RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN, read_input_ack, write_input_event,7+ Capability, ControllerSlot, INPUT_ALPN_V1, INPUT_ALPN_V2, InputClientHelloV2, InputEvent,8+ InputEventV2, InvitationGrants, PointerPosition, RELATIVE_POINTER_DELTA_MAX,9+ RELATIVE_POINTER_DELTA_MIN, read_input_ack, read_input_ack_v2, read_input_host_hello_v2,10+ write_input_client_hello_v2, write_input_event, write_input_event_v2,911 };1012 use tauri::ipc::Channel;1113 @@ -16,7 +18,7 @@ use crate::platform_capabilities::relative_pointer_capture_enabled;
1618 1719 pub(crate) const CLIENT_INPUT_QUEUE_CAPACITY: usize = 256;1820 19-21+2022 pub(crate) struct InputAvailability {2123 pub(crate) relative_pointer: bool,2224 pub(crate) pointer_position_feedback: bool,@@ -28,6 +30,14 @@ pub(crate) struct InputAvailability {
2830 pub(crate) control: bool,2931 }3032 33+pub(crate) fn v2_input_eligibility(grants: InvitationGrants) -> InputAvailability {34+ let capabilities = input_capability_offers(grants)35+ .into_iter()36+ .next()37+ .unwrap_or_default();38+ InputAvailability::from_capabilities(&capabilities)39+}40+3141 impl InputAvailability {3242 fn from_capabilities(capabilities: &[Capability]) -> Self {3343 let relative_pointer = capabilities.contains(&Capability::RelativePointer);@@ -268,16 +278,14 @@ fn input_event_allowed(capabilities: &[Capability], event: &InputEvent) -> bool
268278 }269279 270280 async fn write_client_input_event(271- stream: &mut iroh::endpoint::SendStream,281+ stream: &mut InputWire,272282 event: &InputEvent,273283 diagnostics: Option<&Arc<StdMutex<NetworkSessionDiagnostics>>>,274284 ) -> Result<(), String> {275285 if let Some(diagnostics) = diagnostics {276286 lock_network_diagnostics(diagnostics).begin_input_send(Instant::now());277287 }278- write_input_event(stream, event)279- .await280- .map_err(|error| error.to_string())288+ stream.write(event).await281289 }282290 283291 fn observe_input_ack_if_negotiated(@@ -294,12 +302,67 @@ fn observe_input_ack_if_negotiated(
294302 295303 pub(crate) struct InputSession {296304 pub(crate) connection: iroh::endpoint::Connection,297- pub(crate) send: iroh::endpoint::SendStream,305+ pub(crate) send: InputWire,298306 pub(crate) recv: iroh::endpoint::RecvStream,299307 pub(crate) capabilities: Vec<Capability>,300308 pub(crate) availability: InputAvailability,301309 }302310 311+312+pub(crate) struct V2InputContext {313+ pub(crate) endpoint: Endpoint,314+ pub(crate) address: iroh::EndpointAddr,315+ pub(crate) nonce: [u8; 16],316+ pub(crate) media_session_id: u64,317+ pub(crate) native_generation: u64,318+ pub(crate) grants: InvitationGrants,319+ pub(crate) pointer_channel: Channel<PointerFeedbackPayload>,320+ pub(crate) network_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,321+}322+323+pub(crate) enum InputWire {324+ V1(iroh::endpoint::SendStream),325+ V2 {326+ send: iroh::endpoint::SendStream,327+ session_id: u64,328+ slot: ControllerSlot,329+ focus_generation: u64,330+ },331+}332+333+impl InputWire {334+ async fn write(&mut self, event: &InputEvent) -> Result<(), String> {335+ match self {336+ Self::V1(send) => write_input_event(send, event)337+ .await338+ .map_err(|error| error.to_string()),339+ Self::V2 {340+ send,341+ session_id,342+ slot,343+ focus_generation,344+ } => write_input_event_v2(345+ send,346+ &InputEventV2 {347+ session_id: *session_id,348+ slot: *slot,349+ focus_generation: *focus_generation,350+ event: event.clone(),351+ },352+ )353+ .await354+ .map_err(|error| error.to_string()),355+ }356+ }357+358+ fn finish(&mut self) {359+ let result = match self {360+ Self::V1(send) | Self::V2 { send, .. } => send.finish(),361+ };362+ let _ = result;363+ }364+}365+303366 pub(crate) async fn open_input_session(304367 endpoint: &Endpoint,305368 address: &iroh::EndpointAddr,@@ -337,13 +400,151 @@ pub(crate) async fn open_input_session(
337400 let availability = InputAvailability::from_capabilities(&capabilities);338401 Ok(InputSession {339402 connection,340- send,403+ send: InputWire::V1(send),404+ recv,405+ capabilities,406+ availability,407+ })408+}409+410+pub(crate) async fn open_input_session_v2(411+ endpoint: &Endpoint,412+ address: &iroh::EndpointAddr,413+ nonce: [u8; 16],414+ media_session_id: u64,415+ focus_generation: u64,416+ grants: InvitationGrants,417+) -> Result<InputSession, String> {418+ let offered = input_capability_offers(grants)419+ .into_iter()420+ .next()421+ .unwrap_or_default();422+ let connection = endpoint423+ .connect(address.clone(), INPUT_ALPN_V2)424+ .await425+ .map_err(|error| format!("Failed to connect input v2 stream: {error}"))?;426+ let (mut send, mut recv) = connection427+ .open_bi()428+ .await429+ .map_err(|error| format!("Failed to open input v2 stream: {error}"))?;430+ let hello = InputClientHelloV2::new(431+ "portal/0.1.0",432+ nonce,433+ media_session_id,434+ ControllerSlot::ZERO,435+ focus_generation,436+ offered.clone(),437+ );438+ write_input_client_hello_v2(&mut send, &hello)439+ .await440+ .map_err(|error| format!("Failed to send input v2 handshake: {error}"))?;441+ let response =442+ tokio::time::timeout(Duration::from_secs(10), read_input_host_hello_v2(&mut recv))443+ .await444+ .map_err(|_| "Timed out waiting for input v2 handshake".to_string())?445+ .map_err(|error| format!("Invalid input v2 handshake: {error}"))?446+ .ok_or_else(|| "Host closed during input v2 handshake".to_string())?;447+ if !response.accepted {448+ return Err(format!(449+ "Host rejected input v2 stream: {}",450+ response.message.as_deref().unwrap_or("unspecified reason")451+ ));452+ }453+ if response.session_id != Some(media_session_id)454+ || response.slot != Some(ControllerSlot::ZERO)455+ || response.focus_generation != Some(focus_generation)456+ {457+ return Err("Host returned a mismatched input v2 focus binding".to_string());458+ }459+ if let Some(unoffered) = response460+ .capabilities461+ .iter()462+ .find(|capability| !offered.contains(capability))463+ {464+ return Err(format!(465+ "Host accepted unoffered input v2 capability {unoffered:?}"466+ ));467+ }468+ let capabilities = mask_unavailable_relative_pointer_capabilities(469+ response.capabilities,470+ relative_pointer_capture_enabled(),471+ );472+ let availability = InputAvailability::from_capabilities(&capabilities);473+ Ok(InputSession {474+ connection,475+ send: InputWire::V2 {476+ send,477+ session_id: media_session_id,478+ slot: ControllerSlot::ZERO,479+ focus_generation,480+ },341481 recv,342482 capabilities,343483 availability,344484 })345485 }346486 487+pub(crate) async fn start_focused_input_v2(state: &AppState) -> Result<InputAvailability, String> {488+ let context = state489+ .v2_input_context490+ .lock()491+ .await492+ .clone()493+ .ok_or_else(|| "No control v2 input context is active".to_string())?;494+ let snapshot = state495+ .session_snapshot496+ .lock()497+ .await498+ .as_ref()499+ .filter(|(generation, _)| *generation == context.native_generation)500+ .map(|(_, snapshot)| snapshot.clone())501+ .ok_or_else(|| "No matching control v2 session snapshot is active".to_string())?;502+ let focus_generation = snapshot503+ .self_focus_generation()504+ .ok_or_else(|| "This Portal does not hold slot-0 input focus".to_string())?;505+ if state.input_send.lock().await.is_some() {506+ return Ok(v2_input_eligibility(context.grants));507+ }508+ let InputSession {509+ connection,510+ send,511+ recv,512+ capabilities,513+ availability,514+ } = open_input_session_v2(515+ &context.endpoint,516+ &context.address,517+ context.nonce,518+ context.media_session_id,519+ focus_generation,520+ context.grants,521+ )522+ .await?;523+ let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);524+ *state.input_send.lock().await = Some(tx);525+ *state.input_connection.lock().await = Some((context.native_generation, connection));526+ if availability.pointer_position_feedback || availability.input_ack {527+ tokio::spawn(run_input_feedback_v2(528+ recv,529+ Arc::clone(&context.network_diagnostics),530+ availability.pointer_position_feedback,531+ availability.input_ack,532+ context.pointer_channel,533+ context.media_session_id,534+ focus_generation,535+ ));536+ } else {537+ drop(recv);538+ }539+ tokio::spawn(run_input_forwarder(540+ send,541+ rx,542+ capabilities,543+ Arc::clone(&context.network_diagnostics),544+ ));545+ Ok(availability)546+}547+347548 pub(crate) async fn run_input_feedback(348549 mut input_feedback: iroh::endpoint::RecvStream,349550 feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,@@ -398,7 +599,7 @@ pub(crate) async fn run_input_feedback(
398599 }399600 400601 pub(crate) async fn run_input_forwarder(401- mut input_stream: iroh::endpoint::SendStream,602+ mut input_stream: InputWire,402603 rx: tokio::sync::mpsc::Receiver<InputEvent>,403604 input_capabilities: Vec<Capability>,404605 input_send_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,@@ -509,10 +710,75 @@ pub(crate) async fn run_input_forwarder(
509710 break;510711 }511712 }512- let _ = input_stream.finish();713+ input_stream.finish();714+}715+716+pub(crate) async fn run_input_feedback_v2(717+ mut input_feedback: iroh::endpoint::RecvStream,718+ feedback_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,719+ mut pointer_feedback_enabled: bool,720+ input_ack_enabled: bool,721+ pointer_channel: Channel<PointerFeedbackPayload>,722+ session_id: u64,723+ focus_generation: u64,724+) {725+ let terminal_reason = loop {726+ let response = match read_input_ack_v2(&mut input_feedback).await {727+ Ok(Some(response)) => response,728+ Ok(None) => break PointerFeedbackTerminalReason::Eof,729+ Err(error) => {730+ eprintln!("[client] invalid input v2 feedback: {error}");731+ break PointerFeedbackTerminalReason::Malformed;732+ }733+ };734+ if response.session_id != session_id735+ || response.slot != ControllerSlot::ZERO736+ || response.focus_generation != focus_generation737+ {738+ break PointerFeedbackTerminalReason::Malformed;739+ }740+ if observe_input_ack_if_negotiated(741+ &feedback_diagnostics,742+ input_ack_enabled,743+ response.ack.sequence,744+ Instant::now(),745+ )746+ .is_err()747+ {748+ break PointerFeedbackTerminalReason::Malformed;749+ }750+ if pointer_feedback_enabled751+ && pointer_channel752+ .send(PointerFeedbackPayload::Position {753+ sequence: response.ack.sequence,754+ position: response.ack.pointer_position,755+ pointer_visible: response.ack.pointer_visible,756+ })757+ .is_err()758+ {759+ pointer_feedback_enabled = false;760+ if !input_ack_enabled {761+ return;762+ }763+ }764+ };765+ let _ = pointer_channel.send(PointerFeedbackPayload::Terminal {766+ reason: terminal_reason,767+ });513768 }514769 515770 pub(crate) async fn send_input(state: &AppState, event: InputEvent) -> Result<bool, String> {771+ if state.session_control.lock().await.is_some() {772+ let focused = state773+ .session_snapshot774+ .lock()775+ .await776+ .as_ref()777+ .is_some_and(|(_, snapshot)| snapshot.self_focus_generation().is_some());778+ if !focused {779+ return Ok(false);780+ }781+ }516782 let tx = state517783 .input_send518784 .lock()@@ -1,8 +1,10 @@
11 use std::time::Duration;22 33 use sigil_protocol::{4- KeyframeRequestReasonV3, MediaControlRequestV3, write_media_control_request_v3,4+ ClientControlEnvelopeV2, ControlKeyframeReasonV2, KeyframeRequestReasonV3,5+ MediaControlRequestV3, write_media_control_request_v3,56 };7+use std::sync::atomic::Ordering;68 79 use crate::commands::state::{AppState, MediaControlRequestSender};810 @@ -70,17 +72,40 @@ pub(crate) async fn request_keyframe(
7072 ) -> Result<bool, String> {7173 let reason = parse_keyframe_request_reason(&reason)?;7274 let control = state.media_control.lock().await;75+ if let Some((current_generation, sender)) = control.as_ref() {76+ if *current_generation != generation {77+ return Ok(false);78+ }79+ return match sender.try_send((reason, None)) {80+ Ok(()) => Ok(true),81+ Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(false),82+ Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {83+ Err("Media control channel closed".to_string())84+ }85+ };86+ }87+ drop(control);88+ let control = state.session_control.lock().await;7389 let Some((current_generation, sender)) = control.as_ref() else {7490 return Ok(false);7591 };7692 if *current_generation != generation {7793 return Ok(false);7894 }79- match sender.try_send((reason, None)) {95+ let request_id = state96+ .control_request_id97+ .fetch_add(1, Ordering::Relaxed)98+ .checked_add(1)99+ .ok_or_else(|| "Control v2 request id overflowed".to_string())?;100+ match sender.try_send(ClientControlEnvelopeV2::Keyframe {101+ request_id,102+ last_sequence: None,103+ reason: ControlKeyframeReasonV2::from(reason),104+ }) {80105 Ok(()) => Ok(true),81106 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(false),82107 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {83- Err("Media control channel closed".to_string())108+ Err("Control v2 channel closed".to_string())84109 }85110 }86111 }@@ -0,0 +1,225 @@
1+use std::sync::Arc;2+3+use serde::Serialize;4+use sigil_protocol::{5+ ClientControlEnvelopeV2, FocusCommandActionV2, FocusCommandV2, ServerControlEnvelopeV2,6+ SessionSnapshotV2, read_server_control_v2, write_client_control_v2,7+};8+use tauri::{AppHandle, Emitter};9+10+use crate::commands::state::{SessionControlSender, SessionSnapshotState};11+12+pub(crate) const SESSION_CONTROL_COMMAND_CAPACITY: usize = 4;13+14+15+pub(crate) struct SessionStatePayload {16+ pub(crate) native_generation: u64,17+ pub(crate) snapshot: SessionSnapshotV2,18+}19+20+21+pub(crate) struct FocusCommandResultPayload {22+ pub(crate) native_generation: u64,23+ pub(crate) request_id: u64,24+ pub(crate) accepted: bool,25+ pub(crate) revision: u64,26+ pub(crate) message: Option<String>,27+}28+29+pub(crate) async fn install_initial_snapshot(30+ app: &AppHandle,31+ state: &Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,32+ native_generation: u64,33+ snapshot: SessionSnapshotV2,34+) -> Result<(), String> {35+ snapshot36+ .validate()37+ .map_err(|error| format!("Invalid initial session snapshot: {error}"))?;38+ let payload = SessionStatePayload {39+ native_generation,40+ snapshot: snapshot.clone(),41+ };42+ *state.lock().await = Some((native_generation, snapshot));43+ app.emit("session-state", payload)44+ .map_err(|error| format!("Failed to publish initial session snapshot: {error}"))45+}46+47+pub(crate) async fn run_session_control(48+ app: AppHandle,49+ native_generation: u64,50+ session_id: u64,51+ mut send: iroh::endpoint::SendStream,52+ mut recv: iroh::endpoint::RecvStream,53+ mut commands: tokio::sync::mpsc::Receiver<ClientControlEnvelopeV2>,54+ state: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,55+) {56+ loop {57+ tokio::select! {58+ command = commands.recv() => {59+ let Some(command) = command else { break; };60+ if let Err(error) = write_client_control_v2(&mut send, &command).await {61+ eprintln!("[client] control v2 command write failed: {error}");62+ break;63+ }64+ }65+ envelope = read_server_control_v2(&mut recv) => {66+ match envelope {67+ Ok(Some(ServerControlEnvelopeV2::Snapshot { snapshot })) => {68+ if snapshot.media.generation_id != session_id {69+ eprintln!("[client] control v2 snapshot changed media generation");70+ break;71+ }72+ let mut guard = state.lock().await;73+ let is_current_generation = guard.as_ref().is_some_and(|(generation, _)| {74+ *generation == native_generation75+ });76+ if !is_current_generation {77+ break;78+ }79+ let current_revision = guard80+ .as_ref()81+ .map(|(_, snapshot)| snapshot.revision)82+ .unwrap_or(0);83+ if snapshot.revision <= current_revision {84+ continue;85+ }86+ *guard = Some((native_generation, snapshot.clone()));87+ drop(guard);88+ let _ = app.emit(89+ "session-state",90+ SessionStatePayload { native_generation, snapshot },91+ );92+ }93+ Ok(Some(ServerControlEnvelopeV2::FocusResult { result })) => {94+ let _ = app.emit(95+ "focus-command-result",96+ FocusCommandResultPayload {97+ native_generation,98+ request_id: result.request_id,99+ accepted: result.accepted,100+ revision: result.revision,101+ message: result.message,102+ },103+ );104+ }105+ Ok(None) => break,106+ Err(error) => {107+ eprintln!("[client] invalid control v2 message: {error}");108+ break;109+ }110+ }111+ }112+ }113+ }114+}115+116+pub(crate) async fn send_focus_command(117+ sender: &tokio::sync::Mutex<Option<(u64, SessionControlSender)>>,118+ native_generation: u64,119+ request_id: u64,120+ action: FocusCommandActionV2,121+ expected_revision: u64,122+ expected_focus_generation: Option<u64>,123+) -> Result<bool, String> {124+ let sender = sender125+ .lock()126+ .await127+ .as_ref()128+ .filter(|(generation, _)| *generation == native_generation)129+ .map(|(_, sender)| sender.clone())130+ .ok_or_else(|| "No matching control v2 session is active".to_string())?;131+ let command = ClientControlEnvelopeV2::Focus {132+ command: FocusCommandV2 {133+ request_id,134+ action,135+ slot: sigil_protocol::ControllerSlot::ZERO,136+ expected_revision,137+ expected_focus_generation,138+ },139+ };140+ command141+ .validate()142+ .map_err(|error| format!("Invalid focus command: {error}"))?;143+ match sender.try_send(command) {144+ Ok(()) => Ok(true),145+ Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(false),146+ Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {147+ Err("Control v2 command channel closed".to_string())148+ }149+ }150+}151+152+pub(crate) async fn latest_snapshot(153+ state: &tokio::sync::Mutex<Option<SessionSnapshotState>>,154+ native_generation: u64,155+) -> Option<SessionSnapshotV2> {156+ state157+ .lock()158+ .await159+ .as_ref()160+ .filter(|(generation, _)| *generation == native_generation)161+ .map(|(_, snapshot)| snapshot.clone())162+}163+164+165+mod tests {166+ use super::*;167+ use sigil_protocol::{168+ ControllerSlot, FocusStateV2, FocusTransitionReasonV2, MediaGenerationDescriptorV2,169+ ViewerPresenceId, ViewerPresenceV2,170+ };171+172+ fn snapshot(revision: u64) -> SessionSnapshotV2 {173+ let presence = ViewerPresenceId::new("viewer-1").unwrap();174+ SessionSnapshotV2 {175+ revision,176+ self_presence_id: presence.clone(),177+ viewers: vec![ViewerPresenceV2 {178+ presence_id: presence,179+ session_id: 7,180+ input_capable: true,181+ you: true,182+ }],183+ focus: FocusStateV2::Vacant {184+ slot: ControllerSlot::ZERO,185+ },186+ transition_reason: FocusTransitionReasonV2::Initial,187+ media: MediaGenerationDescriptorV2 {188+ generation_id: 7,189+ broadcast_name: sigil_protocol::media_moq_broadcast_name(7).unwrap(),190+ },191+ }192+ }193+194+ 195+ async fn latest_snapshot_is_owned_by_native_generation() {196+ let state = tokio::sync::Mutex::new(Some((3, snapshot(2))));197+ assert_eq!(latest_snapshot(&state, 3).await.unwrap().revision, 2);198+ assert!(latest_snapshot(&state, 4).await.is_none());199+ }200+201+ 202+ async fn focus_commands_are_bounded_and_revision_bound() {203+ let (sender, mut receiver) = tokio::sync::mpsc::channel(1);204+ let state = tokio::sync::Mutex::new(Some((5, sender)));205+ assert!(206+ send_focus_command(&state, 5, 1, FocusCommandActionV2::Request, 2, None)207+ .await208+ .unwrap()209+ );210+ assert!(211+ !send_focus_command(&state, 5, 2, FocusCommandActionV2::Request, 2, None)212+ .await213+ .unwrap()214+ );215+ assert!(matches!(216+ receiver.recv().await,217+ Some(ClientControlEnvelopeV2::Focus { .. })218+ ));219+ assert!(220+ send_focus_command(&state, 4, 3, FocusCommandActionV2::Request, 2, None)221+ .await222+ .is_err()223+ );224+ }225+}@@ -2,8 +2,9 @@ use std::time::Duration;
22 33 use iroh::Endpoint;44 use sigil_protocol::{5- CONTROL_ALPN_V1, Capability, ClientHello, MEDIA_ALPN_V3, PointerSurfaceDimensions,6- read_host_hello, write_client_hello,5+ CONTROL_ALPN_V1, CONTROL_ALPN_V2, Capability, ClientHello, ClientHelloV2, MEDIA_ALPN_V3,6+ PointerSurfaceDimensions, SessionSnapshotV2, read_host_hello, read_host_hello_v2,7+ write_client_hello, write_client_hello_v2,78 };89 910 pub const MEDIA_TRANSPORT_NAMES: [&str; 2] = ["iroh-moq", "grouped-v3"];@@ -16,6 +17,48 @@ pub(crate) struct NegotiatedV1Stream {
1617 pub(crate) pointer_surface_dimensions: Option<PointerSurfaceDimensions>,1718 }1819 20+pub(crate) struct NegotiatedV2Stream {21+ pub(crate) session_id: u64,22+ pub(crate) pointer_surface_dimensions: Option<PointerSurfaceDimensions>,23+ pub(crate) initial_snapshot: SessionSnapshotV2,24+}25+26+pub(crate) enum NegotiatedMediaStream {27+ V2(NegotiatedV2Stream),28+ V1(NegotiatedV1Stream),29+}30+31+impl NegotiatedMediaStream {32+ pub(crate) fn session_id(&self) -> u64 {33+ match self {34+ Self::V2(stream) => stream.session_id,35+ Self::V1(stream) => stream.session_id,36+ }37+ }38+39+ pub(crate) fn pointer_surface_dimensions(&self) -> Option<PointerSurfaceDimensions> {40+ match self {41+ Self::V2(stream) => stream.pointer_surface_dimensions,42+ Self::V1(stream) => stream.pointer_surface_dimensions,43+ }44+ }45+}46+47+48+pub(crate) enum ControlProtocol {49+ V2,50+ V1,51+}52+53+impl ControlProtocol {54+ pub(crate) const fn diagnostic_name(self) -> &'static str {55+ match self {56+ Self::V2 => "control-v2",57+ Self::V1 => "legacy-v1",58+ }59+ }60+}61+1962 2063 pub(crate) enum MediaTransport {2164 UpstreamMoq,@@ -84,6 +127,54 @@ pub(crate) async fn negotiate_v1(
84127 })85128 }86129 130+async fn negotiate_v2(131+ send: &mut iroh::endpoint::SendStream,132+ recv: &mut iroh::endpoint::RecvStream,133+ nonce: [u8; 16],134+ invitation: Option<&str>,135+) -> Result<NegotiatedV2Stream, String> {136+ let capabilities = vec![Capability::VideoH264];137+ let mut hello = ClientHelloV2::new("portal/0.1.0", nonce, capabilities.clone());138+ if let Some(invitation) = invitation {139+ hello = hello.with_invitation(invitation);140+ }141+ write_client_hello_v2(send, &hello)142+ .await143+ .map_err(|error| format!("Failed to send control v2 handshake: {error}"))?;144+ let response = tokio::time::timeout(Duration::from_secs(10), read_host_hello_v2(recv))145+ .await146+ .map_err(|_| "Timed out waiting for control v2 handshake".to_string())?147+ .map_err(|error| format!("Invalid control v2 handshake: {error}"))?148+ .ok_or_else(|| "Host closed during control v2 handshake".to_string())?;149+ if !response.accepted {150+ return Err(format!(151+ "Host rejected control v2 stream: {}",152+ response.message.as_deref().unwrap_or("unspecified reason")153+ ));154+ }155+ if !response.capabilities.contains(&Capability::VideoH264) {156+ return Err("Host accepted control v2 without required VideoH264 capability".to_string());157+ }158+ if let Some(unoffered) = response159+ .capabilities160+ .iter()161+ .find(|capability| !capabilities.contains(capability))162+ {163+ return Err(format!(164+ "Host accepted unoffered control v2 capability {unoffered:?}"165+ ));166+ }167+ Ok(NegotiatedV2Stream {168+ session_id: response169+ .session_id170+ .ok_or_else(|| "Host omitted control v2 session ID".to_string())?,171+ pointer_surface_dimensions: response.pointer_surface_dimensions,172+ initial_snapshot: response173+ .snapshot174+ .ok_or_else(|| "Host omitted initial control v2 snapshot".to_string())?,175+ })176+}177+87178 fn connection_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectionError) -> bool {88179 matches!(89180 error,@@ -115,8 +206,51 @@ pub(crate) async fn open_negotiated_media_stream(
115206 iroh::endpoint::Connection,116207 iroh::endpoint::RecvStream,117208 iroh::endpoint::SendStream,118- NegotiatedV1Stream,209+ NegotiatedMediaStream,210+ MediaTransport,211+ ControlProtocol,212+ ),213+ String,214+> {215+ match endpoint.connect(address.clone(), CONTROL_ALPN_V2).await {216+ Ok(connection) => {217+ let (mut send, mut recv) = connection218+ .open_bi()219+ .await220+ .map_err(|error| format!("Failed to open control v2 handshake: {error}"))?;221+ let negotiation = negotiate_v2(&mut send, &mut recv, nonce, invitation).await?;222+ Ok((223+ connection,224+ recv,225+ send,226+ NegotiatedMediaStream::V2(negotiation),227+ MediaTransport::UpstreamMoq,228+ ControlProtocol::V2,229+ ))230+ }231+ Err(v2_error) if connect_error_is_unsupported_alpn(&v2_error) => {232+ open_negotiated_v1_or_v3(endpoint, address, nonce, invitation, v2_error).await233+ }234+ Err(v2_error) => Err(format!(235+ "Control v2 connection failed without an explicit unsupported-ALPN signal; refusing an unsafe downgrade: {v2_error}"236+ )),237+ }238+}239+240+async fn open_negotiated_v1_or_v3(241+ endpoint: &Endpoint,242+ address: &iroh::EndpointAddr,243+ nonce: [u8; 16],244+ invitation: Option<&str>,245+ v2_error: iroh::endpoint::ConnectError,246+) -> Result<247+ (248+ iroh::endpoint::Connection,249+ iroh::endpoint::RecvStream,250+ iroh::endpoint::SendStream,251+ NegotiatedMediaStream,119252 MediaTransport,253+ ControlProtocol,120254 ),121255 String,122256 > {@@ -143,8 +277,9 @@ pub(crate) async fn open_negotiated_media_stream(
143277 connection,144278 recv,145279 send,146- negotiation,280+ NegotiatedMediaStream::V1(negotiation),147281 MediaTransport::UpstreamMoq,282+ ControlProtocol::V1,148283 ))149284 }150285 Err(control_error) if connect_error_is_unsupported_alpn(&control_error) => {@@ -154,7 +289,7 @@ pub(crate) async fn open_negotiated_media_stream(
154289 .await155290 .map_err(|v3_error| {156291 format!(157- "Control connection failed ({control_error}); media v3 compatibility connection also failed ({v3_error})"292+ "Control v2 was unsupported ({v2_error}); control v1 failed ({control_error}); media v3 compatibility connection also failed ({v3_error})"158293 )159294 })?;160295 let (mut send, mut recv) = connection@@ -175,12 +310,13 @@ pub(crate) async fn open_negotiated_media_stream(
175310 connection,176311 recv,177312 send,178- negotiation,313+ NegotiatedMediaStream::V1(negotiation),179314 MediaTransport::GroupedObjectsV3,315+ ControlProtocol::V1,180316 ))181317 }182318 Err(control_error) => Err(format!(183- "Control connection failed without an explicit unsupported-ALPN signal; refusing an unsafe media downgrade: {control_error}"319+ "Control v2 was unsupported ({v2_error}); control v1 failed without an explicit unsupported-ALPN signal; refusing an unsafe media downgrade: {control_error}"184320 )),185321 }186322 }@@ -32,9 +32,8 @@ const CLIENT_FRAME_STATS_INTERVAL: Duration = Duration::from_millis(250);
3232 pub(crate) struct VideoDeliveryRequest {3333 pub(crate) app: AppHandle,3434 pub(crate) endpoint: iroh::Endpoint,35- pub(crate) frame_recv: iroh::endpoint::RecvStream,3635 pub(crate) frame_connection: iroh::endpoint::Connection,37- pub(crate) input_connection: iroh::endpoint::Connection,36+ pub(crate) input_connection: Option<iroh::endpoint::Connection>,3837 pub(crate) audio_connection: Option<iroh::endpoint::Connection>,3938 pub(crate) network_diagnostics: Arc<StdMutex<NetworkSessionDiagnostics>>,4039 pub(crate) media_control_requests: Option<MediaControlRequestSender>,@@ -50,9 +49,6 @@ pub(crate) async fn run_video_delivery(request: VideoDeliveryRequest) {
5049 let VideoDeliveryRequest {5150 app,5251 endpoint,53- // The negotiated control/v3 handshake recv leg carries no media but54- // must stay open for the session's lifetime.55- frame_recv: _control_recv_keepalive,5652 frame_connection: frame_connection_for_stats,5753 input_connection,5854 audio_connection: audio_connection_for_stats,@@ -102,7 +98,9 @@ pub(crate) async fn run_video_delivery(request: VideoDeliveryRequest) {
10298 if last_path_sample.elapsed() >= Duration::from_secs(1) {10399 let mut diagnostics = lock_network_diagnostics(&stats_network_diagnostics);104100 diagnostics.observe_connection(NetworkLeg::Media, &stats_connection);105- diagnostics.observe_connection(NetworkLeg::Input, &stats_input_connection);101+ if let Some(input_connection) = stats_input_connection.as_ref() {102+ diagnostics.observe_connection(NetworkLeg::Input, input_connection);103+ }106104 if let Some(audio_connection) = stats_audio_connection.as_ref() {107105 diagnostics.observe_connection(NetworkLeg::Audio, audio_connection);108106 }