cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

refactor(portal): extract media transport

Dami committed 4fad948
commit
4fad948dc6781ce889b51c9d0fb66d9b126e1f9b
parent
5b78b5d2ccdc442a6a829402417bfe14a3da7cec
author
Dami <dami.etoile@gmail.com>
3 files changed, +357 -307
Msrc-tauri/src/commands/network.rs320+---------------------------
Msrc-tauri/src/media/mod.rs1+
Asrc-tauri/src/media/transport.rs343++++++++++++++++++++++++++++++
M src-tauri/src/commands/network.rs +13 -307
@@ -15,6 +15,10 @@ use crate::media::frame_channel::{
1515 use crate::media::metrics::{ClientMediaMetrics, lock_client_media_metrics};1616 use crate::media::moq_catalog::subscribe_goq_video_track;1717 use crate::media::network_diagnostics::{NetworkLeg, NetworkSessionDiagnostics};18+use crate::media::transport::{19+ MediaTransport, NegotiatedV1Stream, connect_error_is_unsupported_alpn, negotiate_v1,20+ open_negotiated_media_stream,21+};1822 use base64::Engine;1923 use iroh::{Endpoint, SecretKey, endpoint::presets};2024 use iroh_moq::{Moq, MoqSession};
@@ -27,16 +31,15 @@ use serde::{Deserialize, Serialize};
2731 use sigil_protocol::MOQ_VIDEO_H264_TRACK;2832 use sigil_protocol::{2933 AUDIO_ALPN_V1, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,30- AudioFlags, AudioPacket, AudioPacketHeader, CONTROL_ALPN_V1, Capability, ClientHello,31- FrameFlags, INPUT_ALPN_V1, InputEvent, InvitationGrants, KeyframeRequestReasonV3,32- MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MAX_MEDIA_OBJECT_ID_V3,33- MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, MediaCodec,34- MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame, MediaObjectV3,35- PointerPosition, PointerSurfaceDimensions, ProtocolError, RELATIVE_POINTER_DELTA_MAX,36- RELATIVE_POINTER_DELTA_MIN, decode_media_frame_object, media_moq_broadcast_name,37- read_adaptive_bitrate_decision_v1, read_host_hello, read_input_ack, read_media_frame,38- read_media_object, read_media_object_v3, write_client_hello, write_input_event,39- write_media_control_request_v3, write_media_feedback_report_v1,34+ AudioFlags, AudioPacket, AudioPacketHeader, Capability, FrameFlags, INPUT_ALPN_V1, InputEvent,35+ InvitationGrants, KeyframeRequestReasonV3, MAX_MEDIA_GROUP_BYTES_V3,36+ MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MAX_MEDIA_OBJECT_ID_V3, MEDIA_FEEDBACK_ALPN_V1,37+ MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,38+ MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError,39+ RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN, decode_media_frame_object,40+ media_moq_broadcast_name, read_adaptive_bitrate_decision_v1, read_input_ack, read_media_frame,41+ read_media_object, read_media_object_v3, write_input_event, write_media_control_request_v3,42+ write_media_feedback_report_v1,4043 };4144 use std::collections::BTreeMap;4245 use std::io::Cursor;
@@ -298,37 +301,6 @@ struct AdaptiveBitrateDecisionDiagnostic {
298301 applied: bool,299302 }300303 301-struct NegotiatedV1Stream {302- session_id: u64,303- capabilities: Vec<Capability>,304- pointer_surface_dimensions: Option<PointerSurfaceDimensions>,305-}306-307-#[derive(Clone, Copy, Debug, PartialEq, Eq)]308-enum MediaTransport {309- UpstreamMoq,310- LegacyV0,311- ReliableStreamV1,312- IndependentObjectsV2,313- GroupedObjectsV3,314-}315-316-impl MediaTransport {317- const fn diagnostic_name(self) -> &'static str {318- match self {319- Self::UpstreamMoq => "iroh-moq",320- Self::LegacyV0 => "reliable-v0",321- Self::ReliableStreamV1 => "reliable-v1",322- Self::IndependentObjectsV2 => "independent-v2",323- Self::GroupedObjectsV3 => "grouped-v3",324- }325- }326-327- const fn supports_adaptive_feedback(self) -> bool {328- matches!(self, Self::UpstreamMoq | Self::GroupedObjectsV3)329- }330-}331-332304 #[derive(Debug)]333305 enum MoqMediaReadOutcome {334306 Frame {
@@ -1287,59 +1259,6 @@ fn stage_relative_input(
12871259 }12881260 }12891261 1290-async fn negotiate_v1(1291- send: &mut iroh::endpoint::SendStream,1292- recv: &mut iroh::endpoint::RecvStream,1293- nonce: [u8; 16],1294- capabilities: Vec<Capability>,1295- required: Option<Capability>,1296- stream_name: &str,1297- invitation: Option<&str>,1298-) -> Result<NegotiatedV1Stream, String> {1299- let mut hello = ClientHello::new("portal/0.1.0", nonce, capabilities.clone());1300- if let Some(invitation) = invitation {1301- hello = hello.with_invitation(invitation);1302- }1303- write_client_hello(send, &hello)1304- .await1305- .map_err(|e| format!("Failed to send {stream_name} handshake: {e}"))?;1306- let response = tokio::time::timeout(Duration::from_secs(10), read_host_hello(recv))1307- .await1308- .map_err(|_| format!("Timed out waiting for {stream_name} handshake"))?1309- .map_err(|e| format!("Invalid {stream_name} handshake: {e}"))?1310- .ok_or_else(|| format!("Host closed during {stream_name} handshake"))?;1311- if !response.accepted {1312- return Err(format!(1313- "Host rejected {stream_name} stream: {}",1314- response.message.as_deref().unwrap_or("unspecified reason")1315- ));1316- }1317- if let Some(required) = required1318- && !response.capabilities.contains(&required)1319- {1320- return Err(format!(1321- "Host accepted {stream_name} without required capability {required:?}"1322- ));1323- }1324- if let Some(unoffered) = response1325- .capabilities1326- .iter()1327- .find(|capability| !capabilities.contains(capability))1328- {1329- return Err(format!(1330- "Host accepted unoffered {stream_name} capability {unoffered:?}"1331- ));1332- }1333- let session_id = response1334- .session_id1335- .ok_or_else(|| format!("Host omitted {stream_name} session ID"))?;1336- Ok(NegotiatedV1Stream {1337- session_id,1338- capabilities: response.capabilities,1339- pointer_surface_dimensions: response.pointer_surface_dimensions,1340- })1341-}1342-13431262 async fn open_negotiated_input_stream(13441263 endpoint: &Endpoint,13451264 address: &iroh::EndpointAddr,
@@ -1375,27 +1294,6 @@ async fn open_negotiated_input_stream(
13751294 Ok((connection, send, recv, negotiation))13761295 }13771296 1378-fn connection_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectionError) -> bool {1379- matches!(1380- error,1381- iroh::endpoint::ConnectionError::ConnectionClosed(close)1382- if close.error_code == iroh::endpoint::TransportErrorCode::crypto(0x78)1383- )1384-}1385-1386-fn connect_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectError) -> bool {1387- match error {1388- iroh::endpoint::ConnectError::Connecting {1389- source: iroh::endpoint::ConnectingError::ConnectionError { source, .. },1390- ..1391- }1392- | iroh::endpoint::ConnectError::Connection { source, .. } => {1393- connection_error_is_unsupported_alpn(source)1394- }1395- _ => false,1396- }1397-}1398-13991297 async fn open_negotiated_feedback_stream(14001298 endpoint: &Endpoint,14011299 address: &iroh::EndpointAddr,
@@ -1639,167 +1537,6 @@ async fn run_media_feedback_session(
16391537 retire_media_feedback_generation(&app, generation).await;16401538 }16411539 1642-async fn open_legacy_negotiated_media_stream(1643- endpoint: &Endpoint,1644- address: &iroh::EndpointAddr,1645- nonce: [u8; 16],1646- invitation: Option<&str>,1647-) -> Result<1648- (1649- iroh::endpoint::Connection,1650- iroh::endpoint::RecvStream,1651- Option<iroh::endpoint::SendStream>,1652- NegotiatedV1Stream,1653- MediaTransport,1654- ),1655- String,1656-> {1657- match endpoint.connect(address.clone(), MEDIA_ALPN_V3).await {1658- Ok(connection) => {1659- let (mut send, mut recv) = connection1660- .open_bi()1661- .await1662- .map_err(|error| format!("Failed to open media v3 handshake: {error}"))?;1663- let negotiation = negotiate_v1(1664- &mut send,1665- &mut recv,1666- nonce,1667- vec![Capability::VideoH264],1668- Some(Capability::VideoH264),1669- "media v3",1670- invitation,1671- )1672- .await?;1673- Ok((1674- connection,1675- recv,1676- Some(send),1677- negotiation,1678- MediaTransport::GroupedObjectsV3,1679- ))1680- }1681- Err(v3_error) if connect_error_is_unsupported_alpn(&v3_error) => {1682- match endpoint.connect(address.clone(), MEDIA_ALPN_V2).await {1683- Ok(connection) => {1684- let (mut send, mut recv) = connection1685- .open_bi()1686- .await1687- .map_err(|error| format!("Failed to open media v2 handshake: {error}"))?;1688- let negotiation = negotiate_v1(1689- &mut send,1690- &mut recv,1691- nonce,1692- vec![Capability::VideoH264],1693- Some(Capability::VideoH264),1694- "media v2",1695- invitation,1696- )1697- .await?;1698- send.finish()1699- .map_err(|error| format!("Failed to finish media v2 handshake: {error}"))?;1700- Ok((1701- connection,1702- recv,1703- None,1704- negotiation,1705- MediaTransport::IndependentObjectsV2,1706- ))1707- }1708- Err(v2_error) if connect_error_is_unsupported_alpn(&v2_error) => {1709- let connection = endpoint1710- .connect(address.clone(), MEDIA_ALPN_V1)1711- .await1712- .map_err(|v1_error| {1713- format!(1714- "Failed to connect media v3 ({v3_error}); v2 compatibility connection failed ({v2_error}); v1 compatibility connection also failed ({v1_error})"1715- )1716- })?;1717- let (mut send, mut recv) = connection1718- .open_bi()1719- .await1720- .map_err(|error| format!("Failed to open media v1 stream: {error}"))?;1721- let negotiation = negotiate_v1(1722- &mut send,1723- &mut recv,1724- nonce,1725- vec![Capability::VideoH264],1726- Some(Capability::VideoH264),1727- "media v1",1728- invitation,1729- )1730- .await?;1731- send.finish()1732- .map_err(|error| format!("Failed to finish media v1 handshake: {error}"))?;1733- Ok((1734- connection,1735- recv,1736- None,1737- negotiation,1738- MediaTransport::ReliableStreamV1,1739- ))1740- }1741- Err(v2_error) => Err(format!(1742- "Media v2 compatibility connection failed without an explicit unsupported-ALPN signal; refusing an unsafe downgrade to v1: {v2_error}"1743- )),1744- }1745- }1746- Err(v3_error) => Err(format!(1747- "Media v3 connection failed without an explicit unsupported-ALPN signal; refusing an unsafe compatibility downgrade: {v3_error}"1748- )),1749- }1750-}1751-1752-async fn open_negotiated_media_stream(1753- endpoint: &Endpoint,1754- address: &iroh::EndpointAddr,1755- nonce: [u8; 16],1756- invitation: Option<&str>,1757-) -> Result<1758- (1759- iroh::endpoint::Connection,1760- iroh::endpoint::RecvStream,1761- Option<iroh::endpoint::SendStream>,1762- NegotiatedV1Stream,1763- MediaTransport,1764- ),1765- String,1766-> {1767- match endpoint.connect(address.clone(), CONTROL_ALPN_V1).await {1768- Ok(connection) => {1769- let (mut send, mut recv) = connection1770- .open_bi()1771- .await1772- .map_err(|error| format!("Failed to open control handshake: {error}"))?;1773- let negotiation = negotiate_v1(1774- &mut send,1775- &mut recv,1776- nonce,1777- vec![Capability::VideoH264],1778- Some(Capability::VideoH264),1779- "control",1780- invitation,1781- )1782- .await?;1783- // CONTROL owns the authenticated host lease. Keep both the1784- // connection and the client->host send leg alive for keyframe1785- // requests while media uses a separate upstream MoQ session.1786- Ok((1787- connection,1788- recv,1789- Some(send),1790- negotiation,1791- MediaTransport::UpstreamMoq,1792- ))1793- }1794- Err(control_error) if connect_error_is_unsupported_alpn(&control_error) => {1795- open_legacy_negotiated_media_stream(endpoint, address, nonce, invitation).await1796- }1797- Err(control_error) => Err(format!(1798- "Control connection failed without an explicit unsupported-ALPN signal; refusing an unsafe media downgrade: {control_error}"1799- )),1800- }1801-}1802-18031540 async fn open_upstream_moq_media(18041541 endpoint: &Endpoint,18051542 address: &iroh::EndpointAddr,
@@ -3690,19 +3427,6 @@ mod tests {
36903427 );36913428 }36923429 3693- #[test]3694- fn upstream_moq_transport_is_distinct_from_legacy_compatibility() {3695- assert_eq!(MediaTransport::UpstreamMoq.diagnostic_name(), "iroh-moq");3696- assert!(MediaTransport::UpstreamMoq.supports_adaptive_feedback());3697- assert!(MediaTransport::GroupedObjectsV3.supports_adaptive_feedback());3698- assert!(!MediaTransport::IndependentObjectsV2.supports_adaptive_feedback());3699- assert_ne!(3700- MediaTransport::UpstreamMoq,3701- MediaTransport::GroupedObjectsV33702- );3703- assert!(decode_media_frame_object(&[0_u8; 8]).is_err());3704- }3705-37063430 fn completed_object_index(outcome: &MediaObjectReadOutcome) -> Option<u64> {37073431 outcome.object_index()37083432 }
@@ -4571,24 +4295,6 @@ mod tests {
45714295 );45724296 }45734297 4574- #[test]4575- fn compatibility_downgrade_requires_tls_no_application_protocol() {4576- let unsupported = iroh::endpoint::ConnectionError::ConnectionClosed(4577- iroh::endpoint::TransportError::new(4578- iroh::endpoint::TransportErrorCode::crypto(0x78),4579- "no application protocol".to_string(),4580- )4581- .into(),4582- );4583- assert!(connection_error_is_unsupported_alpn(&unsupported));4584- assert!(!connection_error_is_unsupported_alpn(4585- &iroh::endpoint::ConnectionError::TimedOut4586- ));4587- assert!(!connection_error_is_unsupported_alpn(4588- &iroh::endpoint::ConnectionError::Reset4589- ));4590- }4591-45924298 #[test]45934299 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {45944300 let view_only = input_capability_offers(InvitationGrants::VIEW);
M src-tauri/src/media/mod.rs +1 -0
A src-tauri/src/media/transport.rs +343 -0
@@ -0,0 +1,343 @@
1+use std::time::Duration;2+3+use iroh::Endpoint;4+#[cfg(test)]5+use sigil_protocol::decode_media_frame_object;6+use sigil_protocol::{7+ CONTROL_ALPN_V1, Capability, ClientHello, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3,8+ PointerSurfaceDimensions, read_host_hello, write_client_hello,9+};10+11+pub const MEDIA_TRANSPORT_NAMES: [&str; 5] = [12+ "iroh-moq",13+ "reliable-v0",14+ "reliable-v1",15+ "independent-v2",16+ "grouped-v3",17+];18+19+pub(crate) struct NegotiatedV1Stream {20+ pub(crate) session_id: u64,21+ pub(crate) capabilities: Vec<Capability>,22+ pub(crate) pointer_surface_dimensions: Option<PointerSurfaceDimensions>,23+}24+25+#[derive(Clone, Copy, Debug, PartialEq, Eq)]26+pub(crate) enum MediaTransport {27+ UpstreamMoq,28+ LegacyV0,29+ ReliableStreamV1,30+ IndependentObjectsV2,31+ GroupedObjectsV3,32+}33+34+impl MediaTransport {35+ pub(crate) const fn diagnostic_name(self) -> &'static str {36+ match self {37+ Self::UpstreamMoq => MEDIA_TRANSPORT_NAMES[0],38+ Self::LegacyV0 => MEDIA_TRANSPORT_NAMES[1],39+ Self::ReliableStreamV1 => MEDIA_TRANSPORT_NAMES[2],40+ Self::IndependentObjectsV2 => MEDIA_TRANSPORT_NAMES[3],41+ Self::GroupedObjectsV3 => MEDIA_TRANSPORT_NAMES[4],42+ }43+ }44+45+ pub(crate) const fn supports_adaptive_feedback(self) -> bool {46+ matches!(self, Self::UpstreamMoq | Self::GroupedObjectsV3)47+ }48+}49+50+pub(crate) async fn negotiate_v1(51+ send: &mut iroh::endpoint::SendStream,52+ recv: &mut iroh::endpoint::RecvStream,53+ nonce: [u8; 16],54+ capabilities: Vec<Capability>,55+ required: Option<Capability>,56+ stream_name: &str,57+ invitation: Option<&str>,58+) -> Result<NegotiatedV1Stream, String> {59+ let mut hello = ClientHello::new("portal/0.1.0", nonce, capabilities.clone());60+ if let Some(invitation) = invitation {61+ hello = hello.with_invitation(invitation);62+ }63+ write_client_hello(send, &hello)64+ .await65+ .map_err(|e| format!("Failed to send {stream_name} handshake: {e}"))?;66+ let response = tokio::time::timeout(Duration::from_secs(10), read_host_hello(recv))67+ .await68+ .map_err(|_| format!("Timed out waiting for {stream_name} handshake"))?69+ .map_err(|e| format!("Invalid {stream_name} handshake: {e}"))?70+ .ok_or_else(|| format!("Host closed during {stream_name} handshake"))?;71+ if !response.accepted {72+ return Err(format!(73+ "Host rejected {stream_name} stream: {}",74+ response.message.as_deref().unwrap_or("unspecified reason")75+ ));76+ }77+ if let Some(required) = required78+ && !response.capabilities.contains(&required)79+ {80+ return Err(format!(81+ "Host accepted {stream_name} without required capability {required:?}"82+ ));83+ }84+ if let Some(unoffered) = response85+ .capabilities86+ .iter()87+ .find(|capability| !capabilities.contains(capability))88+ {89+ return Err(format!(90+ "Host accepted unoffered {stream_name} capability {unoffered:?}"91+ ));92+ }93+ let session_id = response94+ .session_id95+ .ok_or_else(|| format!("Host omitted {stream_name} session ID"))?;96+ Ok(NegotiatedV1Stream {97+ session_id,98+ capabilities: response.capabilities,99+ pointer_surface_dimensions: response.pointer_surface_dimensions,100+ })101+}102+103+fn connection_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectionError) -> bool {104+ matches!(105+ error,106+ iroh::endpoint::ConnectionError::ConnectionClosed(close)107+ if close.error_code == iroh::endpoint::TransportErrorCode::crypto(0x78)108+ )109+}110+111+pub(crate) fn connect_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectError) -> bool {112+ match error {113+ iroh::endpoint::ConnectError::Connecting {114+ source: iroh::endpoint::ConnectingError::ConnectionError { source, .. },115+ ..116+ }117+ | iroh::endpoint::ConnectError::Connection { source, .. } => {118+ connection_error_is_unsupported_alpn(source)119+ }120+ _ => false,121+ }122+}123+124+async fn open_legacy_negotiated_media_stream(125+ endpoint: &Endpoint,126+ address: &iroh::EndpointAddr,127+ nonce: [u8; 16],128+ invitation: Option<&str>,129+) -> Result<130+ (131+ iroh::endpoint::Connection,132+ iroh::endpoint::RecvStream,133+ Option<iroh::endpoint::SendStream>,134+ NegotiatedV1Stream,135+ MediaTransport,136+ ),137+ String,138+> {139+ match endpoint.connect(address.clone(), MEDIA_ALPN_V3).await {140+ Ok(connection) => {141+ let (mut send, mut recv) = connection142+ .open_bi()143+ .await144+ .map_err(|error| format!("Failed to open media v3 handshake: {error}"))?;145+ let negotiation = negotiate_v1(146+ &mut send,147+ &mut recv,148+ nonce,149+ vec![Capability::VideoH264],150+ Some(Capability::VideoH264),151+ "media v3",152+ invitation,153+ )154+ .await?;155+ Ok((156+ connection,157+ recv,158+ Some(send),159+ negotiation,160+ MediaTransport::GroupedObjectsV3,161+ ))162+ }163+ Err(v3_error) if connect_error_is_unsupported_alpn(&v3_error) => {164+ match endpoint.connect(address.clone(), MEDIA_ALPN_V2).await {165+ Ok(connection) => {166+ let (mut send, mut recv) = connection167+ .open_bi()168+ .await169+ .map_err(|error| format!("Failed to open media v2 handshake: {error}"))?;170+ let negotiation = negotiate_v1(171+ &mut send,172+ &mut recv,173+ nonce,174+ vec![Capability::VideoH264],175+ Some(Capability::VideoH264),176+ "media v2",177+ invitation,178+ )179+ .await?;180+ send.finish()181+ .map_err(|error| format!("Failed to finish media v2 handshake: {error}"))?;182+ Ok((183+ connection,184+ recv,185+ None,186+ negotiation,187+ MediaTransport::IndependentObjectsV2,188+ ))189+ }190+ Err(v2_error) if connect_error_is_unsupported_alpn(&v2_error) => {191+ let connection = endpoint192+ .connect(address.clone(), MEDIA_ALPN_V1)193+ .await194+ .map_err(|v1_error| {195+ format!(196+ "Failed to connect media v3 ({v3_error}); v2 compatibility connection failed ({v2_error}); v1 compatibility connection also failed ({v1_error})"197+ )198+ })?;199+ let (mut send, mut recv) = connection200+ .open_bi()201+ .await202+ .map_err(|error| format!("Failed to open media v1 stream: {error}"))?;203+ let negotiation = negotiate_v1(204+ &mut send,205+ &mut recv,206+ nonce,207+ vec![Capability::VideoH264],208+ Some(Capability::VideoH264),209+ "media v1",210+ invitation,211+ )212+ .await?;213+ send.finish()214+ .map_err(|error| format!("Failed to finish media v1 handshake: {error}"))?;215+ Ok((216+ connection,217+ recv,218+ None,219+ negotiation,220+ MediaTransport::ReliableStreamV1,221+ ))222+ }223+ Err(v2_error) => Err(format!(224+ "Media v2 compatibility connection failed without an explicit unsupported-ALPN signal; refusing an unsafe downgrade to v1: {v2_error}"225+ )),226+ }227+ }228+ Err(v3_error) => Err(format!(229+ "Media v3 connection failed without an explicit unsupported-ALPN signal; refusing an unsafe compatibility downgrade: {v3_error}"230+ )),231+ }232+}233+234+pub(crate) async fn open_negotiated_media_stream(235+ endpoint: &Endpoint,236+ address: &iroh::EndpointAddr,237+ nonce: [u8; 16],238+ invitation: Option<&str>,239+) -> Result<240+ (241+ iroh::endpoint::Connection,242+ iroh::endpoint::RecvStream,243+ Option<iroh::endpoint::SendStream>,244+ NegotiatedV1Stream,245+ MediaTransport,246+ ),247+ String,248+> {249+ match endpoint.connect(address.clone(), CONTROL_ALPN_V1).await {250+ Ok(connection) => {251+ let (mut send, mut recv) = connection252+ .open_bi()253+ .await254+ .map_err(|error| format!("Failed to open control handshake: {error}"))?;255+ let negotiation = negotiate_v1(256+ &mut send,257+ &mut recv,258+ nonce,259+ vec![Capability::VideoH264],260+ Some(Capability::VideoH264),261+ "control",262+ invitation,263+ )264+ .await?;265+ // CONTROL owns the authenticated host lease. Keep both the266+ // connection and the client->host send leg alive for keyframe267+ // requests while media uses a separate upstream MoQ session.268+ Ok((269+ connection,270+ recv,271+ Some(send),272+ negotiation,273+ MediaTransport::UpstreamMoq,274+ ))275+ }276+ Err(control_error) if connect_error_is_unsupported_alpn(&control_error) => {277+ open_legacy_negotiated_media_stream(endpoint, address, nonce, invitation).await278+ }279+ Err(control_error) => Err(format!(280+ "Control connection failed without an explicit unsupported-ALPN signal; refusing an unsafe media downgrade: {control_error}"281+ )),282+ }283+}284+285+#[cfg(test)]286+mod tests {287+ use super::*;288+289+ #[test]290+ fn upstream_moq_transport_is_distinct_from_legacy_compatibility() {291+ assert_eq!(MediaTransport::UpstreamMoq.diagnostic_name(), "iroh-moq");292+ assert!(MediaTransport::UpstreamMoq.supports_adaptive_feedback());293+ assert!(MediaTransport::GroupedObjectsV3.supports_adaptive_feedback());294+ assert!(!MediaTransport::IndependentObjectsV2.supports_adaptive_feedback());295+ assert_ne!(296+ MediaTransport::UpstreamMoq,297+ MediaTransport::GroupedObjectsV3298+ );299+ assert!(decode_media_frame_object(&[0_u8; 8]).is_err());300+ }301+302+ #[test]303+ fn compatibility_downgrade_requires_tls_no_application_protocol() {304+ let unsupported = iroh::endpoint::ConnectionError::ConnectionClosed(305+ iroh::endpoint::TransportError::new(306+ iroh::endpoint::TransportErrorCode::crypto(0x78),307+ "no application protocol".to_string(),308+ )309+ .into(),310+ );311+ assert!(connection_error_is_unsupported_alpn(&unsupported));312+ assert!(!connection_error_is_unsupported_alpn(313+ &iroh::endpoint::ConnectionError::TimedOut314+ ));315+ assert!(!connection_error_is_unsupported_alpn(316+ &iroh::endpoint::ConnectionError::Reset317+ ));318+ }319+320+ #[test]321+ fn media_transport_names_match_diagnostic_mapping() {322+ assert_eq!(323+ MEDIA_TRANSPORT_NAMES,324+ [325+ "iroh-moq",326+ "reliable-v0",327+ "reliable-v1",328+ "independent-v2",329+ "grouped-v3",330+ ]331+ );332+ assert_eq!(333+ [334+ MediaTransport::UpstreamMoq.diagnostic_name(),335+ MediaTransport::LegacyV0.diagnostic_name(),336+ MediaTransport::ReliableStreamV1.diagnostic_name(),337+ MediaTransport::IndependentObjectsV2.diagnostic_name(),338+ MediaTransport::GroupedObjectsV3.diagnostic_name(),339+ ],340+ MEDIA_TRANSPORT_NAMES,341+ );342+ }343+}