- commit
- cd9cb2e2666b8f000c551e670f088462ca3fc0d5
- parent
- ccaf747f3ab708dbb429a052c32452ba89e06d52
- author
- Dami <dami.etoile@gmail.com>
| M | src-tauri/ | 514 | +----------------------------- |
| A | src-tauri/ | 516 | ++++++++++++++++++++++++++++++ |
| M | src-tauri/ | 1 | + |
@@ -1,99 +1,24 @@
1-use super::auth::derive_iroh_secret_from_key;2-use super::enrollment::{connection_enrollment, mark_invitation_redeemed};3-use super::state::{AppState, FRAME_ALPN, MediaFeedbackSender, development_direct_node_available};1+use super::state::AppState;42 pub use crate::media::adaptive_feedback::ClientMediaFeedbackReport;5-use crate::media::adaptive_feedback::{6- CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT, open_negotiated_feedback_stream,7- run_media_feedback_session, send_media_feedback,8-};9-use crate::media::audio_delivery::{10- self, AudioStartRequest, lock_audio_deliveries, next_audio_generation, try_start_audio,11-};3+use crate::media::adaptive_feedback::send_media_feedback;4+use crate::media::audio_delivery;5+pub use crate::media::connect::ConnectResult;6+use crate::media::connect::{connect_client, disconnect_client};127 138 pub use crate::media::frame_channel::FramePayload;14-use crate::media::frame_channel::{15- close_generation_connection, next_media_generation, release_frame_channel_slot_for_generation,16-};17-use crate::media::input_delivery::{18- CLIENT_INPUT_QUEUE_CAPACITY, InputSession, open_input_session, run_input_feedback,19- run_input_forwarder, send_input,20-};9+use crate::media::frame_channel::release_frame_channel_slot_for_generation;10+use crate::media::input_delivery::send_input;2111 2212 pub use crate::media::input_delivery::{PointerFeedbackPayload, PointerFeedbackTerminalReason};23-use crate::media::media_control::{request_keyframe, run_media_control_writer_v3};24-use crate::media::moq_receiver::open_upstream_moq_media;25-use crate::media::network_diagnostics::NetworkSessionDiagnostics;26-use crate::media::transport::{27- CLIENT_ENDPOINT_CLOSE_TIMEOUT, MediaTransport, open_negotiated_media_stream,28-};29-use crate::media::video_delivery::{VideoDeliveryRequest, run_video_delivery};30-use iroh::{Endpoint, SecretKey, endpoint::presets};31-use serde::Serialize;32-use sigil_protocol::{33- InputEvent, InvitationGrants, KeyframeRequestReasonV3, PointerSurfaceDimensions,34-};35-use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};36-use std::sync::{Arc, Mutex as StdMutex};37-use std::time::{Duration, Instant};13+use crate::media::media_control::request_keyframe;14+use sigil_protocol::InputEvent;3815 use tauri::{39- AppHandle, Emitter, State,16+ AppHandle, State,4017 ipc::{Channel, Response},4118 };4219 4320 // ─── Client commands ──────────────────────────────────────────────────────────4421 45-struct ClientConnectGuard {46- active: Arc<AtomicBool>,47- committed: bool,48-}49-50-impl ClientConnectGuard {51- fn acquire(active: Arc<AtomicBool>) -> Result<Self, String> {52- active53- .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)54- .map_err(|_| "A client connection is already active or in progress".to_string())?;55- Ok(Self {56- active,57- committed: false,58- })59- }60-61- fn commit(mut self) {62- self.committed = true;63- }64-}65-66-impl Drop for ClientConnectGuard {67- fn drop(&mut self) {68- if !self.committed {69- self.active.store(false, Ordering::SeqCst);70- }71- }72-}73-74-75-pub struct ConnectResult {76- pub connected: bool,77- pub host_node_id: Option<String>,78- pub development_mode: bool,79- pub media_transport: &'static str,80- pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,81- pub relative_pointer_available: bool,82- pub pointer_position_feedback_available: bool,83- pub absolute_pointer_available: bool,84- pub keyboard_available: bool,85- pub text_available: bool,86- pub gamepad_available: bool,87- pub control_available: bool,88- pub audio_available: bool,89- pub audio_generation: Option<u64>,90- pub audio_error: Option<String>,91- pub media_generation: u64,92- pub adaptive_feedback_available: bool,93- pub adaptive_feedback_error: Option<String>,94- pub error: Option<String>,95-}96-9722 9823 pub async fn iroh_client_connect(9924 app: AppHandle,@@ -104,379 +29,16 @@ pub async fn iroh_client_connect(
10429 pointer_channel: Channel<PointerFeedbackPayload>,10530 audio_supported: bool,10631 ) -> Result<ConnectResult, String> {107- let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {108- "Another client connection or disconnection operation is in progress".to_string()109- })?;110- let connect_guard = ClientConnectGuard::acquire(Arc::clone(&state.client_connection_active))?;111-112- let (client_secret, development_mode) = if let Some(node_id) = state.dev_connect_node_id {113- if !development_direct_node_available() {114- return Err(115- "Development direct-node routing requires a debug build or the explicit demo-direct-node feature"116- .to_string(),117- );118- }119- let _ = app.emit(120- "dev-connect-routing",121- serde_json::json!({122- "host_node_id": node_id.to_string(),123- "warning": "Passkey identity lookup skipped; this is not client authorization."124- }),125- );126- (SecretKey::generate(), true)127- } else {128- // FIDO2 derivation — 30s timeout so a missing/stuck key surfaces quickly.129- let client_secret = tokio::time::timeout(130- Duration::from_secs(30),131- tokio::task::spawn_blocking(move || derive_iroh_secret_from_key(&pin)),132- )133- .await134- .map_err(|_| "Security key timed out (30s). Make sure your key is connected.".to_string())?135- .map_err(|e| format!("Task failed: {}", e))?136- .map_err(|e| format!("FIDO2 error: {:?}", e))?;137-138- // Key has been tapped — relay connection is next; update the UI overlay.139- let _ = app.emit("fido-done", ());140- (client_secret, false)141- };142-143- let (host_node_id, grants, invitation) = if development_mode {144- (145- state146- .dev_connect_node_id147- .ok_or_else(|| "Development host routing disappeared".to_string())?,148- InvitationGrants::ALL,149- None,150- )151- } else {152- let enrollment = connection_enrollment(&app, client_secret.public())?;153- (154- enrollment.host_node_id,155- enrollment.grants,156- enrollment.pending_invitation,157- )158- };159- let mut handshake_nonce = [0_u8; 16];160- getrandom::fill(&mut handshake_nonce)161- .map_err(|error| format!("Failed to generate handshake nonce: {error}"))?;162- let endpoint = Endpoint::builder(presets::N0)163- .secret_key(client_secret)164- .bind()165- .await166- .map_err(|e| format!("Failed to bind endpoint: {}", e))?;167-168- let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;169-170- // Use just the node ID — the presets::N0 relay map handles geographic171- // routing and fallback across all N0 relays automatically.172- let addr = iroh::EndpointAddr::new(host_node_id);173-174- // Public Sigil authorization exists only on the bounded, negotiated v1175- // protocols. The inherited v0 leg is retained below solely as migration176- // code and is no longer selected by an ordinary Portal connection.177- let use_v1 = true;178- let (frame_conn, frame_recv, media_control_stream, media_negotiation, media_transport) =179- if use_v1 {180- let first_attempt = open_negotiated_media_stream(181- &endpoint,182- &addr,183- handshake_nonce,184- invitation.as_deref(),185- )186- .await;187- let (connection, recv, control, negotiation, transport) = match first_attempt {188- Ok(result) => result,189- Err(invitation_error)190- if invitation.is_some()191- && invitation_error.contains("Portal peer is not authorized") =>192- {193- // Recover only the narrow crash window where Sigil durably194- // consumed the invitation but Portal did not durably clear it.195- // The replay itself remains rejected; a second, ticket-free196- // connection can succeed only as the already-enrolled Iroh197- // peer authenticated by the exact invited host.198- open_negotiated_media_stream(&endpoint, &addr, handshake_nonce, None)199- .await200- .map_err(|retry_error| {201- format!(202- "{invitation_error}; ticket-free enrollment recovery also failed: {retry_error}"203- )204- })?205- }206- Err(error) => return Err(error),207- };208- (connection, recv, control, Some(negotiation), transport)209- } else {210- let connection = endpoint211- .connect(addr.clone(), FRAME_ALPN)212- .await213- .map_err(|e| format!("Failed to connect frame stream: {e}"))?;214- let (mut send, recv) = connection215- .open_bi()216- .await217- .map_err(|e| format!("Failed to open frame stream: {e}"))?;218- send.write_all(&[1u8])219- .await220- .map_err(|e| format!("Failed to send start: {e}"))?;221- send.finish()222- .map_err(|e| format!("Failed to finish frame start stream: {e}"))?;223- (connection, recv, None, None, MediaTransport::LegacyV0)224- };225- let media_session_id = media_negotiation226- .as_ref()227- .map(|negotiation| negotiation.session_id);228- let (upstream_moq_media, frame_connection_for_stats) = if media_transport229- == MediaTransport::UpstreamMoq230- {231- let session_id = media_session_id232- .ok_or_else(|| "Host omitted the control session ID required by MoQ".to_string())?;233- let (receiver, diagnostics_connection) =234- match open_upstream_moq_media(&endpoint, &addr, session_id).await {235- Ok(media) => media,236- Err(error) => {237- // CONTROL already authenticated and owns the host's238- // one-client lease. A post-auth MoQ failure is239- // terminal, and must explicitly release that lease;240- // it must never fall through to a legacy media ALPN.241- frame_conn.close(1_u32.into(), b"upstream MoQ setup failed");242- let _ =243- tokio::time::timeout(CLIENT_ENDPOINT_CLOSE_TIMEOUT, endpoint.close()).await;244- return Err(error);245- }246- };247- (Some(receiver), diagnostics_connection)248- } else {249- (None, frame_conn.clone())250- };251- let pointer_surface_dimensions = media_negotiation252- .as_ref()253- .and_then(|negotiation| negotiation.pointer_surface_dimensions);254- if !development_mode && let Some(expected_invitation) = invitation.as_deref() {255- // An accepted media hello means Sigil durably committed the one-time256- // enrollment before returning. Future PIN/tap/play sessions send no257- // bearer credential and authenticate by the stable Iroh peer instead.258- mark_invitation_redeemed(&app, expected_invitation)?;259- }260-261- // Feedback is a v3 sidecar for both the preferred upstream-MoQ transport262- // and the grouped-v3 compatibility path. Unsupported ALPN is normal263- // compatibility with older Sigil hosts; all other failures remain visible264- // diagnostics but never downgrade the authenticated media session.265- let (adaptive_feedback_stream, adaptive_feedback_error) = if media_transport266- .supports_adaptive_feedback()267- {268- match tokio::time::timeout(269- CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT,270- open_negotiated_feedback_stream(271- &endpoint,272- &addr,273- handshake_nonce,274- media_session_id.ok_or_else(|| "Media v3 omitted its session ID".to_string())?,275- ),276- )277- .await278- {279- Ok(Ok(stream)) => (stream, None),280- Ok(Err(error)) => {281- eprintln!("[client] adaptive feedback unavailable: {error}");282- (None, Some(error))283- }284- Err(_) => {285- let error = "Adaptive feedback negotiation timed out".to_string();286- eprintln!("[client] adaptive feedback unavailable: {error}");287- (None, Some(error))288- }289- }290- } else {291- (None, None)292- };293-294- let InputSession {295- connection: input_connection,296- send: input_send,297- recv: input_recv,298- capabilities: input_capabilities,299- availability: input_availability,300- } = open_input_session(301- &endpoint,302- &addr,303- handshake_nonce,304- media_session_id,305- grants,306- use_v1,307- )308- .await?;309-310- let network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(311- Instant::now(),312- input_availability.input_ack,313- )));314-315- if input_availability.pointer_position_feedback || input_availability.input_ack {316- let feedback_diagnostics = Arc::clone(&network_diagnostics);317- let pointer_feedback_enabled = input_availability.pointer_position_feedback;318- let input_ack_enabled = input_availability.input_ack;319- tokio::spawn(run_input_feedback(320- input_recv,321- feedback_diagnostics,322- pointer_feedback_enabled,323- input_ack_enabled,324- pointer_channel,325- ));326- } else {327- drop(input_recv);328- }329-330- let audio_generation = next_audio_generation(&state.audio_connection_generation)?;331- lock_audio_deliveries(&state.audio_deliveries).begin_generation(audio_generation)?;332- let audio_result = try_start_audio(333- app.clone(),334- &endpoint,335- AudioStartRequest {336- address: addr.clone(),337- handshake_nonce,338- media_session_id,339- audio_supported,340- audio_channel,341- audio_deliveries: Arc::clone(&state.audio_deliveries),342- connection_generation: Arc::clone(&state.audio_connection_generation),343- generation: audio_generation,344- },345- )346- .await;347- let mut audio_connection_for_stats = None;348- let (audio_available, connected_audio_generation, audio_error) = match audio_result {349- Ok(connection) => {350- audio_connection_for_stats = Some(connection.clone());351- *state.audio_connection.lock().await = Some((audio_generation, connection));352- (true, Some(audio_generation), None)353- }354- Err(error) => {355- lock_audio_deliveries(&state.audio_deliveries).cancel_generation(audio_generation);356- (false, None, Some(error))357- }358- };359- let media_generation = next_media_generation(&state.client_media_generation)?;360-361- let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);362- {363- let mut input_send_guard = state.input_send.lock().await;364- *input_send_guard = Some(tx);365- }366-367- {368- let mut ce = state.client_endpoint.lock().await;369- *ce = Some(endpoint.clone());370- }371- *state.media_connection.lock().await = Some((media_generation, frame_conn.clone()));372- let adaptive_feedback_available = adaptive_feedback_stream.is_some();373- if let Some((connection, send, recv)) = adaptive_feedback_stream {374- let (feedback_tx, feedback_rx) = tokio::sync::watch::channel(None);375- let feedback_sender: MediaFeedbackSender = feedback_tx;376- *state.media_feedback.lock().await =377- Some((media_generation, connection, feedback_sender.clone()));378- tokio::spawn(run_media_feedback_session(379- app.clone(),380- media_generation,381- send,382- recv,383- feedback_rx,384- ));385- } else {386- *state.media_feedback.lock().await = None;387- }388- let media_control_requests = if let Some(control_stream) = media_control_stream {389- let (control_tx, control_rx) = tokio::sync::mpsc::channel(1);390- *state.media_control.lock().await = Some((media_generation, control_tx.clone()));391- tokio::spawn(run_media_control_writer_v3(control_stream, control_rx));392- if media_transport == MediaTransport::GroupedObjectsV3 {393- let _ = control_tx.try_send((KeyframeRequestReasonV3::Join, None));394- }395- Some(control_tx)396- } else {397- *state.media_control.lock().await = None;398- None399- };400- let frame_events_in_flight = Arc::new(AtomicUsize::new(0));401- *state.frame_delivery.lock().await =402- Some((media_generation, Arc::clone(&frame_events_in_flight)));403-404- // Input forwarder: absolute motion is latest-value state and may be405- // dropped at the 60 Hz boundary. Relative motion is displacement, so it406- // owns a separate accumulator and timer that coalesces rather than drops.407- let input_send_diagnostics = Arc::clone(&network_diagnostics);408- tokio::spawn(run_input_forwarder(409- input_send,410- rx,411- use_v1,412- input_capabilities,413- input_send_diagnostics,414- ));415-416- // Frame reader — dual path: WebCodecs (raw bytes) or software JPEG decode417- let use_webcodecs = state.webcodecs.load(Ordering::SeqCst);418- tokio::spawn(run_video_delivery(VideoDeliveryRequest {32+ connect_client(41933 app,420- endpoint,421- frame_recv,422- frame_connection: frame_connection_for_stats,423- input_connection,424- audio_connection: audio_connection_for_stats,425- network_diagnostics,426- media_control_requests,427- upstream_moq_media,428- frame_events_in_flight,34+ &state,35+ pin,42936 frame_channel,430- media_transport,431- media_generation,432- connected_audio_generation,433- use_webcodecs,434- }));435-436- let result = ConnectResult {437- connected: true,438- host_node_id: Some(host_node_id.to_string()),439- development_mode,440- media_transport: media_transport.diagnostic_name(),441- pointer_surface_dimensions,442- relative_pointer_available: input_availability.relative_pointer,443- pointer_position_feedback_available: input_availability.pointer_position_feedback,444- absolute_pointer_available: input_availability.absolute_pointer,445- keyboard_available: input_availability.keyboard,446- text_available: input_availability.text,447- gamepad_available: input_availability.gamepad,448- control_available: input_availability.control,449- audio_available,450- audio_generation: connected_audio_generation,451- audio_error,452- media_generation,453- adaptive_feedback_available,454- adaptive_feedback_error,455- error: None,456- };457- connect_guard.commit();458- Ok(result)459-}460-461-462-mod tests {463- use super::*;464-465- 466- fn client_connect_guard_rejects_overlap_and_resets_only_failed_attempts() {467- let active = Arc::new(AtomicBool::new(false));468- let attempt = ClientConnectGuard::acquire(Arc::clone(&active)).unwrap();469- assert!(ClientConnectGuard::acquire(Arc::clone(&active)).is_err());470- drop(attempt);471- assert!(!active.load(Ordering::SeqCst));472-473- ClientConnectGuard::acquire(Arc::clone(&active))474- .unwrap()475- .commit();476- assert!(active.load(Ordering::SeqCst));477- assert!(ClientConnectGuard::acquire(Arc::clone(&active)).is_err());478- active.store(false, Ordering::SeqCst);479- }37+ audio_channel,38+ pointer_channel,39+ audio_supported,40+ )41+ .await48042 }48143 48244 @@ -544,43 +106,5 @@ pub async fn iroh_client_stop_audio(
544106 545107 546108 pub async fn iroh_client_disconnect(state: State<'_, AppState>) -> Result<bool, String> {547- let _connection_serial = state.client_connection_serial.lock().await;548- next_audio_generation(&state.audio_connection_generation)?;549- lock_audio_deliveries(&state.audio_deliveries).clear();550- *state.media_control.lock().await = None;551- if let Some((_generation, connection, _sender)) = state.media_feedback.lock().await.take() {552- connection.close(0_u32.into(), b"client disconnected");553- }554- // Do not rely on endpoint shutdown alone to retire the session. The frame555- // reader and diagnostics task both own connection clones, and a surviving556- // media connection keeps the host's encoder and one-client lease alive.557- // Closing the media connection explicitly gives the host an immediate,558- // protocol-level session boundary.559- close_generation_connection(state.media_connection.lock().await.take(), |connection| {560- connection.close(0_u32.into(), b"client disconnected");561- });562- close_generation_connection(state.audio_connection.lock().await.take(), |connection| {563- connection.close(0_u32.into(), b"client disconnected");564- });565- {566- let mut ce = state.client_endpoint.lock().await;567- if let Some(endpoint) = ce.take()568- && tokio::time::timeout(CLIENT_ENDPOINT_CLOSE_TIMEOUT, endpoint.close())569- .await570- .is_err()571- {572- eprintln!(573- "[client] timed out waiting for endpoint shutdown after explicit connection close"574- );575- }576- }577- {578- let mut input_send = state.input_send.lock().await;579- *input_send = None;580- }581- *state.frame_delivery.lock().await = None;582- state583- .client_connection_active584- .store(false, Ordering::SeqCst);585- Ok(true)109+ disconnect_client(&state).await586110 }@@ -0,0 +1,516 @@
1+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};2+use std::sync::{Arc, Mutex as StdMutex};3+use std::time::{Duration, Instant};4+5+use iroh::{Endpoint, SecretKey, endpoint::presets};6+use serde::Serialize;7+use sigil_protocol::{8+ InputEvent, InvitationGrants, KeyframeRequestReasonV3, PointerSurfaceDimensions,9+};10+use tauri::{11+ AppHandle, Emitter,12+ ipc::{Channel, Response},13+};14+15+use crate::commands::auth::derive_iroh_secret_from_key;16+use crate::commands::enrollment::{connection_enrollment, mark_invitation_redeemed};17+use crate::commands::state::{18+ AppState, FRAME_ALPN, MediaFeedbackSender, development_direct_node_available,19+};20+use crate::media::adaptive_feedback::{21+ CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT, open_negotiated_feedback_stream,22+ run_media_feedback_session,23+};24+use crate::media::audio_delivery::{25+ AudioStartRequest, lock_audio_deliveries, next_audio_generation, try_start_audio,26+};27+use crate::media::frame_channel::{close_generation_connection, next_media_generation};28+use crate::media::input_delivery::{29+ CLIENT_INPUT_QUEUE_CAPACITY, InputSession, PointerFeedbackPayload, open_input_session,30+ run_input_feedback, run_input_forwarder,31+};32+use crate::media::media_control::run_media_control_writer_v3;33+use crate::media::moq_receiver::open_upstream_moq_media;34+use crate::media::network_diagnostics::NetworkSessionDiagnostics;35+use crate::media::transport::{36+ CLIENT_ENDPOINT_CLOSE_TIMEOUT, MediaTransport, open_negotiated_media_stream,37+};38+use crate::media::video_delivery::{VideoDeliveryRequest, run_video_delivery};39+40+struct ClientConnectGuard {41+ active: Arc<AtomicBool>,42+ committed: bool,43+}44+45+impl ClientConnectGuard {46+ fn acquire(active: Arc<AtomicBool>) -> Result<Self, String> {47+ active48+ .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)49+ .map_err(|_| "A client connection is already active or in progress".to_string())?;50+ Ok(Self {51+ active,52+ committed: false,53+ })54+ }55+56+ fn commit(mut self) {57+ self.committed = true;58+ }59+}60+61+impl Drop for ClientConnectGuard {62+ fn drop(&mut self) {63+ if !self.committed {64+ self.active.store(false, Ordering::SeqCst);65+ }66+ }67+}68+69+70+pub struct ConnectResult {71+ pub connected: bool,72+ pub host_node_id: Option<String>,73+ pub development_mode: bool,74+ pub media_transport: &'static str,75+ pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,76+ pub relative_pointer_available: bool,77+ pub pointer_position_feedback_available: bool,78+ pub absolute_pointer_available: bool,79+ pub keyboard_available: bool,80+ pub text_available: bool,81+ pub gamepad_available: bool,82+ pub control_available: bool,83+ pub audio_available: bool,84+ pub audio_generation: Option<u64>,85+ pub audio_error: Option<String>,86+ pub media_generation: u64,87+ pub adaptive_feedback_available: bool,88+ pub adaptive_feedback_error: Option<String>,89+ pub error: Option<String>,90+}91+92+pub(crate) async fn connect_client(93+ app: AppHandle,94+ state: &AppState,95+ pin: String,96+ frame_channel: Channel<Response>,97+ audio_channel: Channel<Response>,98+ pointer_channel: Channel<PointerFeedbackPayload>,99+ audio_supported: bool,100+) -> Result<ConnectResult, String> {101+ let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {102+ "Another client connection or disconnection operation is in progress".to_string()103+ })?;104+ let connect_guard = ClientConnectGuard::acquire(Arc::clone(&state.client_connection_active))?;105+106+ let (client_secret, development_mode) = if let Some(node_id) = state.dev_connect_node_id {107+ if !development_direct_node_available() {108+ return Err(109+ "Development direct-node routing requires a debug build or the explicit demo-direct-node feature"110+ .to_string(),111+ );112+ }113+ let _ = app.emit(114+ "dev-connect-routing",115+ serde_json::json!({116+ "host_node_id": node_id.to_string(),117+ "warning": "Passkey identity lookup skipped; this is not client authorization."118+ }),119+ );120+ (SecretKey::generate(), true)121+ } else {122+ // FIDO2 derivation — 30s timeout so a missing/stuck key surfaces quickly.123+ let client_secret = tokio::time::timeout(124+ Duration::from_secs(30),125+ tokio::task::spawn_blocking(move || derive_iroh_secret_from_key(&pin)),126+ )127+ .await128+ .map_err(|_| "Security key timed out (30s). Make sure your key is connected.".to_string())?129+ .map_err(|e| format!("Task failed: {}", e))?130+ .map_err(|e| format!("FIDO2 error: {:?}", e))?;131+132+ // Key has been tapped — relay connection is next; update the UI overlay.133+ let _ = app.emit("fido-done", ());134+ (client_secret, false)135+ };136+137+ let (host_node_id, grants, invitation) = if development_mode {138+ (139+ state140+ .dev_connect_node_id141+ .ok_or_else(|| "Development host routing disappeared".to_string())?,142+ InvitationGrants::ALL,143+ None,144+ )145+ } else {146+ let enrollment = connection_enrollment(&app, client_secret.public())?;147+ (148+ enrollment.host_node_id,149+ enrollment.grants,150+ enrollment.pending_invitation,151+ )152+ };153+ let mut handshake_nonce = [0_u8; 16];154+ getrandom::fill(&mut handshake_nonce)155+ .map_err(|error| format!("Failed to generate handshake nonce: {error}"))?;156+ let endpoint = Endpoint::builder(presets::N0)157+ .secret_key(client_secret)158+ .bind()159+ .await160+ .map_err(|e| format!("Failed to bind endpoint: {}", e))?;161+162+ let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;163+164+ // Use just the node ID — the presets::N0 relay map handles geographic165+ // routing and fallback across all N0 relays automatically.166+ let addr = iroh::EndpointAddr::new(host_node_id);167+168+ // Public Sigil authorization exists only on the bounded, negotiated v1169+ // protocols. The inherited v0 leg is retained below solely as migration170+ // code and is no longer selected by an ordinary Portal connection.171+ let use_v1 = true;172+ let (frame_conn, frame_recv, media_control_stream, media_negotiation, media_transport) =173+ if use_v1 {174+ let first_attempt = open_negotiated_media_stream(175+ &endpoint,176+ &addr,177+ handshake_nonce,178+ invitation.as_deref(),179+ )180+ .await;181+ let (connection, recv, control, negotiation, transport) = match first_attempt {182+ Ok(result) => result,183+ Err(invitation_error)184+ if invitation.is_some()185+ && invitation_error.contains("Portal peer is not authorized") =>186+ {187+ // Recover only the narrow crash window where Sigil durably188+ // consumed the invitation but Portal did not durably clear it.189+ // The replay itself remains rejected; a second, ticket-free190+ // connection can succeed only as the already-enrolled Iroh191+ // peer authenticated by the exact invited host.192+ open_negotiated_media_stream(&endpoint, &addr, handshake_nonce, None)193+ .await194+ .map_err(|retry_error| {195+ format!(196+ "{invitation_error}; ticket-free enrollment recovery also failed: {retry_error}"197+ )198+ })?199+ }200+ Err(error) => return Err(error),201+ };202+ (connection, recv, control, Some(negotiation), transport)203+ } else {204+ let connection = endpoint205+ .connect(addr.clone(), FRAME_ALPN)206+ .await207+ .map_err(|e| format!("Failed to connect frame stream: {e}"))?;208+ let (mut send, recv) = connection209+ .open_bi()210+ .await211+ .map_err(|e| format!("Failed to open frame stream: {e}"))?;212+ send.write_all(&[1u8])213+ .await214+ .map_err(|e| format!("Failed to send start: {e}"))?;215+ send.finish()216+ .map_err(|e| format!("Failed to finish frame start stream: {e}"))?;217+ (connection, recv, None, None, MediaTransport::LegacyV0)218+ };219+ let media_session_id = media_negotiation220+ .as_ref()221+ .map(|negotiation| negotiation.session_id);222+ let (upstream_moq_media, frame_connection_for_stats) = if media_transport223+ == MediaTransport::UpstreamMoq224+ {225+ let session_id = media_session_id226+ .ok_or_else(|| "Host omitted the control session ID required by MoQ".to_string())?;227+ let (receiver, diagnostics_connection) =228+ match open_upstream_moq_media(&endpoint, &addr, session_id).await {229+ Ok(media) => media,230+ Err(error) => {231+ // CONTROL already authenticated and owns the host's232+ // one-client lease. A post-auth MoQ failure is233+ // terminal, and must explicitly release that lease;234+ // it must never fall through to a legacy media ALPN.235+ frame_conn.close(1_u32.into(), b"upstream MoQ setup failed");236+ let _ =237+ tokio::time::timeout(CLIENT_ENDPOINT_CLOSE_TIMEOUT, endpoint.close()).await;238+ return Err(error);239+ }240+ };241+ (Some(receiver), diagnostics_connection)242+ } else {243+ (None, frame_conn.clone())244+ };245+ let pointer_surface_dimensions = media_negotiation246+ .as_ref()247+ .and_then(|negotiation| negotiation.pointer_surface_dimensions);248+ if !development_mode && let Some(expected_invitation) = invitation.as_deref() {249+ // An accepted media hello means Sigil durably committed the one-time250+ // enrollment before returning. Future PIN/tap/play sessions send no251+ // bearer credential and authenticate by the stable Iroh peer instead.252+ mark_invitation_redeemed(&app, expected_invitation)?;253+ }254+255+ // Feedback is a v3 sidecar for both the preferred upstream-MoQ transport256+ // and the grouped-v3 compatibility path. Unsupported ALPN is normal257+ // compatibility with older Sigil hosts; all other failures remain visible258+ // diagnostics but never downgrade the authenticated media session.259+ let (adaptive_feedback_stream, adaptive_feedback_error) = if media_transport260+ .supports_adaptive_feedback()261+ {262+ match tokio::time::timeout(263+ CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT,264+ open_negotiated_feedback_stream(265+ &endpoint,266+ &addr,267+ handshake_nonce,268+ media_session_id.ok_or_else(|| "Media v3 omitted its session ID".to_string())?,269+ ),270+ )271+ .await272+ {273+ Ok(Ok(stream)) => (stream, None),274+ Ok(Err(error)) => {275+ eprintln!("[client] adaptive feedback unavailable: {error}");276+ (None, Some(error))277+ }278+ Err(_) => {279+ let error = "Adaptive feedback negotiation timed out".to_string();280+ eprintln!("[client] adaptive feedback unavailable: {error}");281+ (None, Some(error))282+ }283+ }284+ } else {285+ (None, None)286+ };287+288+ let InputSession {289+ connection: input_connection,290+ send: input_send,291+ recv: input_recv,292+ capabilities: input_capabilities,293+ availability: input_availability,294+ } = open_input_session(295+ &endpoint,296+ &addr,297+ handshake_nonce,298+ media_session_id,299+ grants,300+ use_v1,301+ )302+ .await?;303+304+ let network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(305+ Instant::now(),306+ input_availability.input_ack,307+ )));308+309+ if input_availability.pointer_position_feedback || input_availability.input_ack {310+ let feedback_diagnostics = Arc::clone(&network_diagnostics);311+ let pointer_feedback_enabled = input_availability.pointer_position_feedback;312+ let input_ack_enabled = input_availability.input_ack;313+ tokio::spawn(run_input_feedback(314+ input_recv,315+ feedback_diagnostics,316+ pointer_feedback_enabled,317+ input_ack_enabled,318+ pointer_channel,319+ ));320+ } else {321+ drop(input_recv);322+ }323+324+ let audio_generation = next_audio_generation(&state.audio_connection_generation)?;325+ lock_audio_deliveries(&state.audio_deliveries).begin_generation(audio_generation)?;326+ let audio_result = try_start_audio(327+ app.clone(),328+ &endpoint,329+ AudioStartRequest {330+ address: addr.clone(),331+ handshake_nonce,332+ media_session_id,333+ audio_supported,334+ audio_channel,335+ audio_deliveries: Arc::clone(&state.audio_deliveries),336+ connection_generation: Arc::clone(&state.audio_connection_generation),337+ generation: audio_generation,338+ },339+ )340+ .await;341+ let mut audio_connection_for_stats = None;342+ let (audio_available, connected_audio_generation, audio_error) = match audio_result {343+ Ok(connection) => {344+ audio_connection_for_stats = Some(connection.clone());345+ *state.audio_connection.lock().await = Some((audio_generation, connection));346+ (true, Some(audio_generation), None)347+ }348+ Err(error) => {349+ lock_audio_deliveries(&state.audio_deliveries).cancel_generation(audio_generation);350+ (false, None, Some(error))351+ }352+ };353+ let media_generation = next_media_generation(&state.client_media_generation)?;354+355+ let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);356+ {357+ let mut input_send_guard = state.input_send.lock().await;358+ *input_send_guard = Some(tx);359+ }360+361+ {362+ let mut ce = state.client_endpoint.lock().await;363+ *ce = Some(endpoint.clone());364+ }365+ *state.media_connection.lock().await = Some((media_generation, frame_conn.clone()));366+ let adaptive_feedback_available = adaptive_feedback_stream.is_some();367+ if let Some((connection, send, recv)) = adaptive_feedback_stream {368+ let (feedback_tx, feedback_rx) = tokio::sync::watch::channel(None);369+ let feedback_sender: MediaFeedbackSender = feedback_tx;370+ *state.media_feedback.lock().await =371+ Some((media_generation, connection, feedback_sender.clone()));372+ tokio::spawn(run_media_feedback_session(373+ app.clone(),374+ media_generation,375+ send,376+ recv,377+ feedback_rx,378+ ));379+ } else {380+ *state.media_feedback.lock().await = None;381+ }382+ let media_control_requests = if let Some(control_stream) = media_control_stream {383+ let (control_tx, control_rx) = tokio::sync::mpsc::channel(1);384+ *state.media_control.lock().await = Some((media_generation, control_tx.clone()));385+ tokio::spawn(run_media_control_writer_v3(control_stream, control_rx));386+ if media_transport == MediaTransport::GroupedObjectsV3 {387+ let _ = control_tx.try_send((KeyframeRequestReasonV3::Join, None));388+ }389+ Some(control_tx)390+ } else {391+ *state.media_control.lock().await = None;392+ None393+ };394+ let frame_events_in_flight = Arc::new(AtomicUsize::new(0));395+ *state.frame_delivery.lock().await =396+ Some((media_generation, Arc::clone(&frame_events_in_flight)));397+398+ // Input forwarder: absolute motion is latest-value state and may be399+ // dropped at the 60 Hz boundary. Relative motion is displacement, so it400+ // owns a separate accumulator and timer that coalesces rather than drops.401+ let input_send_diagnostics = Arc::clone(&network_diagnostics);402+ tokio::spawn(run_input_forwarder(403+ input_send,404+ rx,405+ use_v1,406+ input_capabilities,407+ input_send_diagnostics,408+ ));409+410+ // Frame reader — dual path: WebCodecs (raw bytes) or software JPEG decode411+ let use_webcodecs = state.webcodecs.load(Ordering::SeqCst);412+ tokio::spawn(run_video_delivery(VideoDeliveryRequest {413+ app,414+ endpoint,415+ frame_recv,416+ frame_connection: frame_connection_for_stats,417+ input_connection,418+ audio_connection: audio_connection_for_stats,419+ network_diagnostics,420+ media_control_requests,421+ upstream_moq_media,422+ frame_events_in_flight,423+ frame_channel,424+ media_transport,425+ media_generation,426+ connected_audio_generation,427+ use_webcodecs,428+ }));429+430+ let result = ConnectResult {431+ connected: true,432+ host_node_id: Some(host_node_id.to_string()),433+ development_mode,434+ media_transport: media_transport.diagnostic_name(),435+ pointer_surface_dimensions,436+ relative_pointer_available: input_availability.relative_pointer,437+ pointer_position_feedback_available: input_availability.pointer_position_feedback,438+ absolute_pointer_available: input_availability.absolute_pointer,439+ keyboard_available: input_availability.keyboard,440+ text_available: input_availability.text,441+ gamepad_available: input_availability.gamepad,442+ control_available: input_availability.control,443+ audio_available,444+ audio_generation: connected_audio_generation,445+ audio_error,446+ media_generation,447+ adaptive_feedback_available,448+ adaptive_feedback_error,449+ error: None,450+ };451+ connect_guard.commit();452+ Ok(result)453+}454+455+pub(crate) async fn disconnect_client(state: &AppState) -> Result<bool, String> {456+ let _connection_serial = state.client_connection_serial.lock().await;457+ next_audio_generation(&state.audio_connection_generation)?;458+ lock_audio_deliveries(&state.audio_deliveries).clear();459+ *state.media_control.lock().await = None;460+ if let Some((_generation, connection, _sender)) = state.media_feedback.lock().await.take() {461+ connection.close(0_u32.into(), b"client disconnected");462+ }463+ // Do not rely on endpoint shutdown alone to retire the session. The frame464+ // reader and diagnostics task both own connection clones, and a surviving465+ // media connection keeps the host's encoder and one-client lease alive.466+ // Closing the media connection explicitly gives the host an immediate,467+ // protocol-level session boundary.468+ close_generation_connection(state.media_connection.lock().await.take(), |connection| {469+ connection.close(0_u32.into(), b"client disconnected");470+ });471+ close_generation_connection(state.audio_connection.lock().await.take(), |connection| {472+ connection.close(0_u32.into(), b"client disconnected");473+ });474+ {475+ let mut ce = state.client_endpoint.lock().await;476+ if let Some(endpoint) = ce.take()477+ && tokio::time::timeout(CLIENT_ENDPOINT_CLOSE_TIMEOUT, endpoint.close())478+ .await479+ .is_err()480+ {481+ eprintln!(482+ "[client] timed out waiting for endpoint shutdown after explicit connection close"483+ );484+ }485+ }486+ {487+ let mut input_send = state.input_send.lock().await;488+ *input_send = None;489+ }490+ *state.frame_delivery.lock().await = None;491+ state492+ .client_connection_active493+ .store(false, Ordering::SeqCst);494+ Ok(true)495+}496+497+498+mod tests {499+ use super::*;500+501+ 502+ fn client_connect_guard_rejects_overlap_and_resets_only_failed_attempts() {503+ let active = Arc::new(AtomicBool::new(false));504+ let attempt = ClientConnectGuard::acquire(Arc::clone(&active)).unwrap();505+ assert!(ClientConnectGuard::acquire(Arc::clone(&active)).is_err());506+ drop(attempt);507+ assert!(!active.load(Ordering::SeqCst));508+509+ ClientConnectGuard::acquire(Arc::clone(&active))510+ .unwrap()511+ .commit();512+ assert!(active.load(Ordering::SeqCst));513+ assert!(ClientConnectGuard::acquire(Arc::clone(&active)).is_err());514+ active.store(false, Ordering::SeqCst);515+ }516+}