Skip to main content

openrtc/
client.rs

1use super::coordination::room::{GatewayRequiredRoomBackend, RoomBackend};
2use crate::signaling::{GatewayRequiredSignalingBackend, SignalingBackend};
3use iroh::Endpoint;
4use iroh_tickets::endpoint::EndpointTicket;
5use std::collections::{HashMap, HashSet};
6use std::str::FromStr;
7#[cfg(not(target_arch = "wasm32"))]
8use std::sync::atomic::Ordering;
9use std::sync::atomic::{AtomicBool, AtomicU64};
10use std::sync::{Arc, Mutex, RwLock as StdRwLock};
11use tokio::sync::RwLock;
12
13#[cfg(not(target_arch = "wasm32"))]
14use crate::client::scope_classifier::{default_scope_classifier, ScopeClassifier};
15
16#[cfg(target_arch = "wasm32")]
17fn wasm_init_log(stage: &str) {
18    let now = js_sys::Date::now();
19    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
20        "[OPENRTC][WASM-INIT] ts_ms={:.0} stage={}",
21        now, stage
22    )));
23}
24
25#[cfg(target_arch = "wasm32")]
26use crate::wasm_node::{AcceptEvent, ConnectEvent, IrohWasmNode};
27
28#[cfg(not(target_arch = "wasm32"))]
29use crate::native_node::{AcceptEvent, ConnectEvent, IncomingStream, IrohNativeNode};
30
31#[cfg(not(target_arch = "wasm32"))]
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct EndpointHandle {
35    pub node_id: String,
36    pub node_addr: String,
37}
38
39#[cfg(not(target_arch = "wasm32"))]
40pub struct BiStream {
41    pub send: crate::application_crypto_streams::PeerSendStream,
42    pub recv: crate::application_crypto_streams::PeerRecvStream,
43    pub id: String,
44}
45
46/// Result of routing a newly accepted native bidirectional stream through the
47/// Rust admission authority. Pending connections must complete SDK-owned token
48/// admission before an application or webview can observe their streams.
49#[cfg(not(target_arch = "wasm32"))]
50pub enum IncomingBiStreamDisposition {
51    Consumed,
52    Forward {
53        send: iroh::endpoint::SendStream,
54        recv: iroh::endpoint::RecvStream,
55        /// Bytes consumed only to classify an out-of-order encrypted product
56        /// frame while reciprocal key confirmation was in flight. The native
57        /// host wrapper must feed them into the application-crypto decoder
58        /// before reading the remaining QUIC stream.
59        recv_prefix: Vec<u8>,
60    },
61}
62
63#[cfg(not(target_arch = "wasm32"))]
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct NativePeerDataEvent {
67    pub connection_id: String,
68    pub remote_node_id: Option<String>,
69    pub transport: String,
70    pub transport_stable_id: u64,
71    pub transport_generation: u64,
72    pub route_generation: u64,
73    pub payload: Vec<u8>,
74}
75
76#[cfg(not(target_arch = "wasm32"))]
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(crate) struct NativePeerDataGeneration {
79    pub transport_stable_id: u64,
80    pub transport_generation: u64,
81    pub route_generation: u64,
82}
83
84#[cfg(not(target_arch = "wasm32"))]
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub(crate) struct NativeBleUpgradeAttemptState {
87    pub generation: NativePeerDataGeneration,
88    pub attempts: u8,
89}
90
91/// Exact physical owner of one SDK native-main stream. The logical connection
92/// id intentionally remains the map key; this token prevents stale physical
93/// generations and duplicate QUIC streams from mutating the current entry.
94#[cfg(not(target_arch = "wasm32"))]
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub(crate) struct NativeControlStreamOwner {
97    pub transport_stable_id: u64,
98    pub stream_rank: u64,
99}
100
101#[cfg(not(target_arch = "wasm32"))]
102#[derive(Clone)]
103pub(crate) struct NativeControlStreamEntry {
104    pub owner: NativeControlStreamOwner,
105    pub endpoint_id: iroh::EndpointId,
106    pub send: Arc<tokio::sync::Mutex<iroh::endpoint::SendStream>>,
107}
108
109/// Last valid OpenRTC protocol activity observed from one physical Iroh
110/// generation.
111///
112/// QUIC health probes use a separate uni-stream. A mobile runtime can process
113/// admission or control frames on an already-open bi-stream while that
114/// diagnostic probe is delayed or dropped. Recording the protocol activity
115/// here lets the lifecycle owner use that stronger positive evidence without
116/// allowing an old physical generation to keep its replacement alive.
117#[cfg(not(target_arch = "wasm32"))]
118#[derive(Debug, Clone, Copy)]
119pub(crate) struct NativeTransportProtocolActivity {
120    pub transport_stable_id: u64,
121    pub observed_at: std::time::Instant,
122}
123
124#[cfg(not(target_arch = "wasm32"))]
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub(crate) struct NativeAdmissionStreamContracts {
127    pub inbound: Option<crate::native_protocol::SessionTokenStreamContract>,
128    pub outbound: Option<crate::native_protocol::SessionTokenStreamContract>,
129}
130
131#[cfg(not(target_arch = "wasm32"))]
132#[derive(Debug, Clone)]
133pub(crate) struct CachedManagedScopeTicket {
134    pub scope: crate::session_token::GrantScope,
135    pub token: String,
136    pub max_connections: u32,
137    pub compound_ticket: String,
138    pub iroh_ticket: String,
139}
140
141#[cfg(not(target_arch = "wasm32"))]
142#[derive(Debug, Clone)]
143pub(crate) struct NativeRouteRepairCredential {
144    pub token: String,
145    pub token_payload: String,
146    pub authoritative_device_id: String,
147}
148
149#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
150#[derive(Debug, Clone)]
151pub(crate) struct NativeWebRTCSuppression {
152    pub until_ms: i64,
153    pub reason: String,
154}
155
156/// Records that an upgrade attempt is currently in-flight for a connection.
157/// Used to serialize the many triggers (inbound-sdp, admission, health-check,
158/// relay-path, foreground, handshake, scheduled-retry) onto a single attempt
159/// at a time so they stop tearing each other's sessions down.
160#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
161#[derive(Debug, Clone)]
162pub(crate) struct NativeWebRTCAttemptInFlight {
163    /// Unique owner for callbacks spawned by this attempt. Negotiation ids can
164    /// be reused across retries, so they are not sufficient lifecycle fences.
165    pub attempt_id: String,
166    pub negotiation_id: String,
167    /// Physical Iroh generation that owns this attempt's signaling path.
168    pub transport_stable_id: Option<u64>,
169    pub started_at_ms: i64,
170    pub expires_at_ms: i64,
171    /// True when this attempt was created for an explicit or inbound
172    /// negotiation id and should not be closed by direct-Iroh path suppression.
173    pub preserve_on_direct_path: bool,
174    /// Last time we logged a "skipped: attempt in flight" line for this
175    /// attempt. Used to rate-limit the log so concurrent triggers don't
176    /// spam it once per tick.
177    pub last_skip_log_ms: i64,
178}
179
180/// Deferred retirement of the managed Iroh connection underneath an active
181/// WebRTC route.
182///
183/// The logical connection id is stable across physical Iroh replacement, so a
184/// deferred cleanup must retain the exact physical generation that requested
185/// it. Otherwise the old WebRTC session can finish after a replacement is
186/// installed and incorrectly remove the new connection record.
187#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) struct DeferredManagedRetirement {
190    pub reason: Option<String>,
191    pub transport_stable_id: Option<u64>,
192    pub transport_generation: u64,
193}
194
195#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub(crate) struct NativeMoQRouteProofState {
198    pub route_instance_id: usize,
199    pub probe_id: String,
200    pub local_node_id: String,
201    pub remote_node_id: String,
202    pub retry_active: bool,
203    pub proven: bool,
204}
205
206#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub(crate) struct NativeWebRTCRouteProofState {
209    pub route_instance_id: usize,
210    pub probe_id: String,
211    pub negotiation_id: String,
212    pub retry_active: bool,
213    pub proven: bool,
214}
215
216/// Opaque, short-lived native trust evidence supplied by a developer backend.
217///
218/// OpenRTC does not interpret the token. The active control plane verifies and
219/// exchanges it while callers and host bridges remain provider-neutral.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct NativeTrustToken {
222    pub token: String,
223    pub expires_at_ms: u64,
224}
225
226#[allow(dead_code)]
227#[derive(Clone)]
228pub struct Client {
229    app_tag: String,
230    signaling: Arc<dyn SignalingBackend>,
231    pub room: Arc<dyn RoomBackend>,
232    node_id: Arc<RwLock<Option<String>>>,
233    pub iroh_endpoint: Arc<RwLock<Option<Endpoint>>>,
234    #[cfg(target_arch = "wasm32")]
235    pub iroh_node: Arc<RwLock<Option<IrohWasmNode>>>,
236    #[cfg(not(target_arch = "wasm32"))]
237    pub iroh_node: Arc<RwLock<Option<IrohNativeNode>>>,
238    #[cfg(not(target_arch = "wasm32"))]
239    native_application_streams: async_channel::Sender<IncomingStream>,
240    #[cfg(not(target_arch = "wasm32"))]
241    native_application_streams_receiver: async_channel::Receiver<IncomingStream>,
242    pub connection_manager: Arc<crate::connection_manager::ConnectionManager>,
243    #[cfg(not(target_arch = "wasm32"))]
244    native_device_identity: Arc<RwLock<Option<crate::native_device::NativeDeviceIdentity>>>,
245    #[cfg(not(target_arch = "wasm32"))]
246    native_device_base_dir: Arc<RwLock<Option<std::path::PathBuf>>>,
247    #[cfg(not(target_arch = "wasm32"))]
248    native_device_identity_init_guard: Arc<tokio::sync::Mutex<()>>,
249    #[cfg(not(target_arch = "wasm32"))]
250    native_device_updates:
251        tokio::sync::broadcast::Sender<crate::native_device::NativeDeviceIdentity>,
252    #[cfg(not(target_arch = "wasm32"))]
253    native_connection_state_updates: tokio::sync::broadcast::Sender<ConnectionStateSnapshot>,
254    #[cfg(not(target_arch = "wasm32"))]
255    native_peer_data_updates: tokio::sync::broadcast::Sender<NativePeerDataEvent>,
256    auto_connect_loop_key: Arc<Mutex<Option<(String, String)>>>,
257    auto_connect_generation: Arc<AtomicU64>,
258    /// Provider-neutral native desired-peer input. Hosted coordination adapters
259    /// submit revisioned snapshots; Rust remains the only dial/retry owner.
260    #[cfg(not(target_arch = "wasm32"))]
261    external_desired_peer_actor:
262        Arc<tokio::sync::Mutex<auto_connect_impl::NativeExternalAutoConnectActorState>>,
263    /// Device IDs excluded from auto-connect. Session-scoped: cleared on restart.
264    auto_connect_excluded: Arc<Mutex<HashSet<String>>>,
265    /// Subset of exclusions installed because the peer explicitly disconnected.
266    ///
267    /// A fresh authenticated user-device admission from that peer is an explicit
268    /// reconnect request and may clear this subset. Locally requested exclusions
269    /// are deliberately not recorded here, so a remote dial cannot override the
270    /// local user's disconnect choice.
271    auto_connect_peer_requested_excluded: Arc<Mutex<HashSet<String>>>,
272    /// Node-id aliases for session-scoped auto-connect exclusions, keyed by
273    /// canonical device id. These are local-only and must not be published into
274    /// the coordination roster's `excludedPeers`, which is a device-id contract.
275    auto_connect_excluded_node_aliases: Arc<Mutex<HashMap<String, HashSet<String>>>>,
276    /// Session token registry — gates incoming connections during short-lived
277    /// sessions (e.g. share page). When non-empty, incoming handshakes must
278    /// carry a valid token. Shared across WASM and native paths.
279    pub session_token_registry: Arc<crate::session_token::SessionTokenRegistry>,
280    /// Directional proof that this runtime validated the remote token on the
281    /// current physical Iroh generation. Logical admission survives reconnect,
282    /// but a replacement leg must establish a fresh SDK control route.
283    #[cfg(not(target_arch = "wasm32"))]
284    inbound_session_admission_transport_ids: Arc<StdRwLock<HashMap<String, u64>>>,
285    /// Declared lifetime of the stream that established admission for this
286    /// logical connection. The contract determines which generation-bound
287    /// proofs gate readiness; it never owns reconnection or settlement.
288    #[cfg(not(target_arch = "wasm32"))]
289    native_admission_stream_contracts:
290        Arc<StdRwLock<HashMap<String, NativeAdmissionStreamContracts>>>,
291    /// Host approval observed by this dialer, fenced to the physical Iroh leg
292    /// and token that produced it. Local trusted-device admission is a separate
293    /// fact and must never suppress remote token presentation.
294    remote_session_admission_proofs: Arc<StdRwLock<HashMap<String, RemoteSessionAdmissionProof>>>,
295    /// Rust-owned transcript for a reciprocal admission response that is
296    /// awaiting an ACK on one exact stream and physical transport generation.
297    /// Host adapters may relay observations, but cannot supply the token or
298    /// scope at commit time.
299    pending_inline_reciprocal_admissions:
300        Arc<StdRwLock<HashMap<String, PendingInlineReciprocalAdmission>>>,
301    /// Last capability token approved by the remote host for each logical
302    /// connection. This is application-security state, not transport state:
303    /// route replacement preserves a same-token epoch, while token rotation
304    /// must retire the previous application key before product traffic resumes.
305    outbound_application_security_epoch_fingerprints: Arc<StdRwLock<HashMap<String, String>>>,
306    /// Valid token presentations may ask this runtime to restore the reverse
307    /// directional proof. The existing auto-connect actor consumes this typed
308    /// input; it does not introduce a second retry or lifecycle owner.
309    #[cfg(not(target_arch = "wasm32"))]
310    pending_reciprocal_session_admission_requests: Arc<StdRwLock<HashSet<String>>>,
311    /// Latest validated managed credential advertised by each remote native
312    /// node. The native admission responder uses this desired-state input to
313    /// answer an inline reciprocal request without opening a second stream.
314    #[cfg(not(target_arch = "wasm32"))]
315    native_route_repair_credentials: Arc<StdRwLock<HashMap<String, NativeRouteRepairCredential>>>,
316    connection_application_crypto_keys:
317        Arc<StdRwLock<HashMap<String, [u8; crate::application_crypto::APPLICATION_KEY_BYTES]>>>,
318    connection_application_crypto_required: Arc<StdRwLock<HashSet<String>>>,
319    /// Product opt-in requiring every trusted native user-device route to
320    /// complete reciprocal application key agreement before product streams
321    /// become routable.
322    #[cfg(not(target_arch = "wasm32"))]
323    trusted_user_device_application_crypto_required: Arc<AtomicBool>,
324    /// Automatic key agreement is not routable until the remote peer has
325    /// acknowledged the exact connection key. Key presence alone is only a
326    /// local derivation fact and can race the reciprocal handshake.
327    connection_application_crypto_confirmed: Arc<StdRwLock<HashSet<String>>>,
328    /// Wakes native ingress tasks when reciprocal key confirmation changes.
329    /// Independent QUIC streams have no cross-stream delivery order, so an
330    /// application stream may arrive immediately before its control-stream
331    /// acknowledgement. The Rust admission owner uses this typed wake to hold
332    /// that stream behind the exact key/admission epoch without polling.
333    #[cfg(not(target_arch = "wasm32"))]
334    connection_application_crypto_confirmation_updates: Arc<tokio::sync::Notify>,
335    connection_application_crypto_outbound_sequences: Arc<StdRwLock<HashMap<String, u64>>>,
336    connection_application_key_agreements:
337        Arc<StdRwLock<HashMap<String, crate::key_agreement::EphemeralKeyAgreement>>>,
338    /// Native-managed admission grants that must survive frontend refreshes
339    /// for the lifetime of the desktop app process. The user-device scope is
340    /// intentionally backend-owned so auto-connect presence can keep
341    /// publishing the same tokenized ticket until explicit revoke/app restart.
342    ///
343    /// This cache is read-heavy (ticket lookups/refreshes) with infrequent
344    /// writes (revoke/rehydrate), so a standard RwLock avoids unnecessary
345    /// exclusive locking overhead from Mutex.
346    #[cfg(not(target_arch = "wasm32"))]
347    managed_scope_tickets: Arc<StdRwLock<HashMap<String, CachedManagedScopeTicket>>>,
348    /// Latest ticket-derived Iroh address for each remote endpoint.
349    ///
350    /// The coordination gateway publishes fresh device tickets. Native stream
351    /// recovery uses this cache to redial Iroh directly when a cached
352    /// connection/stream has gone stale.
353    known_endpoint_addrs: Arc<RwLock<HashMap<String, iroh::EndpointAddr>>>,
354    /// Session-scoped, authoritative mapping from the current remote Iroh node
355    /// to its durable device id. Physical connection records are deliberately
356    /// ephemeral and may be retired before an inbound replacement leg arrives;
357    /// logical identity must survive that transport-generation boundary.
358    known_device_ids_by_node: Arc<StdRwLock<HashMap<String, String>>>,
359    /// When true, the auto-connect loop runs at reduced frequency and skips
360    /// expensive operations (health probing, presence republish, network change
361    /// recovery). Event-driven connects still fire immediately.
362    app_backgrounded: Arc<AtomicBool>,
363    #[cfg(target_arch = "wasm32")]
364    wasm_accept_bridge_started: Arc<std::sync::atomic::AtomicBool>,
365    #[cfg(target_arch = "wasm32")]
366    last_emitted_connection_states:
367        Arc<Mutex<std::collections::HashMap<String, WasmConnectionStateFingerprint>>>,
368    #[cfg(target_arch = "wasm32")]
369    last_empty_peer_sessions_warning_ms: Arc<AtomicU64>,
370    /// Serializes concurrent calls to `init_iroh_with_router_mode` so that only
371    /// one endpoint is ever created. Without this, the background init and a
372    /// frontend IPC `start_iroh_node` can race and bind two endpoints with the
373    /// same secret key, causing the relay to reject the duplicate.
374    iroh_init_guard: Arc<tokio::sync::Mutex<()>>,
375    /// Coalesces concurrent managed dials for the same deterministic connection id.
376    ///
377    /// Without this, two near-simultaneous `connect_device` callers can both dial
378    /// the same endpoint before either observes the other's Pending record. The
379    /// native node then replaces one outbound transport with the other, killing the
380    /// session-token admission stream before the host can adopt the connection.
381    managed_connect_gates: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
382    /// Physical Iroh connection stable IDs with an active selected-path watcher.
383    /// Every install/adoption path may request a watcher; this registry keeps
384    /// ownership singular without assuming another path already spawned one.
385    #[cfg(not(target_arch = "wasm32"))]
386    iroh_path_watcher_stable_ids: Arc<Mutex<HashSet<u64>>>,
387    /// Maps Iroh custom-transport discriminators to OpenRTC path semantics.
388    ///
389    /// Companion crates register their transport id here. Core owns only path
390    /// classification and lifecycle projection; transport-specific sockets,
391    /// scanning, and retries remain in the companion.
392    #[cfg(not(target_arch = "wasm32"))]
393    native_custom_transport_kinds: Arc<RwLock<HashMap<u64, IrohPathKind>>>,
394    /// Runtime-installed native transport providers keyed by their OpenRTC path
395    /// kind. Providers own hardware discovery; OpenRTC owns peer negotiation,
396    /// connection replacement, and lifecycle projection.
397    #[cfg(not(target_arch = "wasm32"))]
398    native_transport_upgrade_providers:
399        Arc<RwLock<HashMap<IrohPathKind, Arc<dyn NativeTransportUpgradeProvider>>>>,
400    /// Coalesces capability handshakes and retries into one transport upgrade
401    /// attempt per logical peer/path pair.
402    #[cfg(not(target_arch = "wasm32"))]
403    native_transport_upgrade_gates:
404        Arc<tokio::sync::Mutex<HashMap<(String, IrohPathKind), String>>>,
405    /// Bounded BLE replacement attempts for the current logical/physical
406    /// generation. The Rust transport owner uses this to retry radio or route
407    /// failures without creating a second lifecycle loop in TypeScript.
408    #[cfg(not(target_arch = "wasm32"))]
409    native_ble_upgrade_attempts:
410        Arc<tokio::sync::Mutex<HashMap<String, NativeBleUpgradeAttemptState>>>,
411    /// Negotiated optional-transport facts for the current logical peer
412    /// session. Path watchers consult this before asking the single upgrade
413    /// owner to react to relay demotion.
414    #[cfg(not(target_arch = "wasm32"))]
415    native_peer_transport_capabilities:
416        Arc<RwLock<HashMap<String, HashSet<NativePeerTransportCapability>>>>,
417    /// Connections for which this native runtime has received a TypeScript
418    /// protocol handshake. Recovery uses this protocol fact to retain the
419    /// native-to-native persistent-control contract.
420    #[cfg(not(target_arch = "wasm32"))]
421    native_scoped_webrtc_signal_peer_connections: Arc<RwLock<HashSet<String>>>,
422    pub(crate) presence_loop_tx:
423        Arc<Mutex<Option<tokio::sync::mpsc::Sender<crate::presence::PresenceCommand>>>>,
424    /// Platform project and token source used for authenticated gateway
425    /// admission. Logical usage policy is enforced by the control plane, not
426    /// cached in the transport runtime.
427    #[cfg(not(target_arch = "wasm32"))]
428    pub(crate) project_id: String,
429    #[cfg(not(target_arch = "wasm32"))]
430    pub(crate) token_provider: Arc<dyn Fn() -> Option<String> + Send + Sync>,
431    transport_config: Arc<RwLock<TransportConfig>>,
432    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
433    local_discovery_registry: crate::local_discovery::LocalDiscoveryRegistry,
434    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
435    mdns_address_lookup: Arc<RwLock<Option<iroh_mdns_address_lookup::MdnsAddressLookup>>>,
436    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
437    native_webrtc_sessions:
438        Arc<RwLock<HashMap<String, Arc<crate::transport::NativeWebRTCDataChannel>>>>,
439    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
440    native_webrtc_route_proofs: Arc<RwLock<HashMap<String, NativeWebRTCRouteProofState>>>,
441    #[cfg(not(target_arch = "wasm32"))]
442    native_optional_route_generations: Arc<RwLock<HashMap<String, NativeOptionalRouteGenerations>>>,
443    #[cfg(not(target_arch = "wasm32"))]
444    native_route_start_gates: Arc<route_adapter::NativeRouteStartGateRegistry>,
445    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
446    native_webrtc_suppressions: Arc<RwLock<HashMap<String, NativeWebRTCSuppression>>>,
447    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
448    native_webrtc_attempt_counts: Arc<RwLock<HashMap<String, u32>>>,
449    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
450    native_webrtc_start_gates: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Notify>>>>,
451    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
452    pub(crate) native_webrtc_attempts_in_flight:
453        Arc<RwLock<HashMap<String, NativeWebRTCAttemptInFlight>>>,
454    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
455    native_webrtc_retry_deadlines: Arc<tokio::sync::Mutex<HashMap<String, i64>>>,
456    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
457    deferred_managed_retirements: Arc<RwLock<HashMap<String, DeferredManagedRetirement>>>,
458    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
459    native_moq_sessions: Arc<RwLock<HashMap<String, Arc<crate::transport::NativeMoQSession>>>>,
460    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
461    native_moq_route_proofs: Arc<RwLock<HashMap<String, NativeMoQRouteProofState>>>,
462    /// Persistent SDK-owned native-main control stream. The admission request
463    /// establishes this channel before application streams are exposed; transport
464    /// capability, replacement, and signaling frames reuse it for the peer
465    /// session lifetime. The map is keyed by deterministic connection id, but
466    /// each value is owned by one physical Iroh generation and QUIC stream.
467    #[cfg(not(target_arch = "wasm32"))]
468    pub(crate) native_control_streams:
469        Arc<tokio::sync::Mutex<HashMap<String, NativeControlStreamEntry>>>,
470    /// Generation-bound positive liveness evidence produced by successfully
471    /// parsed OpenRTC protocol frames. This is an input to the single Rust
472    /// lifecycle owner, not a second timer or connection-state authority.
473    #[cfg(not(target_arch = "wasm32"))]
474    native_transport_protocol_activity:
475        Arc<StdRwLock<HashMap<String, NativeTransportProtocolActivity>>>,
476    /// Same-transport deterministic control-stream handoff observed while a
477    /// bilateral persistent admission response is still in flight.
478    ///
479    /// The higher-ranked duplicate can receive EOF immediately before the
480    /// lower-ranked stream is installed locally. Keep the generation proofs
481    /// intact across that bounded handoff, but remember the exact stable ID so
482    /// an unacknowledged candidate can fail closed.
483    #[cfg(not(target_arch = "wasm32"))]
484    pending_native_control_handoffs: Arc<StdRwLock<HashMap<String, u64>>>,
485    /// Experimental, opt-in scoped actor registry. The default runtime has no
486    /// Rust-side actor: the TypeScript scoped actor is the single active
487    /// coalescer until a native actor owns real dial and channel routing.
488    #[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
489    scoped_connection_actor_registry: Arc<
490        RwLock<Option<Arc<crate::client::scoped_connection_actor::ScopedConnectionActorRegistry>>>,
491    >,
492    /// Phase 5: shared auth-readiness gate. The TS auth bridge (or any
493    /// host that owns auth lifecycle) pushes leg state via `mark_*`;
494    /// the drive-grant connection actor and other readiness-aware
495    /// callers consult it via `wait_until_ready`. Always present so
496    /// `Default`-style construction does not need a feature flag.
497    #[cfg(not(target_arch = "wasm32"))]
498    auth_readiness: Arc<crate::client::auth_readiness::AuthReadinessStore>,
499    /// Scope classifier for mapping admitted scopes → correlation labels.
500    /// Default preserves legacy drive-grant/user-device behavior.
501    #[cfg(not(target_arch = "wasm32"))]
502    scope_classifier: Arc<RwLock<Arc<dyn ScopeClassifier>>>,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
506struct RemoteSessionAdmissionProof {
507    transport_stable_id: u64,
508    token_fingerprint: String,
509    approval_scope: String,
510}
511
512#[derive(Debug, Clone)]
513struct PendingInlineReciprocalAdmission {
514    connection_id: String,
515    remote_node_id: String,
516    local_device_id: String,
517    remote_device_id: String,
518    transport_stable_id: u64,
519    stream_instance_id: String,
520    presentation_id: String,
521    token: String,
522    token_fingerprint: String,
523    expected_scope: String,
524    inbound_admission_fingerprint: String,
525    inbound_admission_epoch: u64,
526}
527
528fn now_millis_i64() -> i64 {
529    crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64
530}
531
532/// The current path kind of an iroh connection, used to gate transport upgrades.
533///
534/// Priority order for native data sending is edge-aware:
535///   proven WebRTC/MoQ routes stay first; `DirectQuic` / `DirectLan` / `Ble`
536///   remain primary base paths; relay or unknown paths can probe optional
537///   upgrades first.
538///
539/// WebRTC and MoQ upgrades are started when the path is `Relay` and suspended
540/// (but not torn down) when it transitions back to `DirectQuic`.
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
542pub enum IrohPathKind {
543    /// Direct UDP/QUIC hole-punched path — low latency, no intermediary.
544    DirectQuic,
545    /// Direct QUIC over a private/link-local LAN address.
546    DirectLan,
547    /// Traffic routed through an iroh relay server — higher latency, rate-limited for web.
548    Relay,
549    /// Selected path uses a BLE custom transport.
550    Ble,
551    /// No live connection or path information is not yet available.
552    Unknown,
553}
554
555#[cfg(not(target_arch = "wasm32"))]
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
557pub(crate) enum NativePeerTransportCapability {
558    WebRtc,
559    Moq,
560    Ble,
561}
562
563/// Hardware-specific preparation for a native Iroh transport upgrade.
564///
565/// Implementations may scan radios or resolve a platform route, but they do
566/// not mutate OpenRTC connection state. The returned address must contain only
567/// the prepared transport path so OpenRTC can prove the replacement did not
568/// silently fall back to relay or IP.
569#[cfg(not(target_arch = "wasm32"))]
570#[async_trait::async_trait]
571pub trait NativeTransportUpgradeProvider: std::fmt::Debug + Send + Sync {
572    fn kind(&self) -> IrohPathKind;
573    fn transport_id(&self) -> u64;
574    async fn prepare_endpoint_addr(
575        &self,
576        endpoint_id: iroh::EndpointId,
577    ) -> anyhow::Result<iroh::EndpointAddr>;
578}
579
580impl IrohPathKind {
581    pub fn is_relay_path(self) -> bool {
582        matches!(self, Self::Relay)
583    }
584
585    pub fn transport_label(self) -> &'static str {
586        match self {
587            Self::DirectQuic => crate::transport_label::IROH_QUIC,
588            Self::DirectLan => crate::transport_label::IROH_LAN,
589            Self::Relay => crate::transport_label::IROH_RELAY,
590            Self::Ble => crate::transport_label::BLE,
591            Self::Unknown => crate::transport_label::IROH,
592        }
593    }
594}
595
596/// Native iroh LAN discovery configuration.
597#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
598#[serde(rename_all = "camelCase")]
599pub struct IrohLanConfig {
600    #[serde(default = "default_lan_enabled")]
601    pub enabled: bool,
602    /// When false, listen for LAN peers without advertising this endpoint.
603    #[serde(default = "default_lan_advertise")]
604    pub advertise: bool,
605}
606
607fn default_lan_enabled() -> bool {
608    true
609}
610
611fn default_lan_advertise() -> bool {
612    true
613}
614
615impl Default for IrohLanConfig {
616    fn default() -> Self {
617        Self {
618            enabled: true,
619            advertise: true,
620        }
621    }
622}
623
624/// Native BLE discovery and iroh custom-transport configuration.
625///
626/// BLE is native-only and is intended as a nearby-device path for poor or
627/// unavailable internet conditions. Browser runtimes ignore this setting.
628#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
629#[serde(rename_all = "camelCase")]
630pub struct BleConfig {
631    #[serde(default)]
632    pub enabled: bool,
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub connect_timeout_ms: Option<u64>,
635}
636
637/// How long a newly-bound transport has to reach settled-ready state before the
638/// connection is considered dead and retired.  Must be longer than the
639/// duplicate-close grace window (8 s) so that auto-connect suppression covers
640/// the entire hold period.
641pub(crate) const MANAGED_SETTLE_DEADLINE_MS: i64 =
642    crate::runtime_policy::MANAGED_SETTLE_DEADLINE_MS;
643
644/// How long an incoming connection has to present a valid session token before
645/// it is closed with session-admission-timeout.  This is intentionally longer
646/// than MANAGED_SETTLE_DEADLINE_MS because share-ticket peers must complete
647/// full WASM initialization and iroh connection setup before they can send the
648/// token stream, which can take 20-40 s on a cold web load.  Matches
649/// NATIVE_WEBRTC_CONNECT_TIMEOUT_MS so WebRTC negotiation can complete in the
650/// same window.
651#[cfg(not(target_arch = "wasm32"))]
652pub(crate) const SESSION_ADMISSION_TIMEOUT_MS: u64 =
653    crate::runtime_policy::SESSION_ADMISSION_TIMEOUT_MS;
654
655#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
656#[serde(rename_all = "camelCase")]
657pub enum DeviceConnectionStatus {
658    Disconnected,
659    Connecting,
660    Connected,
661    Failed,
662    Closed,
663    Online,
664}
665
666#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
667#[serde(rename_all = "camelCase")]
668pub enum DevicePresenceStatus {
669    Online,
670    Idle,
671    Offline,
672}
673
674#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
675#[serde(rename_all = "camelCase")]
676pub enum ReadinessState {
677    Connecting,
678    TransportOnly,
679    Settling,
680    Routable,
681    AwaitingReplacement,
682    Closed,
683    Failed,
684}
685
686#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
687#[serde(rename_all = "camelCase")]
688pub struct TransportLatencySnapshot {
689    pub webrtc: Option<u64>,
690    pub webrtc_lan: Option<u64>,
691    pub webrtc_turn: Option<u64>,
692    pub iroh: Option<u64>,
693    pub iroh_lan: Option<u64>,
694    pub iroh_relay: Option<u64>,
695    pub ble: Option<u64>,
696    pub moq: Option<u64>,
697}
698
699impl TransportLatencySnapshot {
700    fn set(&mut self, transport: &str, latency_ms: u64) {
701        match transport.trim().to_ascii_lowercase().as_str() {
702            "webrtc-lan" => self.webrtc_lan = Some(latency_ms),
703            "webrtc-turn" => self.webrtc_turn = Some(latency_ms),
704            "webrtc" => self.webrtc = Some(latency_ms),
705            "iroh-lan" => self.iroh_lan = Some(latency_ms),
706            "iroh-relay" => self.iroh_relay = Some(latency_ms),
707            "iroh" | "iroh-quic" => self.iroh = Some(latency_ms),
708            "ble" => self.ble = Some(latency_ms),
709            "moq" => self.moq = Some(latency_ms),
710            _ => {}
711        }
712    }
713
714    fn get(&self, transport: &str) -> Option<u64> {
715        match transport.trim().to_ascii_lowercase().as_str() {
716            "webrtc-lan" => self.webrtc_lan,
717            "webrtc-turn" => self.webrtc_turn,
718            "webrtc" => self.webrtc,
719            "iroh-lan" => self.iroh_lan,
720            "iroh-relay" => self.iroh_relay,
721            "iroh" | "iroh-quic" => self.iroh,
722            "ble" => self.ble,
723            "moq" => self.moq,
724            _ => None,
725        }
726    }
727}
728
729#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
730#[serde(rename_all = "camelCase")]
731pub struct DeviceStatusSnapshot {
732    #[serde(flatten)]
733    pub device: crate::signaling::Device,
734    pub presence_status: DevicePresenceStatus,
735    pub presence_updated_at: Option<i64>,
736    pub presence_expires_at: Option<i64>,
737    pub connectable: bool,
738    pub connection_status: DeviceConnectionStatus,
739    pub settled_ready: bool,
740    pub readiness_state: ReadinessState,
741    pub readiness_reason: String,
742    pub peer_health: crate::connection_manager::ConnectionHealth,
743    pub peer_id: Option<String>,
744    pub scopes: Vec<String>,
745    pub connection_id: Option<String>,
746    pub device_id_hint: Option<String>,
747    pub active_transport_stable_id: Option<u64>,
748    pub transport_generation: u64,
749    pub route_generation: u64,
750    pub active_transport: String,
751    pub parallel_transport: Option<String>,
752    #[serde(default)]
753    pub latency_ms: Option<u64>,
754    #[serde(default)]
755    pub latency_by_transport: TransportLatencySnapshot,
756}
757
758#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
759#[serde(rename_all = "camelCase")]
760pub struct PeerSessionSnapshot {
761    pub peer_id: String,
762    pub device_id: Option<String>,
763    pub device_id_hint: Option<String>,
764    pub node_id: Option<String>,
765    pub active_connection_id: Option<String>,
766    pub candidate_connection_ids: Vec<String>,
767    pub status: crate::connection_manager::ConnectionState,
768    pub health: crate::connection_manager::ConnectionHealth,
769    pub settled_ready: bool,
770    pub readiness_state: ReadinessState,
771    pub active_transport_stable_id: Option<u64>,
772    pub transport_generation: u64,
773    pub route_generation: u64,
774    pub active_transport: String,
775    pub parallel_transport: Option<String>,
776    pub replacement_pending: bool,
777    pub last_lifecycle_transition_at_ms: i64,
778    pub readiness_reason: String,
779    pub transition_count: u64,
780    pub connecting_transition_count: u64,
781    pub replacement_count: u64,
782    pub retire_count: u64,
783    pub last_disconnect_reason: Option<String>,
784    pub last_reconnect_reason: Option<String>,
785    pub scopes: Vec<String>,
786    pub last_seen_at_ms: i64,
787    pub error: Option<String>,
788}
789
790#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
791#[serde(rename_all = "camelCase")]
792pub struct ManagedConnectResult {
793    pub connection_id: String,
794    pub device_id: Option<String>,
795    pub device_id_hint: Option<String>,
796    pub remote_node_id: String,
797    pub state: String,
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub approved_scope: Option<String>,
800}
801
802#[cfg(not(target_arch = "wasm32"))]
803#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
804#[serde(rename_all = "camelCase")]
805pub struct ManagedConnectionAdoption {
806    pub connection_id: String,
807    pub node_id: String,
808    pub device_id: Option<String>,
809    pub transport_generation: u64,
810    pub status_reason: Option<String>,
811    pub main_stream_ready: bool,
812}
813
814#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
815#[serde(rename_all = "camelCase")]
816pub enum ManagedConnectionHealthStatus {
817    Healthy,
818    AwaitingReplacement,
819    Dead,
820}
821
822#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
823#[serde(rename_all = "camelCase")]
824pub struct ManagedConnectionHealthSnapshot {
825    pub connection_id: String,
826    pub device_id: Option<String>,
827    pub device_id_hint: Option<String>,
828    pub node_id: Option<String>,
829    pub active_transport_stable_id: Option<u64>,
830    pub transport_generation: u64,
831    pub route_generation: u64,
832    pub status: ManagedConnectionHealthStatus,
833    pub settled_ready: bool,
834    pub readiness_state: ReadinessState,
835    pub replacement_pending: bool,
836    pub last_lifecycle_transition_at_ms: i64,
837    pub readiness_reason: String,
838    pub transition_count: u64,
839    pub connecting_transition_count: u64,
840    pub replacement_count: u64,
841    pub retire_count: u64,
842    pub last_disconnect_reason: Option<String>,
843    pub last_reconnect_reason: Option<String>,
844}
845
846#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
847#[serde(rename_all = "camelCase")]
848pub struct ConnectionStateSnapshot {
849    pub connection_id: String,
850    pub device_id: Option<String>,
851    pub device_id_hint: Option<String>,
852    pub remote_node_id: Option<String>,
853    pub state: String,
854    pub transport_state: String,
855    pub protocol_state: String,
856    pub routable: bool,
857    pub readiness_state: ReadinessState,
858    pub readiness_reason: String,
859    pub transport_generation: u64,
860    pub route_generation: u64,
861    pub active_transport_stable_id: Option<u64>,
862    pub active_transport: String,
863    pub parallel_transport: Option<String>,
864    pub replacement_in_progress: bool,
865    pub last_lifecycle_transition_at_ms: i64,
866    pub transition_count: u64,
867    pub connecting_transition_count: u64,
868    pub replacement_count: u64,
869    pub retire_count: u64,
870    pub last_disconnect_reason: Option<String>,
871    pub last_reconnect_reason: Option<String>,
872    pub error: Option<String>,
873    pub created_at: i64,
874    pub updated_at: i64,
875}
876
877#[cfg(target_arch = "wasm32")]
878#[derive(Debug, Clone, PartialEq, Eq)]
879struct WasmConnectionStateFingerprint {
880    connection_id: String,
881    device_id: Option<String>,
882    device_id_hint: Option<String>,
883    remote_node_id: Option<String>,
884    state: String,
885    transport_state: String,
886    protocol_state: String,
887    routable: bool,
888    transport_generation: u64,
889    route_generation: u64,
890    active_transport_stable_id: Option<u64>,
891    active_transport: String,
892    parallel_transport: Option<String>,
893    replacement_in_progress: bool,
894    transition_count: u64,
895    connecting_transition_count: u64,
896    replacement_count: u64,
897    retire_count: u64,
898    last_disconnect_reason: Option<String>,
899    last_reconnect_reason: Option<String>,
900    error: Option<String>,
901}
902
903#[cfg(target_arch = "wasm32")]
904impl From<&ConnectionStateSnapshot> for WasmConnectionStateFingerprint {
905    fn from(snapshot: &ConnectionStateSnapshot) -> Self {
906        Self {
907            connection_id: snapshot.connection_id.clone(),
908            device_id: snapshot.device_id.clone(),
909            device_id_hint: snapshot.device_id_hint.clone(),
910            remote_node_id: snapshot.remote_node_id.clone(),
911            state: snapshot.state.clone(),
912            transport_state: snapshot.transport_state.clone(),
913            protocol_state: snapshot.protocol_state.clone(),
914            routable: snapshot.routable,
915            transport_generation: snapshot.transport_generation,
916            route_generation: snapshot.route_generation,
917            active_transport_stable_id: snapshot.active_transport_stable_id,
918            active_transport: snapshot.active_transport.clone(),
919            parallel_transport: snapshot.parallel_transport.clone(),
920            replacement_in_progress: snapshot.replacement_in_progress,
921            transition_count: snapshot.transition_count,
922            connecting_transition_count: snapshot.connecting_transition_count,
923            replacement_count: snapshot.replacement_count,
924            retire_count: snapshot.retire_count,
925            last_disconnect_reason: snapshot.last_disconnect_reason.clone(),
926            last_reconnect_reason: snapshot.last_reconnect_reason.clone(),
927            error: snapshot.error.clone(),
928        }
929    }
930}
931
932#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
933#[serde(rename_all = "camelCase", tag = "kind")]
934pub enum ManagedConnectionBridgeAction {
935    Healthy,
936    AwaitReplacement {
937        reason: String,
938    },
939    Rebind {
940        remote_node_id: Option<String>,
941        transport_generation: u64,
942    },
943    Retire {
944        reason: String,
945    },
946}
947
948#[cfg(not(target_arch = "wasm32"))]
949#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950enum DuplicateClosedHandling {
951    RebindKeptTransport,
952    RetireClosedTransport,
953}
954
955#[cfg(not(target_arch = "wasm32"))]
956#[derive(Debug, Clone, Copy, PartialEq, Eq)]
957pub(crate) enum IncomingTransportCloseResolution {
958    PreservedKeptTransport,
959    RetiredClosedTransport,
960}
961
962#[cfg(not(target_arch = "wasm32"))]
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964enum AutoConnectTieBreakDecision {
965    ObserveConnectedTransport,
966    WaitForInitiator,
967    ActAsInitiator,
968}
969
970#[cfg(not(target_arch = "wasm32"))]
971fn duplicate_closed_handling(
972    close_reason_debug: &str,
973    kept_transport_alive: bool,
974    kept_transport_healthy: bool,
975    kept_stable_id: u64,
976    closed_stable_id: u64,
977) -> DuplicateClosedHandling {
978    let _ = close_reason_debug;
979    let _ = kept_transport_healthy;
980    if kept_stable_id != closed_stable_id && kept_transport_alive {
981        DuplicateClosedHandling::RebindKeptTransport
982    } else {
983        DuplicateClosedHandling::RetireClosedTransport
984    }
985}
986
987#[cfg(not(target_arch = "wasm32"))]
988fn auto_connect_tie_break_decision(
989    local_node_id: &str,
990    remote_node_id: &str,
991    transport_connected: bool,
992    waited_ms: i64,
993    initiator_grace_ms: i64,
994) -> AutoConnectTieBreakDecision {
995    if transport_connected {
996        return AutoConnectTieBreakDecision::ObserveConnectedTransport;
997    }
998    if local_node_id <= remote_node_id {
999        // Non-initiator waits for the grace period, then acts as initiator
1000        // to avoid getting stuck when the true initiator is unavailable.
1001        if waited_ms >= initiator_grace_ms {
1002            AutoConnectTieBreakDecision::ActAsInitiator
1003        } else {
1004            AutoConnectTieBreakDecision::WaitForInitiator
1005        }
1006    } else {
1007        AutoConnectTieBreakDecision::ActAsInitiator
1008    }
1009}
1010
1011fn normalize_lookup_id(value: Option<&str>) -> Option<String> {
1012    let trimmed = value?.trim();
1013    if trimmed.is_empty() {
1014        None
1015    } else {
1016        Some(trimmed.to_ascii_lowercase())
1017    }
1018}
1019
1020fn peer_snapshot_lookup_aliases(peer: &crate::connection_manager::PeerSnapshot) -> Vec<String> {
1021    [
1022        normalize_lookup_id(peer.device_id.as_deref()),
1023        normalize_lookup_id(peer.device_id_hint.as_deref()),
1024        normalize_lookup_id(peer.node_id.as_deref()),
1025        normalize_lookup_id(Some(peer.peer_id.as_str())),
1026    ]
1027    .into_iter()
1028    .flatten()
1029    .collect()
1030}
1031
1032fn peer_snapshot_matches_any_alias(
1033    peer: &crate::connection_manager::PeerSnapshot,
1034    aliases: &std::collections::HashSet<String>,
1035) -> bool {
1036    peer_snapshot_lookup_aliases(peer)
1037        .into_iter()
1038        .any(|alias| aliases.contains(&alias))
1039}
1040
1041fn preferred_peer_snapshot(
1042    current: Option<crate::connection_manager::PeerSnapshot>,
1043    candidate: crate::connection_manager::PeerSnapshot,
1044) -> crate::connection_manager::PeerSnapshot {
1045    match current {
1046        Some(existing) if !prefer_device_status_peer_snapshot(&candidate, &existing) => existing,
1047        _ => candidate,
1048    }
1049}
1050
1051fn snapshot_connection_status(
1052    device: &crate::signaling::Device,
1053    peer: Option<&crate::connection_manager::PeerSnapshot>,
1054) -> DeviceConnectionStatus {
1055    match peer.map(|value| &value.status) {
1056        Some(crate::connection_manager::ConnectionState::Pending)
1057        | Some(crate::connection_manager::ConnectionState::Connecting) => {
1058            DeviceConnectionStatus::Connecting
1059        }
1060        Some(crate::connection_manager::ConnectionState::Connected)
1061            if peer.map(peer_snapshot_settled_ready).unwrap_or(false) =>
1062        {
1063            DeviceConnectionStatus::Connected
1064        }
1065        Some(crate::connection_manager::ConnectionState::Connected) => {
1066            DeviceConnectionStatus::Connecting
1067        }
1068        Some(crate::connection_manager::ConnectionState::Failed) => DeviceConnectionStatus::Failed,
1069        Some(crate::connection_manager::ConnectionState::Closed)
1070        | Some(crate::connection_manager::ConnectionState::Closing) => {
1071            DeviceConnectionStatus::Closed
1072        }
1073        None if device.online => DeviceConnectionStatus::Online,
1074        None => DeviceConnectionStatus::Disconnected,
1075    }
1076}
1077
1078fn snapshot_settled_ready(
1079    peer: Option<&crate::connection_manager::PeerSnapshot>,
1080    connection_status: &DeviceConnectionStatus,
1081) -> bool {
1082    matches!(connection_status, DeviceConnectionStatus::Connected)
1083        && peer.map(peer_snapshot_settled_ready).unwrap_or(false)
1084}
1085
1086fn device_presence_updated_at(device: &crate::signaling::Device) -> Option<i64> {
1087    [
1088        device.last_seen_at.as_ref(),
1089        device.updated_at.as_ref(),
1090        device.created_at.as_ref(),
1091    ]
1092    .into_iter()
1093    .flatten()
1094    .filter_map(|value| crate::presence_policy::parse_millis(Some(value)))
1095    .max()
1096}
1097
1098fn device_presence_expires_at(device: &crate::signaling::Device) -> Option<i64> {
1099    crate::presence_policy::parse_millis(device.expires_at.as_ref())
1100}
1101
1102fn device_presence_status(
1103    device: &crate::signaling::Device,
1104    presence_updated_at: Option<i64>,
1105    now_ms: i64,
1106) -> DevicePresenceStatus {
1107    if !device.online {
1108        return DevicePresenceStatus::Offline;
1109    }
1110
1111    if presence_updated_at
1112        .map(|updated_at| {
1113            updated_at.saturating_add(crate::presence_policy::DEVICE_STALE_HEARTBEAT_MS) <= now_ms
1114        })
1115        .unwrap_or(false)
1116    {
1117        DevicePresenceStatus::Idle
1118    } else {
1119        DevicePresenceStatus::Online
1120    }
1121}
1122
1123fn device_connectable(
1124    device: &crate::signaling::Device,
1125    presence_status: &DevicePresenceStatus,
1126) -> bool {
1127    matches!(presence_status, DevicePresenceStatus::Online)
1128        && device
1129            .ticket
1130            .as_deref()
1131            .map(str::trim)
1132            .is_some_and(|value| !value.is_empty())
1133}
1134
1135fn peer_snapshot_settled_ready(peer: &crate::connection_manager::PeerSnapshot) -> bool {
1136    matches!(peer_readiness_state(peer), ReadinessState::Routable)
1137}
1138
1139fn peer_has_live_transport(peer: &crate::connection_manager::PeerSnapshot) -> bool {
1140    !peer.connection_ids.is_empty()
1141        && (peer.active_transport_stable_id.is_some()
1142            || crate::transport_label::is_independent_transport(peer.active_transport.as_str()))
1143}
1144
1145fn peer_readiness_state(peer: &crate::connection_manager::PeerSnapshot) -> ReadinessState {
1146    match peer.status {
1147        crate::connection_manager::ConnectionState::Pending
1148        | crate::connection_manager::ConnectionState::Connecting => ReadinessState::Connecting,
1149        crate::connection_manager::ConnectionState::Connected => {
1150            // A transport object or socket-ready optional route is not readiness
1151            // proof. Only a current-generation pong or generation-bound
1152            // application-route observation may set Healthy.
1153            let connection_ids_present = !peer.connection_ids.is_empty();
1154            let healthy_with_connections = connection_ids_present
1155                && matches!(
1156                    peer.health,
1157                    crate::connection_manager::ConnectionHealth::Healthy
1158                );
1159            if healthy_with_connections {
1160                ReadinessState::Routable
1161            } else if !peer_has_live_transport(peer) {
1162                ReadinessState::TransportOnly
1163            } else if now_millis_i64().saturating_sub(peer.last_lifecycle_transition_at_ms)
1164                >= MANAGED_SETTLE_DEADLINE_MS
1165            {
1166                ReadinessState::AwaitingReplacement
1167            } else {
1168                ReadinessState::Settling
1169            }
1170        }
1171        crate::connection_manager::ConnectionState::Closing
1172        | crate::connection_manager::ConnectionState::Closed => ReadinessState::Closed,
1173        crate::connection_manager::ConnectionState::Failed => ReadinessState::Failed,
1174    }
1175}
1176
1177fn peer_readiness_reason(peer: &crate::connection_manager::PeerSnapshot) -> String {
1178    match peer_readiness_state(peer) {
1179        ReadinessState::Connecting => "waiting-for-transport".to_string(),
1180        ReadinessState::TransportOnly => {
1181            "transport-attached-awaiting-readiness-confirmation".to_string()
1182        }
1183        ReadinessState::Settling => "transport-connected-health-check-pending".to_string(),
1184        ReadinessState::Routable => "transport-healthy".to_string(),
1185        ReadinessState::AwaitingReplacement => "readiness-confirmation-timed-out".to_string(),
1186        ReadinessState::Closed => "transport-closed".to_string(),
1187        ReadinessState::Failed => "connection-failed".to_string(),
1188    }
1189}
1190
1191fn connection_readiness_state(
1192    record: &crate::connection_manager::ConnectionRecord,
1193    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1194) -> ReadinessState {
1195    match record.state {
1196        crate::connection_manager::ConnectionState::Pending
1197        | crate::connection_manager::ConnectionState::Connecting => ReadinessState::Connecting,
1198        crate::connection_manager::ConnectionState::Connected => {
1199            if peer_snapshot
1200                .map(peer_snapshot_settled_ready)
1201                .unwrap_or(false)
1202            {
1203                ReadinessState::Routable
1204            } else if record.transport_stable_id.is_none() {
1205                ReadinessState::AwaitingReplacement
1206            } else if now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
1207                >= MANAGED_SETTLE_DEADLINE_MS
1208            {
1209                ReadinessState::AwaitingReplacement
1210            } else {
1211                ReadinessState::Settling
1212            }
1213        }
1214        crate::connection_manager::ConnectionState::Closing
1215        | crate::connection_manager::ConnectionState::Closed => ReadinessState::Closed,
1216        crate::connection_manager::ConnectionState::Failed => ReadinessState::Failed,
1217    }
1218}
1219
1220fn connection_readiness_reason(
1221    record: &crate::connection_manager::ConnectionRecord,
1222    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1223) -> String {
1224    match connection_readiness_state(record, peer_snapshot) {
1225        ReadinessState::Connecting => "waiting-for-transport".to_string(),
1226        ReadinessState::TransportOnly => {
1227            "transport-attached-awaiting-readiness-confirmation".to_string()
1228        }
1229        ReadinessState::Settling => "bounded-health-confirmation-pending".to_string(),
1230        ReadinessState::Routable => "transport-healthy".to_string(),
1231        ReadinessState::AwaitingReplacement => {
1232            if record.transport_stable_id.is_none() {
1233                "missing-active-transport".to_string()
1234            } else {
1235                "readiness-confirmation-timed-out".to_string()
1236            }
1237        }
1238        ReadinessState::Closed => "transport-closed".to_string(),
1239        ReadinessState::Failed => "connection-failed".to_string(),
1240    }
1241}
1242
1243fn public_peer_state(
1244    peer: &crate::connection_manager::PeerSnapshot,
1245) -> crate::connection_manager::ConnectionState {
1246    if matches!(
1247        peer.status,
1248        crate::connection_manager::ConnectionState::Connected
1249    ) && !peer_snapshot_settled_ready(peer)
1250    {
1251        crate::connection_manager::ConnectionState::Connecting
1252    } else {
1253        peer.status.clone()
1254    }
1255}
1256
1257fn peer_session_snapshot_from_peer(
1258    peer: crate::connection_manager::PeerSnapshot,
1259) -> PeerSessionSnapshot {
1260    let status = public_peer_state(&peer);
1261    let active_connection_id = peer.connection_ids.first().cloned();
1262    let candidate_connection_ids = if peer.connection_ids.len() > 1 {
1263        peer.connection_ids[1..].to_vec()
1264    } else {
1265        Vec::new()
1266    };
1267    let settled_ready = peer_snapshot_settled_ready(&peer);
1268    let readiness_state = peer_readiness_state(&peer);
1269    let readiness_reason = peer_readiness_reason(&peer);
1270    let crate::connection_manager::PeerSnapshot {
1271        peer_id,
1272        device_id,
1273        device_id_hint,
1274        node_id,
1275        active_transport_stable_id,
1276        active_transport_generation,
1277        active_route_generation,
1278        active_transport,
1279        parallel_transport,
1280        last_lifecycle_transition_at_ms,
1281        health,
1282        transition_count,
1283        connecting_transition_count,
1284        replacement_count,
1285        retire_count,
1286        last_disconnect_reason,
1287        last_reconnect_reason,
1288        scopes,
1289        last_seen_at_ms,
1290        error,
1291        ..
1292    } = peer;
1293
1294    PeerSessionSnapshot {
1295        peer_id,
1296        device_id,
1297        device_id_hint,
1298        node_id,
1299        active_connection_id,
1300        candidate_connection_ids,
1301        status,
1302        health,
1303        settled_ready,
1304        readiness_state: readiness_state.clone(),
1305        active_transport_stable_id,
1306        transport_generation: active_transport_generation,
1307        route_generation: active_route_generation,
1308        active_transport,
1309        parallel_transport,
1310        replacement_pending: matches!(readiness_state, ReadinessState::AwaitingReplacement),
1311        last_lifecycle_transition_at_ms,
1312        readiness_reason,
1313        transition_count,
1314        connecting_transition_count,
1315        replacement_count,
1316        retire_count,
1317        last_disconnect_reason,
1318        last_reconnect_reason,
1319        scopes,
1320        last_seen_at_ms,
1321        error,
1322    }
1323}
1324
1325fn connection_state_snapshot_from_parts(
1326    record: &crate::connection_manager::ConnectionRecord,
1327    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1328) -> ConnectionStateSnapshot {
1329    let settled_ready = peer_snapshot
1330        .map(peer_snapshot_settled_ready)
1331        .unwrap_or(false);
1332    let readiness_state = connection_readiness_state(record, peer_snapshot);
1333    // An in-place transport reconnect (the base transport closes with a
1334    // replacement/transient reason, then re-binds on the SAME connection — e.g.
1335    // physical reconnect, bumping transport_generation) is not a
1336    // disconnect. The record momentarily transitions Closing/Closed before the
1337    // successor transport lands; surfacing that intermediate frame as "closed" makes
1338    // every consumer flicker connected -> disconnected -> connected on each
1339    // generation bump. When the close reason is an in-place reconnect, report the
1340    // reconnecting family ("connecting") instead.
1341    //
1342    // Scoped to ReplacementInProgress + is_transient_reconnect (same-connection
1343    // reconnects). It deliberately excludes is_replacement_churn (replaced-by-new-*),
1344    // which marks a DIFFERENT connection that was genuinely superseded — that record
1345    // is correctly closed, and the peer is live on the successor connection. A
1346    // genuine close (manual / graceful / failed / revoked) keeps a non-reconnect
1347    // reason and still surfaces as closed; each transition overwrites status_reason,
1348    // so a later terminal close clears this.
1349    let in_place_reconnect_close = matches!(
1350        record.state,
1351        crate::connection_manager::ConnectionState::Closing
1352            | crate::connection_manager::ConnectionState::Closed
1353    ) && crate::lifecycle_reason::LifecycleReasonCode::from_text(
1354        record.status_reason.as_deref(),
1355    )
1356    .map(|code| {
1357        matches!(
1358            code,
1359            crate::lifecycle_reason::LifecycleReasonCode::ReplacementInProgress
1360        ) || code.is_transient_reconnect()
1361    })
1362    .unwrap_or(false);
1363    let state = match record.state {
1364        crate::connection_manager::ConnectionState::Pending => "connecting",
1365        crate::connection_manager::ConnectionState::Connecting => "connecting",
1366        crate::connection_manager::ConnectionState::Connected if settled_ready => "connected",
1367        crate::connection_manager::ConnectionState::Connected => "connecting",
1368        crate::connection_manager::ConnectionState::Failed => "failed",
1369        crate::connection_manager::ConnectionState::Closing
1370        | crate::connection_manager::ConnectionState::Closed
1371            if in_place_reconnect_close =>
1372        {
1373            "connecting"
1374        }
1375        crate::connection_manager::ConnectionState::Closing
1376        | crate::connection_manager::ConnectionState::Closed => "closed",
1377    };
1378    let transport_state = match record.state {
1379        crate::connection_manager::ConnectionState::Pending
1380        | crate::connection_manager::ConnectionState::Connecting => "connecting",
1381        crate::connection_manager::ConnectionState::Connected => "connected",
1382        crate::connection_manager::ConnectionState::Closing
1383        | crate::connection_manager::ConnectionState::Closed
1384            if in_place_reconnect_close =>
1385        {
1386            "connecting"
1387        }
1388        crate::connection_manager::ConnectionState::Failed
1389        | crate::connection_manager::ConnectionState::Closing
1390        | crate::connection_manager::ConnectionState::Closed => "closed",
1391    };
1392    let protocol_state = match record.state {
1393        crate::connection_manager::ConnectionState::Connected if settled_ready => "routable",
1394        crate::connection_manager::ConnectionState::Connected => "transport-only",
1395        crate::connection_manager::ConnectionState::Pending
1396        | crate::connection_manager::ConnectionState::Connecting => "connecting",
1397        crate::connection_manager::ConnectionState::Closing
1398        | crate::connection_manager::ConnectionState::Closed
1399            if in_place_reconnect_close =>
1400        {
1401            "connecting"
1402        }
1403        crate::connection_manager::ConnectionState::Failed
1404        | crate::connection_manager::ConnectionState::Closing
1405        | crate::connection_manager::ConnectionState::Closed => "closed",
1406    };
1407
1408    ConnectionStateSnapshot {
1409        connection_id: record.connection_id.clone(),
1410        device_id: record
1411            .device_id
1412            .clone()
1413            .or_else(|| peer_snapshot.and_then(|snapshot| snapshot.device_id.clone())),
1414        device_id_hint: record
1415            .device_id_hint
1416            .clone()
1417            .or_else(|| peer_snapshot.and_then(|snapshot| snapshot.device_id_hint.clone())),
1418        remote_node_id: record.node_id.clone(),
1419        state: state.to_string(),
1420        transport_state: transport_state.to_string(),
1421        protocol_state: protocol_state.to_string(),
1422        routable: settled_ready,
1423        readiness_state: readiness_state.clone(),
1424        readiness_reason: connection_readiness_reason(record, peer_snapshot),
1425        transport_generation: record.transport_generation,
1426        route_generation: record.route_generation,
1427        active_transport_stable_id: record.transport_stable_id,
1428        active_transport: record.active_transport.clone(),
1429        parallel_transport: record.parallel_transport.clone(),
1430        replacement_in_progress: matches!(readiness_state, ReadinessState::AwaitingReplacement),
1431        last_lifecycle_transition_at_ms: record
1432            .last_transport_change_at_ms
1433            .max(record.updated_at_ms),
1434        transition_count: record.transition_count,
1435        connecting_transition_count: record.connecting_transition_count,
1436        replacement_count: record.replacement_count,
1437        retire_count: record.retire_count,
1438        last_disconnect_reason: record.last_disconnect_reason.clone(),
1439        last_reconnect_reason: record.last_reconnect_reason.clone(),
1440        error: record.status_reason.clone(),
1441        created_at: record.created_at_ms,
1442        updated_at: record.updated_at_ms,
1443    }
1444}
1445
1446fn merge_device_status_snapshots(
1447    devices: Vec<crate::signaling::Device>,
1448    peers: Vec<crate::connection_manager::PeerSnapshot>,
1449) -> Vec<DeviceStatusSnapshot> {
1450    let mut peer_by_device: std::collections::HashMap<
1451        String,
1452        crate::connection_manager::PeerSnapshot,
1453    > = std::collections::HashMap::new();
1454
1455    for peer in peers {
1456        for alias in peer_snapshot_lookup_aliases(&peer) {
1457            let replace = match peer_by_device.get(&alias) {
1458                Some(existing) => prefer_device_status_peer_snapshot(&peer, existing),
1459                None => true,
1460            };
1461            if replace {
1462                peer_by_device.insert(alias, peer.clone());
1463            }
1464        }
1465    }
1466
1467    devices
1468        .into_iter()
1469        .map(|device| {
1470            let authoritative_node_id = normalize_lookup_id(device.node_id.as_deref());
1471            let lookup_aliases: Vec<String> = [
1472                normalize_lookup_id(Some(device.device_id.as_str())),
1473                authoritative_node_id.clone(),
1474            ]
1475            .into_iter()
1476            .flatten()
1477            .collect();
1478            let peer = lookup_aliases
1479                .iter()
1480                .filter_map(|key| peer_by_device.get(key).cloned())
1481                .filter(|candidate| {
1482                    let candidate_node_id = normalize_lookup_id(candidate.node_id.as_deref());
1483                    match (&authoritative_node_id, candidate_node_id) {
1484                        (Some(expected), Some(actual)) => expected == &actual,
1485                        _ => true,
1486                    }
1487                })
1488                .fold(None, |current, candidate| {
1489                    Some(preferred_peer_snapshot(current, candidate))
1490                });
1491            let connection_status = snapshot_connection_status(&device, peer.as_ref());
1492            let presence_updated_at = device_presence_updated_at(&device);
1493            let presence_expires_at = device_presence_expires_at(&device);
1494            let presence_status =
1495                device_presence_status(&device, presence_updated_at, now_millis_i64());
1496            let connectable = device_connectable(&device, &presence_status);
1497            let readiness_state = peer.as_ref().map(peer_readiness_state).unwrap_or_else(|| {
1498                if device.online {
1499                    ReadinessState::Connecting
1500                } else {
1501                    ReadinessState::Closed
1502                }
1503            });
1504            DeviceStatusSnapshot {
1505                presence_status,
1506                presence_updated_at,
1507                presence_expires_at,
1508                connectable,
1509                settled_ready: snapshot_settled_ready(peer.as_ref(), &connection_status),
1510                readiness_state: readiness_state.clone(),
1511                readiness_reason: peer.as_ref().map(peer_readiness_reason).unwrap_or_else(|| {
1512                    if device.online {
1513                        "device-online-awaiting-runtime-session".to_string()
1514                    } else {
1515                        "device-offline".to_string()
1516                    }
1517                }),
1518                connection_status,
1519                peer_health: peer
1520                    .as_ref()
1521                    .map(|value| value.health.clone())
1522                    .unwrap_or_else(|| {
1523                        if device.online {
1524                            crate::connection_manager::ConnectionHealth::Healthy
1525                        } else {
1526                            crate::connection_manager::ConnectionHealth::Unknown
1527                        }
1528                    }),
1529                peer_id: peer.as_ref().map(|value| value.peer_id.clone()),
1530                scopes: peer
1531                    .as_ref()
1532                    .map(|value| value.scopes.clone())
1533                    .unwrap_or_default(),
1534                connection_id: peer
1535                    .as_ref()
1536                    .and_then(|value| value.connection_ids.first().cloned()),
1537                device_id_hint: peer.as_ref().and_then(|value| value.device_id_hint.clone()),
1538                active_transport_stable_id: peer
1539                    .as_ref()
1540                    .and_then(|value| value.active_transport_stable_id),
1541                transport_generation: peer
1542                    .as_ref()
1543                    .map(|value| value.active_transport_generation)
1544                    .unwrap_or(0),
1545                route_generation: peer
1546                    .as_ref()
1547                    .map(|value| value.active_route_generation)
1548                    .unwrap_or(0),
1549                active_transport: peer
1550                    .as_ref()
1551                    .map(|value| value.active_transport.clone())
1552                    .unwrap_or_else(|| "iroh".to_string()),
1553                parallel_transport: peer
1554                    .as_ref()
1555                    .and_then(|value| value.parallel_transport.clone()),
1556                latency_ms: None,
1557                latency_by_transport: TransportLatencySnapshot::default(),
1558                device,
1559            }
1560        })
1561        .collect()
1562}
1563
1564fn peer_snapshot_recency_ms(peer: &crate::connection_manager::PeerSnapshot) -> i64 {
1565    peer.last_seen_at_ms
1566        .max(peer.last_lifecycle_transition_at_ms)
1567}
1568
1569/// Picks the winning [`PeerSnapshot`] when the same device/node alias maps to more than one
1570/// aggregate (different `peer_id` keys / backfill + dial rows). `device_status_peer_priority`
1571/// ranks *readiness* (Routable) ahead of *status* (Failed), which lets a stale
1572/// "connected + routable" snapshot replace a newer failed dial; mirror `strongest_state` by
1573/// comparing `Connected` vs `Failed` with recency first.
1574fn prefer_device_status_peer_snapshot(
1575    candidate: &crate::connection_manager::PeerSnapshot,
1576    existing: &crate::connection_manager::PeerSnapshot,
1577) -> bool {
1578    use crate::connection_manager::ConnectionState;
1579    let c = &candidate.status;
1580    let e = &existing.status;
1581    let c_fail = matches!(c, ConnectionState::Failed);
1582    let c_conn = matches!(c, ConnectionState::Connected);
1583    let e_fail = matches!(e, ConnectionState::Failed);
1584    let e_conn = matches!(e, ConnectionState::Connected);
1585
1586    // Data-plane truth: a Connected snapshot with a live base transport or an
1587    // independent upgraded transport reflects an actual working data path and
1588    // beats a Failed snapshot regardless of recency. Recency only matters when
1589    // neither side has a live transport, in which case we fall back to the
1590    // previous tie-break.
1591    let c_live = c_conn && peer_has_live_transport(candidate);
1592    let e_live = e_conn && peer_has_live_transport(existing);
1593    if c_live && !e_live {
1594        return true;
1595    }
1596    if !c_live && e_live {
1597        return false;
1598    }
1599
1600    if c_fail && e_conn {
1601        return peer_snapshot_recency_ms(candidate) >= peer_snapshot_recency_ms(existing);
1602    }
1603    if c_conn && e_fail {
1604        return peer_snapshot_recency_ms(candidate) > peer_snapshot_recency_ms(existing);
1605    }
1606    device_status_peer_priority(candidate) > device_status_peer_priority(existing)
1607}
1608
1609fn device_status_peer_priority(
1610    peer: &crate::connection_manager::PeerSnapshot,
1611) -> (u8, u8, u8, u8, i64, u64, u64) {
1612    let readiness_rank = match peer_readiness_state(peer) {
1613        ReadinessState::Routable => 5,
1614        ReadinessState::Settling => 4,
1615        ReadinessState::TransportOnly => 3,
1616        ReadinessState::Connecting => 2,
1617        ReadinessState::AwaitingReplacement => 1,
1618        ReadinessState::Closed | ReadinessState::Failed => 0,
1619    };
1620
1621    let status_rank = match peer.status {
1622        crate::connection_manager::ConnectionState::Connected => 3,
1623        crate::connection_manager::ConnectionState::Connecting
1624        | crate::connection_manager::ConnectionState::Pending => 2,
1625        crate::connection_manager::ConnectionState::Closing
1626        | crate::connection_manager::ConnectionState::Closed => 1,
1627        crate::connection_manager::ConnectionState::Failed => 0,
1628    };
1629
1630    let health_rank = match peer.health {
1631        crate::connection_manager::ConnectionHealth::Healthy => 3,
1632        crate::connection_manager::ConnectionHealth::Suspect => 2,
1633        crate::connection_manager::ConnectionHealth::Unknown => 1,
1634        crate::connection_manager::ConnectionHealth::Stale => 0,
1635    };
1636
1637    (
1638        readiness_rank,
1639        status_rank,
1640        health_rank,
1641        u8::from(peer_has_live_transport(peer)),
1642        peer.last_seen_at_ms,
1643        peer.transition_count,
1644        peer.replacement_count,
1645    )
1646}
1647
1648#[cfg(not(target_arch = "wasm32"))]
1649fn auto_connect_verbose() -> bool {
1650    std::env::var("OPENRTC_AUTOCONNECT_VERBOSE")
1651        .map(|value| {
1652            let normalized = value.trim().to_ascii_lowercase();
1653            normalized == "1" || normalized == "true" || normalized == "yes"
1654        })
1655        .unwrap_or(false)
1656}
1657
1658#[cfg(not(target_arch = "wasm32"))]
1659fn parse_env_bool(value: &str) -> Option<bool> {
1660    match value.trim().to_ascii_lowercase().as_str() {
1661        "1" | "true" | "yes" | "on" => Some(true),
1662        "0" | "false" | "no" | "off" => Some(false),
1663        _ => None,
1664    }
1665}
1666
1667#[cfg(not(target_arch = "wasm32"))]
1668fn env_bool(name: &str) -> Option<bool> {
1669    std::env::var(name)
1670        .ok()
1671        .and_then(|value| parse_env_bool(&value))
1672}
1673
1674#[cfg(not(target_arch = "wasm32"))]
1675fn should_use_relay_only_mode() -> bool {
1676    env_bool("PLUTO_IROH_RELAY_ONLY").unwrap_or(false)
1677}
1678
1679#[cfg(not(target_arch = "wasm32"))]
1680fn should_disable_ipv6() -> bool {
1681    resolve_disable_ipv6(
1682        env_bool("PLUTO_IROH_DISABLE_IPV6"),
1683        env_bool("PLUTO_IROH_ENABLE_IPV6"),
1684    )
1685}
1686
1687#[cfg(not(target_arch = "wasm32"))]
1688fn resolve_disable_ipv6(disable: Option<bool>, enable: Option<bool>) -> bool {
1689    disable
1690        .or_else(|| enable.map(|value| !value))
1691        .unwrap_or(false)
1692}
1693
1694#[cfg(not(target_arch = "wasm32"))]
1695fn apply_native_network_preferences(
1696    mut builder: iroh::endpoint::Builder,
1697    relay_only: bool,
1698    relay_transport_policy: IrohRelayTransportPolicy,
1699) -> anyhow::Result<iroh::endpoint::Builder> {
1700    #[cfg(openrtc_iroh_relay_transport_policy_api)]
1701    {
1702        let policy = match relay_transport_policy {
1703            IrohRelayTransportPolicy::Auto => iroh::RelayTransportPolicy::Auto,
1704            IrohRelayTransportPolicy::QuicRequired => iroh::RelayTransportPolicy::QuicRequired,
1705            IrohRelayTransportPolicy::WebsocketRequired => {
1706                iroh::RelayTransportPolicy::WebsocketRequired
1707            }
1708        };
1709        builder = builder.relay_transport_policy(policy);
1710    }
1711    #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
1712    {
1713        match relay_transport_policy {
1714            IrohRelayTransportPolicy::QuicRequired => {
1715                anyhow::bail!(
1716                    "requested relay carriage quicRequired is unsupported by this upstream-Iroh build; refusing to initialize"
1717                );
1718            }
1719            IrohRelayTransportPolicy::Auto => {
1720                eprintln!(
1721                    "[pluto-rtc][native] requested relay carriage auto; effective carriage is websocket fallback because this upstream-Iroh build has no QUIC relay policy API"
1722                );
1723            }
1724            IrohRelayTransportPolicy::WebsocketRequired => {}
1725        }
1726    }
1727
1728    // Increase QUIC idle timeout to survive iOS background suspension.
1729    // The default is 6.5s (5s heartbeat + 1.5s grace), which is too aggressive:
1730    // when iOS suspends the process, PING frames stop and the connection is torn
1731    // down before the OS resumes us. 120s allows the app to be backgrounded,
1732    // complete its background task window (~30s), and still reconnect gracefully
1733    // without the connection being declared dead mid-transfer.
1734    // Keep-alive interval defaults to 25s on desktop. On mobile targets use a
1735    // slower cadence to reduce background wake pressure.
1736    //
1737    // Flow-control window tuning for relay throughput:
1738    // When using the iroh relay (WebSocket-based for WASM↔native), the round-trip
1739    // time is ~100–300 ms. Quinn's defaults (stream_receive_window=256KB,
1740    // receive_window=1MB, send_window=2MB) cap throughput at roughly 1–10 MB/s on
1741    // these high-latency paths because the sender stalls waiting for WINDOW_UPDATE
1742    // frames. Setting windows large enough to cover the bandwidth-delay product
1743    // (target 40 MB/s × 300 ms RTT = 12 MB) avoids flow-control stalls entirely.
1744    builder = builder.transport_config(crate::native_node::native_iroh_transport_config());
1745
1746    if relay_only {
1747        // Relay-only mode avoids NAT traversal + local candidate churn on constrained networks.
1748        return Ok(builder.clear_ip_transports());
1749    }
1750
1751    if should_disable_ipv6() {
1752        // IPv4-only is an explicit compatibility override. Native endpoints are
1753        // dual-stack by default so iOS remains routable on IPv6-only/NAT64 networks.
1754        builder = builder.clear_ip_transports();
1755        builder = builder
1756            .bind_addr("0.0.0.0:0")
1757            .map_err(|e| anyhow::anyhow!("failed to bind IPv4 transport: {}", e))?;
1758    }
1759
1760    Ok(builder)
1761}
1762
1763#[cfg(not(target_arch = "wasm32"))]
1764fn auto_connect_failure_backoff_ms(failure_count: u8) -> i64 {
1765    match failure_count {
1766        0 => 0,
1767        _ => {
1768            // Foreground transport failures should recover quickly. Slow or
1769            // offline peers are still bounded, but one startup race must not
1770            // turn an already-discovered peer into a minute-long wait.
1771            let exp = 500_i64.saturating_mul(1_i64 << (failure_count as u32).min(4));
1772            exp.min(8_000)
1773        }
1774    }
1775}
1776
1777#[cfg(not(target_arch = "wasm32"))]
1778fn auto_connect_admission_rejection_backoff_ms(failure_count: u8) -> i64 {
1779    match failure_count {
1780        0 => 0,
1781        _ => {
1782            // Invalid or unauthorized credentials are not a reachability
1783            // failure. Keep the conservative schedule until coordination
1784            // publishes materially new ticket data.
1785            let exp = 1_000_i64.saturating_mul(1_i64 << (failure_count as u32).min(5));
1786            exp.min(30_000)
1787        }
1788    }
1789}
1790
1791#[cfg(not(target_arch = "wasm32"))]
1792fn auto_connect_network_change_failure_threshold() -> u8 {
1793    std::env::var("OPENRTC_NETWORK_CHANGE_FAILURE_THRESHOLD")
1794        .ok()
1795        .and_then(|value| value.parse::<u8>().ok())
1796        .map(|value| value.clamp(1, 6))
1797        .unwrap_or(2)
1798}
1799
1800/// Grace period for the non-initiator before it escalates to dial.
1801/// Uses exponential backoff with 20 % jitter to avoid duplicate-storm collisions.
1802/// Progression: 2 s → 4 s → 8 s → 16 s (cap) with ±20 % jitter.
1803#[cfg(not(target_arch = "wasm32"))]
1804fn non_initiator_escalation_grace_ms(escalation_count: u8) -> i64 {
1805    let base_ms = 2_000_i64;
1806    let exp_ms = base_ms.saturating_mul(1_i64 << (escalation_count as u32).min(3));
1807    let capped_ms = exp_ms.min(16_000);
1808    // Add ±20 % jitter using the escalation count as a cheap deterministic seed.
1809    // A real random source would be nicer but we avoid pulling in a dependency.
1810    let jitter_pct = ((escalation_count as i64 * 37 + 7) % 41) - 20; // -20..+20
1811    let jitter_ms = capped_ms * jitter_pct / 100;
1812    (capped_ms + jitter_ms).max(1_000)
1813}
1814
1815#[cfg(not(target_arch = "wasm32"))]
1816fn next_non_initiator_wakeup_delay_ms(
1817    wait_started_at: &std::collections::HashMap<String, i64>,
1818    escalation_count: &std::collections::HashMap<String, u8>,
1819    now_ms: i64,
1820) -> Option<u64> {
1821    wait_started_at
1822        .iter()
1823        .map(|(device_id, started_at)| {
1824            let grace_ms = non_initiator_escalation_grace_ms(
1825                escalation_count.get(device_id).copied().unwrap_or(0),
1826            );
1827            started_at
1828                .saturating_add(grace_ms)
1829                .saturating_sub(now_ms)
1830                .max(1) as u64
1831        })
1832        .min()
1833}
1834
1835#[cfg(not(target_arch = "wasm32"))]
1836fn register_auto_connect_failure(
1837    remote_device_id: &str,
1838    now_ms: i64,
1839    failure_count: &mut std::collections::HashMap<String, u8>,
1840    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
1841) {
1842    let count = failure_count
1843        .entry(remote_device_id.to_string())
1844        .or_insert(0);
1845    *count = count.saturating_add(1).min(10);
1846    let backoff_ms = auto_connect_failure_backoff_ms(*count);
1847    failure_backoff_until.insert(
1848        remote_device_id.to_string(),
1849        now_ms.saturating_add(backoff_ms),
1850    );
1851}
1852
1853#[cfg(not(target_arch = "wasm32"))]
1854fn register_auto_connect_admission_rejection(
1855    remote_device_id: &str,
1856    now_ms: i64,
1857    failure_count: &mut std::collections::HashMap<String, u8>,
1858    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
1859) {
1860    let count = failure_count
1861        .entry(remote_device_id.to_string())
1862        .or_insert(0);
1863    *count = count.saturating_add(1).min(10);
1864    let backoff_ms = auto_connect_admission_rejection_backoff_ms(*count);
1865    failure_backoff_until.insert(
1866        remote_device_id.to_string(),
1867        now_ms.saturating_add(backoff_ms),
1868    );
1869}
1870
1871#[cfg(not(target_arch = "wasm32"))]
1872fn clear_auto_connect_failure_state(
1873    remote_device_id: &str,
1874    failure_count: &mut std::collections::HashMap<String, u8>,
1875    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
1876) {
1877    failure_count.remove(remote_device_id);
1878    failure_backoff_until.remove(remote_device_id);
1879}
1880
1881#[cfg(target_arch = "wasm32")]
1882fn emit_wasm_connection_state_event(snapshot: &ConnectionStateSnapshot) {
1883    use wasm_bindgen::JsValue;
1884
1885    let Some(window) = web_sys::window() else {
1886        return;
1887    };
1888
1889    let detail = js_sys::Object::new();
1890    let _ = js_sys::Reflect::set(
1891        &detail,
1892        &JsValue::from_str("connectionId"),
1893        &JsValue::from_str(snapshot.connection_id.as_str()),
1894    );
1895    let _ = js_sys::Reflect::set(
1896        &detail,
1897        &JsValue::from_str("state"),
1898        &JsValue::from_str(snapshot.state.as_str()),
1899    );
1900    let _ = js_sys::Reflect::set(
1901        &detail,
1902        &JsValue::from_str("createdAt"),
1903        &JsValue::from_f64(snapshot.created_at as f64),
1904    );
1905    let _ = js_sys::Reflect::set(
1906        &detail,
1907        &JsValue::from_str("updatedAt"),
1908        &JsValue::from_f64(snapshot.updated_at as f64),
1909    );
1910    let _ = js_sys::Reflect::set(
1911        &detail,
1912        &JsValue::from_str("transportState"),
1913        &JsValue::from_str(snapshot.transport_state.as_str()),
1914    );
1915    let _ = js_sys::Reflect::set(
1916        &detail,
1917        &JsValue::from_str("protocolState"),
1918        &JsValue::from_str(snapshot.protocol_state.as_str()),
1919    );
1920    let _ = js_sys::Reflect::set(
1921        &detail,
1922        &JsValue::from_str("routable"),
1923        &JsValue::from_bool(snapshot.routable),
1924    );
1925    let _ = js_sys::Reflect::set(
1926        &detail,
1927        &JsValue::from_str("transportGeneration"),
1928        &JsValue::from_f64(snapshot.transport_generation as f64),
1929    );
1930    let _ = js_sys::Reflect::set(
1931        &detail,
1932        &JsValue::from_str("routeGeneration"),
1933        &JsValue::from_f64(snapshot.route_generation as f64),
1934    );
1935    let _ = js_sys::Reflect::set(
1936        &detail,
1937        &JsValue::from_str("activeTransportStableId"),
1938        &snapshot
1939            .active_transport_stable_id
1940            .map(|value| JsValue::from_f64(value as f64))
1941            .unwrap_or(JsValue::NULL),
1942    );
1943    let _ = js_sys::Reflect::set(
1944        &detail,
1945        &JsValue::from_str("activeTransport"),
1946        &JsValue::from_str(snapshot.active_transport.as_str()),
1947    );
1948    let _ = js_sys::Reflect::set(
1949        &detail,
1950        &JsValue::from_str("parallelTransport"),
1951        &snapshot
1952            .parallel_transport
1953            .as_deref()
1954            .map(JsValue::from_str)
1955            .unwrap_or(JsValue::NULL),
1956    );
1957    let _ = js_sys::Reflect::set(
1958        &detail,
1959        &JsValue::from_str("replacementInProgress"),
1960        &JsValue::from_bool(snapshot.replacement_in_progress),
1961    );
1962    let _ = js_sys::Reflect::set(
1963        &detail,
1964        &JsValue::from_str("transitionCount"),
1965        &JsValue::from_f64(snapshot.transition_count as f64),
1966    );
1967    let _ = js_sys::Reflect::set(
1968        &detail,
1969        &JsValue::from_str("connectingTransitionCount"),
1970        &JsValue::from_f64(snapshot.connecting_transition_count as f64),
1971    );
1972    let _ = js_sys::Reflect::set(
1973        &detail,
1974        &JsValue::from_str("replacementCount"),
1975        &JsValue::from_f64(snapshot.replacement_count as f64),
1976    );
1977    let _ = js_sys::Reflect::set(
1978        &detail,
1979        &JsValue::from_str("retireCount"),
1980        &JsValue::from_f64(snapshot.retire_count as f64),
1981    );
1982
1983    if let Some(device_id) = snapshot.device_id.as_deref() {
1984        let _ = js_sys::Reflect::set(
1985            &detail,
1986            &JsValue::from_str("deviceId"),
1987            &JsValue::from_str(device_id),
1988        );
1989    }
1990    if let Some(device_id_hint) = snapshot.device_id_hint.as_deref() {
1991        let _ = js_sys::Reflect::set(
1992            &detail,
1993            &JsValue::from_str("deviceIdHint"),
1994            &JsValue::from_str(device_id_hint),
1995        );
1996    }
1997    if let Some(remote_node_id) = snapshot.remote_node_id.as_deref() {
1998        let _ = js_sys::Reflect::set(
1999            &detail,
2000            &JsValue::from_str("remoteNodeId"),
2001            &JsValue::from_str(remote_node_id),
2002        );
2003    }
2004    if let Some(error) = snapshot.error.as_deref() {
2005        let _ = js_sys::Reflect::set(
2006            &detail,
2007            &JsValue::from_str("error"),
2008            &JsValue::from_str(error),
2009        );
2010    }
2011    if let Some(reason) = snapshot.last_disconnect_reason.as_deref() {
2012        let _ = js_sys::Reflect::set(
2013            &detail,
2014            &JsValue::from_str("lastDisconnectReason"),
2015            &JsValue::from_str(reason),
2016        );
2017    }
2018    if let Some(reason) = snapshot.last_reconnect_reason.as_deref() {
2019        let _ = js_sys::Reflect::set(
2020            &detail,
2021            &JsValue::from_str("lastReconnectReason"),
2022            &JsValue::from_str(reason),
2023        );
2024    }
2025
2026    let init = web_sys::CustomEventInit::new();
2027    init.set_detail(&detail.into());
2028    if let Ok(event) =
2029        web_sys::CustomEvent::new_with_event_init_dict("connection-state-changed", &init)
2030    {
2031        let _ = window.dispatch_event(&event);
2032    }
2033}
2034
2035fn parse_endpoint_ticket(ticket: &str) -> anyhow::Result<iroh::EndpointAddr> {
2036    let trimmed = ticket.trim();
2037    if trimmed.is_empty() {
2038        return Err(anyhow::anyhow!("endpoint ticket is required"));
2039    }
2040
2041    let parsed = EndpointTicket::from_str(trimmed)
2042        .map_err(|e| anyhow::anyhow!("invalid endpoint ticket: {}", e))?;
2043    Ok(parsed.endpoint_addr().clone())
2044}
2045
2046#[derive(Debug, Clone, serde::Serialize)]
2047#[serde(rename_all = "camelCase")]
2048pub struct RuntimeStatus {
2049    pub ready: bool,
2050    pub node_id: Option<String>,
2051    pub transport: RuntimeTransportStatus,
2052}
2053
2054#[derive(Debug, Clone, serde::Serialize)]
2055#[serde(rename_all = "camelCase")]
2056pub struct RuntimeTransportFeatureStatus {
2057    pub compiled: bool,
2058    pub enabled: bool,
2059}
2060
2061impl RuntimeTransportFeatureStatus {
2062    pub(crate) fn new(compiled: bool, enabled: bool) -> Self {
2063        Self {
2064            compiled,
2065            enabled: compiled && enabled,
2066        }
2067    }
2068}
2069
2070#[derive(Debug, Clone, serde::Serialize)]
2071#[serde(rename_all = "camelCase")]
2072pub struct RuntimeTransportStatus {
2073    pub iroh_quic: RuntimeTransportFeatureStatus,
2074    pub iroh_lan: RuntimeTransportFeatureStatus,
2075    pub web_rtc: RuntimeTransportFeatureStatus,
2076    pub web_rtc_lan: RuntimeTransportFeatureStatus,
2077    pub moq: RuntimeTransportFeatureStatus,
2078    pub ble: RuntimeTransportFeatureStatus,
2079    pub iroh_relay_only: bool,
2080    #[serde(skip_serializing_if = "Option::is_none")]
2081    pub iroh_relay_transport_policy: Option<IrohRelayTransportPolicy>,
2082    pub iroh_relay: IrohRelayDiagnosticStatus,
2083}
2084
2085#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2086#[serde(rename_all = "camelCase")]
2087pub enum IrohRelayProvider {
2088    /// The vendored OpenRTC Iroh fork exposes QUIC relay carriage selection.
2089    VendoredIroh,
2090    /// An upstream Iroh build exposes the WebSocket relay carriage only.
2091    UpstreamIroh,
2092}
2093
2094#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2095#[serde(rename_all = "camelCase")]
2096pub enum IrohRelayEffectiveCarriage {
2097    Quic,
2098    Websocket,
2099    QuicWithWebsocketFallback,
2100    WebsocketFallback,
2101    Unsupported,
2102}
2103
2104#[derive(Debug, Clone, serde::Serialize)]
2105#[serde(rename_all = "camelCase")]
2106pub struct IrohRelayDiagnosticStatus {
2107    pub provider: IrohRelayProvider,
2108    pub requested_carriage: IrohRelayTransportPolicy,
2109    pub effective_carriage: IrohRelayEffectiveCarriage,
2110}
2111
2112impl IrohRelayDiagnosticStatus {
2113    pub(crate) fn from_policy(
2114        requested_carriage: IrohRelayTransportPolicy,
2115        provider: IrohRelayProvider,
2116    ) -> Self {
2117        let effective_carriage = match requested_carriage {
2118            IrohRelayTransportPolicy::WebsocketRequired => IrohRelayEffectiveCarriage::Websocket,
2119            IrohRelayTransportPolicy::Auto => {
2120                #[cfg(target_arch = "wasm32")]
2121                {
2122                    IrohRelayEffectiveCarriage::Websocket
2123                }
2124                #[cfg(all(not(target_arch = "wasm32"), openrtc_iroh_relay_transport_policy_api))]
2125                {
2126                    IrohRelayEffectiveCarriage::QuicWithWebsocketFallback
2127                }
2128                #[cfg(all(
2129                    not(target_arch = "wasm32"),
2130                    not(openrtc_iroh_relay_transport_policy_api)
2131                ))]
2132                {
2133                    IrohRelayEffectiveCarriage::WebsocketFallback
2134                }
2135            }
2136            IrohRelayTransportPolicy::QuicRequired => {
2137                #[cfg(target_arch = "wasm32")]
2138                {
2139                    IrohRelayEffectiveCarriage::Unsupported
2140                }
2141                #[cfg(all(not(target_arch = "wasm32"), openrtc_iroh_relay_transport_policy_api))]
2142                {
2143                    IrohRelayEffectiveCarriage::Quic
2144                }
2145                #[cfg(all(
2146                    not(target_arch = "wasm32"),
2147                    not(openrtc_iroh_relay_transport_policy_api)
2148                ))]
2149                {
2150                    IrohRelayEffectiveCarriage::Unsupported
2151                }
2152            }
2153        };
2154
2155        Self {
2156            provider,
2157            requested_carriage,
2158            effective_carriage,
2159        }
2160    }
2161}
2162
2163#[cfg(openrtc_iroh_relay_transport_policy_api)]
2164pub(crate) const fn current_iroh_relay_provider() -> IrohRelayProvider {
2165    IrohRelayProvider::VendoredIroh
2166}
2167
2168#[cfg(not(openrtc_iroh_relay_transport_policy_api))]
2169pub(crate) const fn current_iroh_relay_provider() -> IrohRelayProvider {
2170    IrohRelayProvider::UpstreamIroh
2171}
2172
2173impl RuntimeTransportStatus {
2174    pub(crate) fn from_config_with_ble_available(
2175        config: &TransportConfig,
2176        ble_available: bool,
2177        relay_provider: IrohRelayProvider,
2178    ) -> Self {
2179        let lan_compiled = cfg!(feature = "transport-lan");
2180        let lan_enabled = config
2181            .iroh_lan
2182            .as_ref()
2183            .map(|lan| lan.enabled)
2184            .unwrap_or(false);
2185        let webrtc_compiled = cfg!(feature = "transport-webrtc");
2186        let webrtc_enabled = config.webrtc.is_some();
2187        let webrtc_lan_enabled = config
2188            .webrtc
2189            .as_ref()
2190            .map(|webrtc| webrtc.lan_mode)
2191            .unwrap_or(false);
2192        let moq_compiled = cfg!(feature = "transport-moq");
2193        let moq_enabled = config.moq.is_some();
2194        let ble_compiled = ble_available;
2195        let ble_enabled = config.ble.as_ref().map(|ble| ble.enabled).unwrap_or(false);
2196
2197        Self {
2198            iroh_quic: RuntimeTransportFeatureStatus::new(true, true),
2199            iroh_lan: RuntimeTransportFeatureStatus::new(lan_compiled, lan_enabled),
2200            web_rtc: RuntimeTransportFeatureStatus::new(webrtc_compiled, webrtc_enabled),
2201            web_rtc_lan: RuntimeTransportFeatureStatus::new(
2202                webrtc_compiled,
2203                webrtc_enabled && webrtc_lan_enabled,
2204            ),
2205            moq: RuntimeTransportFeatureStatus::new(moq_compiled, moq_enabled),
2206            ble: RuntimeTransportFeatureStatus::new(ble_compiled, ble_enabled),
2207            iroh_relay_only: config.iroh_relay_only,
2208            iroh_relay_transport_policy: config.iroh_relay_transport_policy.clone(),
2209            iroh_relay: IrohRelayDiagnosticStatus::from_policy(
2210                config
2211                    .iroh_relay_transport_policy
2212                    .clone()
2213                    .unwrap_or(IrohRelayTransportPolicy::WebsocketRequired),
2214                relay_provider,
2215            ),
2216        }
2217    }
2218}
2219
2220#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2221#[serde(rename_all = "camelCase")]
2222pub enum AuthMode {
2223    External,
2224    Anonymous,
2225    Required,
2226}
2227
2228#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2229#[serde(rename_all = "camelCase")]
2230pub struct IceServerConfig {
2231    #[serde(default)]
2232    pub urls: Vec<String>,
2233    #[serde(default, skip_serializing_if = "Option::is_none")]
2234    pub username: Option<String>,
2235    #[serde(default, skip_serializing_if = "Option::is_none")]
2236    pub credential: Option<String>,
2237}
2238
2239#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
2240#[serde(rename_all = "camelCase")]
2241pub struct WebRTCConfig {
2242    #[serde(default)]
2243    pub ice_servers: Vec<IceServerConfig>,
2244    #[serde(default)]
2245    pub privacy_mode: bool,
2246    #[serde(default)]
2247    pub lan_mode: bool,
2248}
2249
2250#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2251#[serde(rename_all = "camelCase")]
2252pub struct MoQConfig {
2253    #[serde(default)]
2254    pub relay_url: String,
2255    #[serde(default, skip_serializing)]
2256    pub access_token: Option<String>,
2257}
2258
2259impl std::fmt::Debug for MoQConfig {
2260    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2261        let relay_label = self
2262            .relay_url
2263            .split(['?', '#'])
2264            .next()
2265            .unwrap_or("invalid-moq-relay-url");
2266        formatter
2267            .debug_struct("MoQConfig")
2268            .field("relay_url", &relay_label)
2269            .field(
2270                "access_token",
2271                &self.access_token.as_ref().map(|_| "[redacted]"),
2272            )
2273            .finish()
2274    }
2275}
2276
2277impl Default for MoQConfig {
2278    fn default() -> Self {
2279        Self {
2280            relay_url: String::new(),
2281            access_token: None,
2282        }
2283    }
2284}
2285
2286#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2287#[serde(rename_all = "camelCase")]
2288pub enum IrohRelayTransportPolicy {
2289    Auto,
2290    QuicRequired,
2291    WebsocketRequired,
2292}
2293
2294#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2295#[serde(rename_all = "camelCase")]
2296pub struct TransportConfig {
2297    #[serde(default)]
2298    pub iroh_relay_only: bool,
2299    #[serde(default, skip_serializing_if = "Option::is_none")]
2300    pub iroh_relay_transport_policy: Option<IrohRelayTransportPolicy>,
2301    #[serde(default, skip_serializing_if = "Option::is_none")]
2302    pub iroh_lan: Option<IrohLanConfig>,
2303    #[serde(default, skip_serializing_if = "Option::is_none")]
2304    pub webrtc: Option<WebRTCConfig>,
2305    #[serde(default, skip_serializing_if = "Option::is_none")]
2306    pub moq: Option<MoQConfig>,
2307    #[serde(default, skip_serializing_if = "Option::is_none")]
2308    pub ble: Option<BleConfig>,
2309}
2310
2311impl Default for TransportConfig {
2312    fn default() -> Self {
2313        Self {
2314            iroh_relay_only: false,
2315            iroh_relay_transport_policy: None,
2316            iroh_lan: Some(IrohLanConfig::default()),
2317            webrtc: None,
2318            moq: None,
2319            ble: None,
2320        }
2321    }
2322}
2323
2324impl TransportConfig {
2325    /// Returns true when applying `next` requires constructing a new Iroh
2326    /// endpoint. These options control socket binding or address discovery and
2327    /// cannot be truthfully changed on an endpoint that is already running.
2328    #[cfg(not(target_arch = "wasm32"))]
2329    pub(crate) fn endpoint_rebind_required(&self, next: &Self) -> bool {
2330        self.iroh_relay_only != next.iroh_relay_only
2331            || self.iroh_relay_transport_policy != next.iroh_relay_transport_policy
2332            || self.iroh_lan != next.iroh_lan
2333    }
2334
2335    pub fn sanitize_for_runtime(self) -> Self {
2336        self.sanitize_for_runtime_with_ble_available(false)
2337    }
2338
2339    pub(crate) fn sanitize_for_runtime_with_ble_available(mut self, ble_available: bool) -> Self {
2340        if self.ble.as_ref().map(|ble| ble.enabled).unwrap_or(false) && !ble_available {
2341            self.ble = None;
2342        }
2343
2344        self
2345    }
2346}
2347
2348pub struct ClientBuilder {
2349    project_id: String,
2350    app_tag: String,
2351    auth_mode: AuthMode,
2352    transport_config: TransportConfig,
2353    token_provider: Arc<dyn Fn() -> Option<String> + Send + Sync>,
2354    native_trust_token_provider: Arc<dyn Fn() -> Option<NativeTrustToken> + Send + Sync>,
2355    signaling: Option<Arc<dyn SignalingBackend>>,
2356    room: Option<Arc<dyn RoomBackend>>,
2357}
2358
2359impl ClientBuilder {
2360    pub fn new(
2361        project_id: String,
2362        api_key: String,
2363        token_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
2364    ) -> Self {
2365        Self::new_with_app_tag(
2366            project_id,
2367            crate::app_tag_from_api_key(&api_key),
2368            token_provider,
2369        )
2370    }
2371
2372    pub fn new_with_app_tag(
2373        project_id: String,
2374        app_tag: String,
2375        token_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
2376    ) -> Self {
2377        Self {
2378            project_id,
2379            app_tag,
2380            auth_mode: AuthMode::External,
2381            transport_config: TransportConfig::default(),
2382            token_provider: Arc::from(token_provider),
2383            native_trust_token_provider: Arc::new(|| None),
2384            signaling: None,
2385            room: None,
2386        }
2387    }
2388
2389    pub fn auth_mode(mut self, auth_mode: AuthMode) -> Self {
2390        self.auth_mode = auth_mode;
2391        self
2392    }
2393
2394    #[cfg(not(target_arch = "wasm32"))]
2395    pub fn native_auth_state(mut self, auth_state: crate::native_auth::NativeAuthState) -> Self {
2396        self.token_provider = Arc::from(auth_state.token_provider());
2397        self
2398    }
2399
2400    pub fn transport_config(mut self, transport_config: TransportConfig) -> Self {
2401        self.transport_config = transport_config.sanitize_for_runtime();
2402        self
2403    }
2404
2405    pub fn native_trust_token_provider(
2406        mut self,
2407        provider: Box<dyn Fn() -> Option<NativeTrustToken> + Send + Sync>,
2408    ) -> Self {
2409        self.native_trust_token_provider = Arc::from(provider);
2410        self
2411    }
2412
2413    pub fn signaling_backend(mut self, signaling: Arc<dyn SignalingBackend>) -> Self {
2414        self.signaling = Some(signaling);
2415        self
2416    }
2417
2418    pub fn room_backend(mut self, room: Arc<dyn RoomBackend>) -> Self {
2419        self.room = Some(room);
2420        self
2421    }
2422
2423    pub fn build(self) -> Client {
2424        let app_backgrounded = Arc::new(AtomicBool::new(false));
2425
2426        let room = self
2427            .room
2428            .unwrap_or_else(|| Arc::new(GatewayRequiredRoomBackend) as Arc<dyn RoomBackend>);
2429
2430        let signaling = self.signaling.unwrap_or_else(|| {
2431            Arc::new(GatewayRequiredSignalingBackend) as Arc<dyn SignalingBackend>
2432        });
2433
2434        #[cfg(not(target_arch = "wasm32"))]
2435        let (native_device_updates, _) = tokio::sync::broadcast::channel(32);
2436        #[cfg(not(target_arch = "wasm32"))]
2437        let (native_connection_state_updates, _) = tokio::sync::broadcast::channel(64);
2438        #[cfg(not(target_arch = "wasm32"))]
2439        let (native_peer_data_updates, _) = tokio::sync::broadcast::channel(256);
2440        #[cfg(not(target_arch = "wasm32"))]
2441        let (native_application_streams, native_application_streams_receiver) =
2442            async_channel::bounded(256);
2443
2444        Client {
2445            app_tag: self.app_tag,
2446            signaling,
2447            room,
2448            node_id: Arc::new(RwLock::new(None)),
2449            iroh_endpoint: Arc::new(RwLock::new(None)),
2450            #[cfg(target_arch = "wasm32")]
2451            iroh_node: Arc::new(RwLock::new(None)),
2452            #[cfg(not(target_arch = "wasm32"))]
2453            iroh_node: Arc::new(RwLock::new(None)),
2454            #[cfg(not(target_arch = "wasm32"))]
2455            native_application_streams,
2456            #[cfg(not(target_arch = "wasm32"))]
2457            native_application_streams_receiver,
2458            connection_manager: Arc::new(crate::connection_manager::ConnectionManager::new()),
2459            session_token_registry: Arc::new(crate::session_token::SessionTokenRegistry::new()),
2460            #[cfg(not(target_arch = "wasm32"))]
2461            inbound_session_admission_transport_ids: Arc::new(StdRwLock::new(HashMap::new())),
2462            #[cfg(not(target_arch = "wasm32"))]
2463            native_admission_stream_contracts: Arc::new(StdRwLock::new(HashMap::new())),
2464            remote_session_admission_proofs: Arc::new(StdRwLock::new(HashMap::new())),
2465            pending_inline_reciprocal_admissions: Arc::new(StdRwLock::new(HashMap::new())),
2466            outbound_application_security_epoch_fingerprints: Arc::new(StdRwLock::new(
2467                HashMap::new(),
2468            )),
2469            #[cfg(not(target_arch = "wasm32"))]
2470            pending_reciprocal_session_admission_requests: Arc::new(StdRwLock::new(HashSet::new())),
2471            #[cfg(not(target_arch = "wasm32"))]
2472            native_route_repair_credentials: Arc::new(StdRwLock::new(HashMap::new())),
2473            connection_application_crypto_keys:
2474                crate::client::application_crypto_impl::new_connection_application_crypto_key_map(),
2475            connection_application_crypto_required:
2476                crate::client::application_crypto_impl::new_connection_application_crypto_required_set(),
2477            #[cfg(not(target_arch = "wasm32"))]
2478            trusted_user_device_application_crypto_required: Arc::new(AtomicBool::new(false)),
2479            connection_application_crypto_confirmed:
2480                crate::client::application_crypto_impl::new_connection_application_crypto_confirmed_set(),
2481            #[cfg(not(target_arch = "wasm32"))]
2482            connection_application_crypto_confirmation_updates: Arc::new(
2483                tokio::sync::Notify::new(),
2484            ),
2485            connection_application_crypto_outbound_sequences:
2486                crate::client::application_crypto_impl::new_connection_application_crypto_outbound_sequences(),
2487            connection_application_key_agreements:
2488                crate::client::application_crypto_impl::new_connection_application_key_agreement_map(),
2489            #[cfg(not(target_arch = "wasm32"))]
2490            managed_scope_tickets: Arc::new(StdRwLock::new(HashMap::new())),
2491            known_endpoint_addrs: Arc::new(RwLock::new(HashMap::new())),
2492            known_device_ids_by_node: Arc::new(StdRwLock::new(HashMap::new())),
2493            #[cfg(not(target_arch = "wasm32"))]
2494            native_device_identity: Arc::new(RwLock::new(None)),
2495            #[cfg(not(target_arch = "wasm32"))]
2496            native_device_base_dir: Arc::new(RwLock::new(None)),
2497            #[cfg(not(target_arch = "wasm32"))]
2498            native_device_identity_init_guard: Arc::new(tokio::sync::Mutex::new(())),
2499            #[cfg(not(target_arch = "wasm32"))]
2500            native_device_updates,
2501            #[cfg(not(target_arch = "wasm32"))]
2502            native_connection_state_updates,
2503            #[cfg(not(target_arch = "wasm32"))]
2504            native_peer_data_updates,
2505            auto_connect_loop_key: Arc::new(Mutex::new(None)),
2506            auto_connect_generation: Arc::new(AtomicU64::new(0)),
2507            #[cfg(not(target_arch = "wasm32"))]
2508            external_desired_peer_actor: Arc::new(tokio::sync::Mutex::new(
2509                auto_connect_impl::NativeExternalAutoConnectActorState::default(),
2510            )),
2511            auto_connect_excluded: Arc::new(Mutex::new(HashSet::new())),
2512            auto_connect_peer_requested_excluded: Arc::new(Mutex::new(HashSet::new())),
2513            auto_connect_excluded_node_aliases: Arc::new(Mutex::new(HashMap::new())),
2514            app_backgrounded,
2515            #[cfg(target_arch = "wasm32")]
2516            wasm_accept_bridge_started: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2517            #[cfg(target_arch = "wasm32")]
2518            last_emitted_connection_states: Arc::new(Mutex::new(std::collections::HashMap::new())),
2519            #[cfg(target_arch = "wasm32")]
2520            last_empty_peer_sessions_warning_ms: Arc::new(AtomicU64::new(0)),
2521            iroh_init_guard: Arc::new(tokio::sync::Mutex::new(())),
2522            managed_connect_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2523            #[cfg(not(target_arch = "wasm32"))]
2524            iroh_path_watcher_stable_ids: Arc::new(Mutex::new(HashSet::new())),
2525            #[cfg(not(target_arch = "wasm32"))]
2526            native_custom_transport_kinds: Arc::new(RwLock::new(HashMap::new())),
2527            #[cfg(not(target_arch = "wasm32"))]
2528            native_transport_upgrade_providers: Arc::new(RwLock::new(HashMap::new())),
2529            #[cfg(not(target_arch = "wasm32"))]
2530            native_transport_upgrade_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2531            #[cfg(not(target_arch = "wasm32"))]
2532            native_ble_upgrade_attempts: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2533            #[cfg(not(target_arch = "wasm32"))]
2534            native_peer_transport_capabilities: Arc::new(RwLock::new(HashMap::new())),
2535            #[cfg(not(target_arch = "wasm32"))]
2536            native_scoped_webrtc_signal_peer_connections: Arc::new(RwLock::new(HashSet::new())),
2537            presence_loop_tx: Arc::new(Mutex::new(None)),
2538            #[cfg(not(target_arch = "wasm32"))]
2539            #[cfg(not(target_arch = "wasm32"))]
2540            project_id: self.project_id,
2541            #[cfg(not(target_arch = "wasm32"))]
2542            token_provider: self.token_provider,
2543            transport_config: Arc::new(RwLock::new(self.transport_config.sanitize_for_runtime())),
2544            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
2545            local_discovery_registry: crate::local_discovery::LocalDiscoveryRegistry::new(),
2546            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
2547            mdns_address_lookup: Arc::new(RwLock::new(None)),
2548            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2549            native_webrtc_sessions: Arc::new(RwLock::new(HashMap::new())),
2550            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2551            native_webrtc_route_proofs: Arc::new(RwLock::new(HashMap::new())),
2552            #[cfg(not(target_arch = "wasm32"))]
2553            native_optional_route_generations: Arc::new(RwLock::new(HashMap::new())),
2554            #[cfg(not(target_arch = "wasm32"))]
2555            native_route_start_gates: Arc::new(
2556                route_adapter::NativeRouteStartGateRegistry::default(),
2557            ),
2558            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2559            native_webrtc_suppressions: Arc::new(RwLock::new(HashMap::new())),
2560            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2561            native_webrtc_attempt_counts: Arc::new(RwLock::new(HashMap::new())),
2562            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2563            native_webrtc_start_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2564            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2565            native_webrtc_attempts_in_flight: Arc::new(RwLock::new(HashMap::new())),
2566            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2567            native_webrtc_retry_deadlines: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2568            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
2569            deferred_managed_retirements: Arc::new(RwLock::new(HashMap::new())),
2570            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
2571            native_moq_sessions: Arc::new(RwLock::new(HashMap::new())),
2572            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
2573            native_moq_route_proofs: Arc::new(RwLock::new(HashMap::new())),
2574            #[cfg(not(target_arch = "wasm32"))]
2575            native_control_streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
2576            #[cfg(not(target_arch = "wasm32"))]
2577            native_transport_protocol_activity: Arc::new(StdRwLock::new(HashMap::new())),
2578            #[cfg(not(target_arch = "wasm32"))]
2579            pending_native_control_handoffs: Arc::new(StdRwLock::new(HashMap::new())),
2580            #[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
2581            scoped_connection_actor_registry: Arc::new(RwLock::new(None)),
2582            #[cfg(not(target_arch = "wasm32"))]
2583            auth_readiness: Arc::new(crate::client::auth_readiness::AuthReadinessStore::new()),
2584            #[cfg(not(target_arch = "wasm32"))]
2585            scope_classifier: Arc::new(RwLock::new(default_scope_classifier())),
2586        }
2587    }
2588}
2589
2590// Delegated implementation modules for readability and ownership boundaries.
2591// - `core_impl`: constructors, endpoint lifecycle, transport primitives, accept bridges.
2592// - `admission_impl`: session-token admission and managed-scope persistence.
2593// - `state_signaling_impl`: peer state APIs, managed connection snapshots, signaling loops.
2594// - `auto_connect_impl`: transport liveness checks and auto-connect policy loop internals.
2595// - `scoped_connection_actor`: optional native experiment, never a default
2596//   lifecycle authority until it owns real dial and channel routing end-to-end.
2597mod admission_impl;
2598mod application_crypto_impl;
2599#[cfg(not(target_arch = "wasm32"))]
2600pub mod auth_readiness;
2601mod auto_connect_impl;
2602mod core_impl;
2603#[cfg(not(target_arch = "wasm32"))]
2604pub mod correlation;
2605#[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
2606mod drive_grant_actor;
2607#[cfg(all(
2608    test,
2609    not(target_arch = "wasm32"),
2610    feature = "experimental-scoped-actor"
2611))]
2612mod drive_grant_actor_tests;
2613#[cfg(not(target_arch = "wasm32"))]
2614mod route_adapter;
2615#[cfg(not(target_arch = "wasm32"))]
2616pub mod scope_classifier;
2617#[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
2618pub mod scoped_connection_actor;
2619mod state_signaling_impl;
2620#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
2621pub(crate) use route_adapter::NativeOptionalRouteTerminalDisposition;
2622#[cfg(not(target_arch = "wasm32"))]
2623pub(crate) use route_adapter::{NativeOptionalRouteGeneration, NativeOptionalRouteGenerations};
2624#[cfg(not(target_arch = "wasm32"))]
2625mod transport_upgrade_impl;
2626#[cfg(all(not(target_arch = "wasm32"), test))]
2627pub(crate) use transport_upgrade_impl::{
2628    retire_losing_native_control_candidate, retire_replaced_native_control_send,
2629};
2630
2631/// WASM stub for `send_peer`.
2632///
2633/// On native this is provided by `transport_upgrade_impl`. On WASM the TypeScript
2634/// layer owns transport selection; this stub exists for API symmetry.
2635#[cfg(target_arch = "wasm32")]
2636impl Client {
2637    pub async fn send_peer(&self, _id: &str, _data: &[u8]) -> anyhow::Result<()> {
2638        Err(anyhow::anyhow!(
2639            "send_peer: use TypeScript Connection.sendTyped() for transport-aware sending in browser environments"
2640        ))
2641    }
2642}
2643
2644#[cfg(not(target_arch = "wasm32"))]
2645impl Client {
2646    pub fn subscribe_native_peer_data(
2647        &self,
2648    ) -> tokio::sync::broadcast::Receiver<NativePeerDataEvent> {
2649        self.native_peer_data_updates.subscribe()
2650    }
2651
2652    #[cfg_attr(
2653        not(any(feature = "transport-webrtc", feature = "transport-moq")),
2654        allow(dead_code)
2655    )]
2656    pub(crate) fn emit_native_peer_data(&self, event: NativePeerDataEvent) {
2657        let _ = self.native_peer_data_updates.send(event);
2658    }
2659}
2660
2661#[cfg(test)]
2662mod tests;
2663
2664#[cfg(all(test, not(target_arch = "wasm32")))]
2665mod relay_diagnostic_truth_tests {
2666    use super::*;
2667
2668    #[test]
2669    fn auto_reports_requested_and_effective_relay_carriage() {
2670        let diagnostic = IrohRelayDiagnosticStatus::from_policy(
2671            IrohRelayTransportPolicy::Auto,
2672            current_iroh_relay_provider(),
2673        );
2674
2675        assert_eq!(
2676            diagnostic.requested_carriage,
2677            IrohRelayTransportPolicy::Auto
2678        );
2679        let serialized = serde_json::to_value(&diagnostic).expect("relay diagnostic serializes");
2680        assert_eq!(serialized["requestedCarriage"], "auto");
2681        #[cfg(openrtc_iroh_relay_transport_policy_api)]
2682        assert_eq!(
2683            diagnostic.effective_carriage,
2684            IrohRelayEffectiveCarriage::QuicWithWebsocketFallback
2685        );
2686        #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
2687        assert_eq!(
2688            diagnostic.effective_carriage,
2689            IrohRelayEffectiveCarriage::WebsocketFallback
2690        );
2691    }
2692
2693    #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
2694    #[test]
2695    fn upstream_quic_required_fails_closed_before_endpoint_bind() {
2696        let result = apply_native_network_preferences(
2697            iroh::Endpoint::builder(iroh::endpoint::presets::N0),
2698            false,
2699            IrohRelayTransportPolicy::QuicRequired,
2700        );
2701
2702        assert!(result.is_err());
2703        assert!(result
2704            .err()
2705            .expect("unsupported QUIC policy should return an error")
2706            .to_string()
2707            .contains("unsupported by this upstream-Iroh build"));
2708    }
2709
2710    #[cfg(openrtc_iroh_relay_transport_policy_api)]
2711    #[test]
2712    fn vendored_quic_required_reports_quic_and_keeps_endpoint_policy_available() {
2713        let diagnostic = IrohRelayDiagnosticStatus::from_policy(
2714            IrohRelayTransportPolicy::QuicRequired,
2715            current_iroh_relay_provider(),
2716        );
2717
2718        assert_eq!(
2719            diagnostic.effective_carriage,
2720            IrohRelayEffectiveCarriage::Quic
2721        );
2722        assert_eq!(diagnostic.provider, IrohRelayProvider::VendoredIroh);
2723    }
2724}