cat / goq
clone

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

fix/release-manifest-language
goq / src-tauri / src / commands / state.rs
state.rs 26.1K
1use serde::Serialize;2use sigil_protocol::{3 ClientControlEnvelopeV2, KeyframeRequestReasonV3, MediaFeedbackReportV1, SessionSnapshotV2,4};5use std::collections::BTreeSet;6use std::ffi::OsString;7use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};8use std::sync::{Arc, Mutex as StdMutex};9use tokio::sync::Mutex as TokioMutex;1011#[cfg(target_os = "macos")]12static MACOS_CURSOR_HIDE_DEPTH: StdMutex<usize> = StdMutex::new(0);13#[cfg(target_os = "macos")]14static MACOS_CURSOR_GRABBED: AtomicBool = AtomicBool::new(false);1516#[cfg(target_os = "macos")]17#[link(name = "CoreGraphics", kind = "framework")]18unsafe extern "C" {19 fn CGMainDisplayID() -> u32;20 fn CGAssociateMouseAndMouseCursorPosition(connected: u32) -> i32;21 fn CGDisplayHideCursor(display: u32) -> i32;22 fn CGDisplayShowCursor(display: u32) -> i32;23}2425#[cfg(target_os = "macos")]26fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {27 if !should_apply_native_cursor_association(MACOS_CURSOR_GRABBED.load(Ordering::Acquire), grab) {28 return Ok(());29 }30 let status = unsafe { CGAssociateMouseAndMouseCursorPosition(u32::from(!grab)) };31 if status != 0 {32 return Err(format!(33 "Could not {} native cursor grab: CGError {status}",34 if grab { "enable" } else { "release" }35 ));36 }37 MACOS_CURSOR_GRABBED.store(grab, Ordering::Release);38 Ok(())39}4041/// CoreGraphics cursor disassociation is scoped to the foreground application.42/// Re-applying an active grab is therefore required after the window regains43/// focus even though Sigil's desired grab state has not changed.44#[cfg(any(target_os = "macos", test))]45const fn should_apply_native_cursor_association(current: bool, requested: bool) -> bool {46 requested || current != requested47}4849#[cfg(all(50 not(target_os = "macos"),51 feature = "experimental-non-macos-pointer-capture"52))]53fn set_native_cursor_grab(window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {54 window55 .set_cursor_grab(grab)56 .map_err(|error| format!("Could not set native cursor grab to {grab}: {error}"))57}5859#[cfg(all(60 not(target_os = "macos"),61 not(feature = "experimental-non-macos-pointer-capture")62))]63fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {64 if grab {65 return Err(66 "Relative pointer capture is unavailable on this Portal build; rebuild with the \67 experimental-non-macos-pointer-capture feature for platform UAT"68 .to_string(),69 );70 }71 Ok(())72}7374#[cfg(target_os = "macos")]75fn set_macos_cursor_hidden(hidden: bool) -> Result<(), String> {76 let mut depth = MACOS_CURSOR_HIDE_DEPTH77 .lock()78 .map_err(|_| "macOS cursor hide state is poisoned".to_string())?;79 update_macos_cursor_hide_depth(80 &mut depth,81 hidden,82 false,83 hide_macos_cursor,84 show_macos_cursor,85 )86}8788#[cfg(target_os = "macos")]89fn reassert_macos_cursor_hidden() -> Result<(), String> {90 let mut depth = MACOS_CURSOR_HIDE_DEPTH91 .lock()92 .map_err(|_| "macOS cursor hide state is poisoned".to_string())?;93 update_macos_cursor_hide_depth(&mut depth, true, true, hide_macos_cursor, show_macos_cursor)94}9596#[cfg(target_os = "macos")]97fn hide_macos_cursor() -> Result<(), String> {98 // kCGDirectMainDisplay is a macro that calls CGMainDisplayID(), not a99 // zero-valued sentinel. Passing zero can report success without hiding the100 // cursor on the active display.101 let status = unsafe { CGDisplayHideCursor(CGMainDisplayID()) };102 (status == 0)103 .then_some(())104 .ok_or_else(|| format!("Could not globally hide the macOS cursor: CGError {status}"))105}106107#[cfg(target_os = "macos")]108fn show_macos_cursor() -> Result<(), String> {109 let status = unsafe { CGDisplayShowCursor(CGMainDisplayID()) };110 (status == 0)111 .then_some(())112 .ok_or_else(|| format!("Could not restore the macOS cursor: CGError {status}"))113}114115#[cfg(any(target_os = "macos", test))]116fn update_macos_cursor_hide_depth(117 depth: &mut usize,118 hidden: bool,119 force: bool,120 mut hide: impl FnMut() -> Result<(), String>,121 mut show: impl FnMut() -> Result<(), String>,122) -> Result<(), String> {123 if hidden {124 if *depth > 0 && !force {125 return Ok(());126 }127 hide()?;128 *depth = depth129 .checked_add(1)130 .ok_or_else(|| "macOS cursor hide depth overflowed".to_string())?;131 return Ok(());132 }133134 while *depth > 0 {135 show()?;136 *depth -= 1;137 }138 Ok(())139}140141#[cfg(not(target_os = "macos"))]142fn set_macos_cursor_hidden(_hidden: bool) -> Result<(), String> {143 Ok(())144}145146pub(crate) fn restore_client_cursor() {147 let _ = set_macos_cursor_hidden(false);148 #[cfg(target_os = "macos")]149 {150 let status = unsafe { CGAssociateMouseAndMouseCursorPosition(1) };151 if status == 0 {152 MACOS_CURSOR_GRABBED.store(false, Ordering::Release);153 }154 }155}156157#[cfg(target_os = "macos")]158pub(crate) fn reassert_client_cursor_grab(window: &tauri::WebviewWindow) -> Result<(), String> {159 if !MACOS_CURSOR_GRABBED.load(Ordering::Acquire) {160 return Ok(());161 }162 set_native_cursor_grab(window, true)?;163 reassert_macos_cursor_hidden()?;164 let cursor_rect_result = reapply_hidden_cursor_rect(|visible| {165 window.set_cursor_visible(visible).map_err(|error| {166 format!(167 "Could not {} after focus changed: {error}",168 if visible {169 "reset native cursor visibility state"170 } else {171 "re-hide native cursor"172 }173 )174 })175 });176 // WebKit also rebuilds cursor rectangles after focus. Refresh both native177 // layers: the CoreGraphics hide is acquired before Tao briefly resets its178 // cursor state, so the local pointer cannot flash at its frozen position.179 cursor_rect_result180}181182#[cfg(not(target_os = "macos"))]183pub(crate) fn reassert_client_cursor_grab(_window: &tauri::WebviewWindow) -> Result<(), String> {184 Ok(())185}186187fn reapply_hidden_cursor_rect(188 mut set_visible: impl FnMut(bool) -> Result<(), String>,189) -> Result<(), String> {190 // Tao only invalidates the platform cursor state when this boolean191 // changes. Force that transition while the native grab is active; on192 // macOS the balanced CoreGraphics hide prevents the intermediate state193 // from flashing at the frozen cursor position.194 set_visible(true)?;195 set_visible(false)196}197198pub const AUDIO_DELIVERY_CAPACITY: usize = 3;199200#[derive(Debug, Default)]201pub struct AudioDeliveryState {202 generation: Option<u64>,203 next_delivery_id: u64,204 outstanding: BTreeSet<u64>,205}206207impl AudioDeliveryState {208 pub fn begin_generation(&mut self, generation: u64) -> Result<(), String> {209 if generation == 0 {210 return Err("Audio generation must be non-zero".to_string());211 }212 self.generation = Some(generation);213 self.next_delivery_id = 1;214 self.outstanding.clear();215 Ok(())216 }217218 pub fn reserve(&mut self, generation: u64) -> Result<Option<u64>, String> {219 if self.generation != Some(generation) {220 return Err(format!(221 "Stale audio generation {generation}; current generation is {:?}",222 self.generation223 ));224 }225 if self.outstanding.len() >= AUDIO_DELIVERY_CAPACITY {226 return Ok(None);227 }228 let delivery_id = self.next_delivery_id;229 self.next_delivery_id = delivery_id230 .checked_add(1)231 .ok_or_else(|| "Audio delivery ID overflowed".to_string())?;232 if !self.outstanding.insert(delivery_id) {233 return Err(format!("Audio delivery ID {delivery_id} was reused"));234 }235 Ok(Some(delivery_id))236 }237238 pub fn acknowledge(&mut self, generation: u64, delivery_id: u64) -> Result<(), String> {239 if self.generation != Some(generation) {240 return Err(format!(241 "Stale audio generation {generation}; current generation is {:?}",242 self.generation243 ));244 }245 if !self.outstanding.remove(&delivery_id) {246 return Err(format!(247 "Unknown or already acknowledged audio delivery ID {delivery_id}"248 ));249 }250 Ok(())251 }252253 pub fn release_failed_delivery(&mut self, generation: u64, delivery_id: u64) -> bool {254 self.generation == Some(generation) && self.outstanding.remove(&delivery_id)255 }256257 pub fn cancel_generation(&mut self, expected_generation: u64) -> bool {258 if self.generation != Some(expected_generation) {259 return false;260 }261 self.generation = None;262 self.outstanding.clear();263 true264 }265266 pub fn depth(&self, generation: u64) -> Option<usize> {267 (self.generation == Some(generation)).then_some(self.outstanding.len())268 }269270 pub fn generation(&self) -> Option<u64> {271 self.generation272 }273274 pub fn clear(&mut self) {275 self.generation = None;276 self.outstanding.clear();277 }278}279280pub const fn development_direct_node_available() -> bool {281 cfg!(any(debug_assertions, feature = "demo-direct-node"))282}283284#[derive(Clone, Debug, Default, PartialEq, Eq)]285pub struct LaunchOptions {286 pub dev_connect_node_id: Option<iroh::PublicKey>,287}288289pub fn parse_launch_options<I, S>(args: I, allow_dev_connect: bool) -> Result<LaunchOptions, String>290where291 I: IntoIterator<Item = S>,292 S: Into<OsString>,293{294 let mut options = LaunchOptions::default();295 let mut args = args.into_iter().map(Into::into);296297 // The first value is the executable path. Unknown values are left for the298 // platform/Tauri runtime, but Portal-owned flags are parsed strictly.299 let _ = args.next();300 while let Some(arg) = args.next() {301 if arg == "--daemon" {302 return Err(303 "--daemon is not supported by Portal; run the separate sigil executable"304 .to_string(),305 );306 }307308 let arg = arg309 .into_string()310 .map_err(|_| "Portal command-line arguments must be valid UTF-8".to_string())?;311 let node_id = if arg == "--dev-connect" {312 Some(313 args.next()314 .ok_or_else(|| "--dev-connect requires an iroh node ID".to_string())?315 .into_string()316 .map_err(|_| "The --dev-connect node ID must be valid UTF-8".to_string())?,317 )318 } else {319 arg.strip_prefix("--dev-connect=").map(str::to_owned)320 };321322 let Some(node_id) = node_id else {323 continue;324 };325 if !allow_dev_connect {326 return Err(327 "--dev-connect requires a debug build or the explicit demo-direct-node feature; ordinary release builds require the normal passkey flow"328 .to_string(),329 );330 }331 if options.dev_connect_node_id.is_some() {332 return Err("--dev-connect may be specified only once".to_string());333 }334 if node_id.is_empty() {335 return Err("--dev-connect requires an iroh node ID".to_string());336 }337 options.dev_connect_node_id = Some(338 node_id339 .parse::<iroh::PublicKey>()340 .map_err(|e| format!("Invalid iroh node ID for --dev-connect: {e}"))?,341 );342 }343344 Ok(options)345}346347pub struct AppState {348 pub enrollment: super::enrollment::EnrollmentState,349 pub input_send: TokioMutex<Option<tokio::sync::mpsc::Sender<sigil_protocol::InputEvent>>>,350 pub input_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,351 pub v2_input_context: TokioMutex<Option<crate::media::input_delivery::V2InputContext>>,352 pub client_endpoint: TokioMutex<Option<iroh::Endpoint>>,353 pub media_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,354 pub media_control: TokioMutex<Option<(u64, MediaControlRequestSender)>>,355 pub session_control: TokioMutex<Option<(u64, SessionControlSender)>>,356 pub session_snapshot: Arc<TokioMutex<Option<SessionSnapshotState>>>,357 pub media_feedback: TokioMutex<Option<(u64, iroh::endpoint::Connection, MediaFeedbackSender)>>,358 pub media_feedback_report_id: Arc<AtomicU64>,359 pub control_request_id: Arc<AtomicU64>,360 pub frame_delivery: TokioMutex<Option<(u64, Arc<AtomicUsize>)>>,361 pub client_media_generation: Arc<AtomicU64>,362 pub audio_deliveries: Arc<StdMutex<AudioDeliveryState>>,363 pub audio_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,364 pub audio_connection_generation: Arc<AtomicU64>,365 pub client_connection_serial: TokioMutex<()>,366 pub client_connection_active: Arc<AtomicBool>,367 pub webcodecs: AtomicBool,368 pub dev_connect_node_id: Option<iroh::PublicKey>,369}370371pub type MediaControlRequestSender =372 tokio::sync::mpsc::Sender<(KeyframeRequestReasonV3, Option<u64>)>;373pub type MediaFeedbackSender = tokio::sync::watch::Sender<Option<AccumulatedMediaFeedback>>;374pub type SessionControlSender = tokio::sync::mpsc::Sender<ClientControlEnvelopeV2>;375pub type SessionSnapshotState = (u64, SessionSnapshotV2);376377const MAX_MEDIA_FEEDBACK_INTERVAL_MS: u64 = 5_000;378379/// Constant-size latest receiver state plus cumulative interval pressure.380///381/// A watch channel deliberately coalesces updates while its consumer is382/// blocked. Keeping cumulative counters here lets the writer recover the full383/// delta since its last successful write instead of losing the reports that384/// were replaced in the meantime.385#[derive(Clone, Copy, Debug, PartialEq, Eq)]386pub struct AccumulatedMediaFeedback {387 latest: MediaFeedbackReportV1,388 interval_ms_total: u64,389 transport_dropped_total: u64,390 frontend_dropped_total: u64,391 decoder_dropped_total: u64,392 presenter_dropped_total: u64,393}394395impl AccumulatedMediaFeedback {396 pub fn new(report: MediaFeedbackReportV1) -> Self {397 Self {398 latest: report,399 interval_ms_total: u64::from(report.interval_ms),400 transport_dropped_total: u64::from(report.transport_dropped_delta),401 frontend_dropped_total: u64::from(report.frontend_dropped_delta),402 decoder_dropped_total: u64::from(report.decoder_dropped_delta),403 presenter_dropped_total: u64::from(report.presenter_dropped_delta),404 }405 }406407 pub fn merge(&mut self, report: MediaFeedbackReportV1) {408 self.latest = report;409 self.interval_ms_total = self410 .interval_ms_total411 .saturating_add(u64::from(report.interval_ms));412 self.transport_dropped_total = self413 .transport_dropped_total414 .saturating_add(u64::from(report.transport_dropped_delta));415 self.frontend_dropped_total = self416 .frontend_dropped_total417 .saturating_add(u64::from(report.frontend_dropped_delta));418 self.decoder_dropped_total = self419 .decoder_dropped_total420 .saturating_add(u64::from(report.decoder_dropped_delta));421 self.presenter_dropped_total = self422 .presenter_dropped_total423 .saturating_add(u64::from(report.presenter_dropped_delta));424 }425426 pub fn report_since(self, previous: Option<Self>) -> MediaFeedbackReportV1 {427 let previous = previous.unwrap_or(Self {428 latest: self.latest,429 interval_ms_total: 0,430 transport_dropped_total: 0,431 frontend_dropped_total: 0,432 decoder_dropped_total: 0,433 presenter_dropped_total: 0,434 });435 let mut report = self.latest;436 report.interval_ms = self437 .interval_ms_total438 .saturating_sub(previous.interval_ms_total)439 .min(MAX_MEDIA_FEEDBACK_INTERVAL_MS) as u16;440 report.transport_dropped_delta = cumulative_delta(441 self.transport_dropped_total,442 previous.transport_dropped_total,443 );444 report.frontend_dropped_delta =445 cumulative_delta(self.frontend_dropped_total, previous.frontend_dropped_total);446 report.decoder_dropped_delta =447 cumulative_delta(self.decoder_dropped_total, previous.decoder_dropped_total);448 report.presenter_dropped_delta = cumulative_delta(449 self.presenter_dropped_total,450 previous.presenter_dropped_total,451 );452 report453 }454}455456fn cumulative_delta(current: u64, previous: u64) -> u32 {457 current.saturating_sub(previous).min(u64::from(u32::MAX)) as u32458}459460impl Default for AppState {461 fn default() -> Self {462 Self::new(LaunchOptions::default())463 }464}465466impl AppState {467 pub fn from_args<I, S>(args: I) -> Result<Self, String>468 where469 I: IntoIterator<Item = S>,470 S: Into<OsString>,471 {472 parse_launch_options(args, development_direct_node_available()).map(Self::new)473 }474475 fn new(options: LaunchOptions) -> Self {476 Self {477 enrollment: super::enrollment::EnrollmentState::default(),478 input_send: TokioMutex::new(None),479 input_connection: TokioMutex::new(None),480 v2_input_context: TokioMutex::new(None),481 client_endpoint: TokioMutex::new(None),482 media_connection: TokioMutex::new(None),483 media_control: TokioMutex::new(None),484 session_control: TokioMutex::new(None),485 session_snapshot: Arc::new(TokioMutex::new(None)),486 media_feedback: TokioMutex::new(None),487 media_feedback_report_id: Arc::new(AtomicU64::new(0)),488 control_request_id: Arc::new(AtomicU64::new(0)),489 frame_delivery: TokioMutex::new(None),490 client_media_generation: Arc::new(AtomicU64::new(0)),491 audio_deliveries: Arc::new(StdMutex::new(AudioDeliveryState::default())),492 audio_connection: TokioMutex::new(None),493 audio_connection_generation: Arc::new(AtomicU64::new(0)),494 client_connection_serial: TokioMutex::new(()),495 client_connection_active: Arc::new(AtomicBool::new(false)),496 webcodecs: AtomicBool::new(false),497 dev_connect_node_id: options.dev_connect_node_id,498 }499 }500}501502// ─── Simple state Tauri commands ─────────────────────────────────────────────503504#[derive(Clone, Debug, Serialize)]505pub struct DevelopmentConnectionMode {506 pub enabled: bool,507 pub host_node_id: Option<String>,508 pub warning: &'static str,509}510511#[tauri::command]512pub fn development_connection_mode(state: tauri::State<'_, AppState>) -> DevelopmentConnectionMode {513 DevelopmentConnectionMode {514 enabled: state.dev_connect_node_id.is_some(),515 host_node_id: state.dev_connect_node_id.map(|node_id| node_id.to_string()),516 warning: "Development direct-node routing skips passkey identity lookup; it is not client authorization.",517 }518}519520#[tauri::command]521pub fn set_client_cursor_grab(window: tauri::WebviewWindow, grab: bool) -> Result<bool, String> {522 if grab {523 set_native_cursor_grab(&window, true)?;524 if let Err(error) = reapply_hidden_cursor_rect(|visible| {525 window526 .set_cursor_visible(visible)527 .map_err(|error| format!("Could not set native cursor visibility: {error}"))528 }) {529 let _ = set_native_cursor_grab(&window, false);530 return Err(error);531 }532 if let Err(error) = set_macos_cursor_hidden(true) {533 let _ = window.set_cursor_visible(true);534 let _ = set_native_cursor_grab(&window, false);535 return Err(error);536 }537 // CoreGraphics disassociation already supplies relative deltas on538 // macOS. WebKit Pointer Lock would be a second asynchronous cursor539 // owner and can reveal the native pointer after this command returns.540 return Ok(!cfg!(target_os = "macos"));541 }542543 let global_visibility_result = set_macos_cursor_hidden(false);544 let visibility_result = window545 .set_cursor_visible(true)546 .map_err(|error| format!("Could not restore native cursor visibility: {error}"));547 let release_result = set_native_cursor_grab(&window, false);548 global_visibility_result549 .and(release_result)550 .and(visibility_result)551 .map(|()| !cfg!(target_os = "macos"))552}553554#[tauri::command]555pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) -> bool {556 state.webcodecs.store(available, Ordering::SeqCst);557 available558}559560#[tauri::command]561pub fn is_webcodecs_available(state: tauri::State<'_, AppState>) -> bool {562 state.webcodecs.load(Ordering::SeqCst)563}564565#[cfg(test)]566mod tests {567 use super::*;568569 fn test_node_id() -> String {570 iroh::SecretKey::generate().public().to_string()571 }572573 #[test]574 fn active_native_cursor_grab_is_reapplied_after_focus_changes() {575 assert!(should_apply_native_cursor_association(true, true));576 assert!(should_apply_native_cursor_association(false, true));577 assert!(should_apply_native_cursor_association(true, false));578 assert!(!should_apply_native_cursor_association(false, false));579 }580581 #[test]582 fn cursor_focus_reassertion_forces_tao_visibility_transition() {583 let mut visibility = Vec::new();584585 reapply_hidden_cursor_rect(|visible| {586 visibility.push(visible);587 Ok(())588 })589 .unwrap();590591 assert_eq!(visibility, [true, false]);592 }593594 #[test]595 fn cursor_focus_reassertion_owns_each_core_graphics_hide() {596 use std::cell::Cell;597598 let mut depth = 0;599 let hides = Cell::new(0);600 let shows = Cell::new(0);601 for force in [false, false, true] {602 update_macos_cursor_hide_depth(603 &mut depth,604 true,605 force,606 || {607 hides.set(hides.get() + 1);608 Ok(())609 },610 || {611 shows.set(shows.get() + 1);612 Ok(())613 },614 )615 .unwrap();616 }617 assert_eq!((depth, hides.get(), shows.get()), (2, 2, 0));618619 update_macos_cursor_hide_depth(620 &mut depth,621 false,622 false,623 || {624 hides.set(hides.get() + 1);625 Ok(())626 },627 || {628 shows.set(shows.get() + 1);629 Ok(())630 },631 )632 .unwrap();633 assert_eq!((depth, hides.get(), shows.get()), (0, 2, 2));634 }635636 #[test]637 fn cursor_hide_depth_keeps_failed_operations_recoverable() {638 let mut depth = 0;639 let error = update_macos_cursor_hide_depth(640 &mut depth,641 true,642 false,643 || Err("hide failed".to_string()),644 || Ok(()),645 )646 .unwrap_err();647 assert_eq!(error, "hide failed");648 assert_eq!(depth, 0);649650 depth = 2;651 let error = update_macos_cursor_hide_depth(652 &mut depth,653 false,654 false,655 || Ok(()),656 || Err("show failed".to_string()),657 )658 .unwrap_err();659 assert_eq!(error, "show failed");660 assert_eq!(depth, 2);661 }662663 #[test]664 fn parses_debug_direct_node_flag() {665 let node_id = test_node_id();666 let options = parse_launch_options(["portal", "--dev-connect", &node_id], true).unwrap();667668 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);669 }670671 #[test]672 fn rejects_legacy_daemon_mode() {673 let error = parse_launch_options(["portal", "--daemon"], true).unwrap_err();674 assert!(error.contains("separate sigil executable"));675 }676677 #[test]678 fn parses_equals_form() {679 let node_id = test_node_id();680 let flag = format!("--dev-connect={node_id}");681 let options = parse_launch_options(["portal", &flag], true).unwrap();682683 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);684 }685686 #[test]687 fn rejects_direct_node_when_debug_mode_is_disabled() {688 let node_id = test_node_id();689 let error = parse_launch_options(["portal", "--dev-connect", &node_id], false).unwrap_err();690691 assert!(error.contains("debug build or the explicit demo-direct-node feature"));692 }693694 #[test]695 fn ordinary_release_excludes_direct_node_bypass() {696 if cfg!(not(debug_assertions)) && cfg!(not(feature = "demo-direct-node")) {697 assert!(!development_direct_node_available());698 }699 }700701 #[test]702 fn app_state_accepts_direct_node_only_in_debug_builds() {703 let node_id = test_node_id();704 let result = AppState::from_args(["portal", "--dev-connect", &node_id]);705706 assert_eq!(result.is_ok(), development_direct_node_available());707 }708709 #[test]710 fn app_state_starts_without_owned_transport_connections() {711 let state = AppState::default();712713 assert!(state.client_endpoint.try_lock().unwrap().is_none());714 assert!(state.input_connection.try_lock().unwrap().is_none());715 assert!(state.v2_input_context.try_lock().unwrap().is_none());716 assert!(state.media_connection.try_lock().unwrap().is_none());717 assert!(state.media_control.try_lock().unwrap().is_none());718 assert!(state.session_control.try_lock().unwrap().is_none());719 assert!(state.session_snapshot.try_lock().unwrap().is_none());720 assert!(state.media_feedback.try_lock().unwrap().is_none());721 assert!(state.audio_connection.try_lock().unwrap().is_none());722 }723724 #[test]725 fn rejects_missing_invalid_and_duplicate_node_ids() {726 assert!(727 parse_launch_options(["portal", "--dev-connect"], true)728 .unwrap_err()729 .contains("requires an iroh node ID")730 );731 assert!(732 parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true)733 .unwrap_err()734 .contains("Invalid iroh node ID")735 );736737 let node_id = test_node_id();738 assert!(739 parse_launch_options(740 [741 "portal",742 "--dev-connect",743 &node_id,744 "--dev-connect",745 &node_id,746 ],747 true,748 )749 .unwrap_err()750 .contains("only once")751 );752 }753}