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