#![forbid(unsafe_code)] use std::time::Duration; use crate::handshake::PeerCaps; use crate::input::camera::{CameraCodec, CameraConfig}; /// Resolve the server address the client should dial first. /// /// Inputs: process arguments after the executable name plus the optional /// `LESAVKA_SERVER_ADDR` override from the environment. /// Outputs: the address that should be used for both the handshake and the /// long-lived RPC channels. /// Why: keeping precedence rules pure makes startup behavior testable without /// having to mutate the real process environment in every caller. #[must_use] pub fn resolve_server_addr(args: &[String], env_addr: Option<&str>) -> String { args.first() .cloned() .or_else(|| env_addr.map(ToOwned::to_owned)) .unwrap_or_else(|| "http://127.0.0.1:50051".to_string()) } /// Convert handshake metadata into a local camera capture configuration. /// /// Inputs: the negotiated peer capabilities reported by the server. /// Outputs: `Some(CameraConfig)` only when the server advertised a complete /// camera profile that the client can honor locally. /// Why: camera startup should fail closed when the negotiated profile is /// incomplete, rather than guessing a codec or frame size on the client. #[must_use] pub fn camera_config_from_caps(caps: &PeerCaps) -> Option { let codec = parse_camera_codec(caps.camera_codec.as_deref()?)?; Some(CameraConfig { codec, width: caps.camera_width?, height: caps.camera_height?, fps: caps.camera_fps?, }) } /// Clamp the video queue size to a sensible minimum. /// /// Inputs: the operator-provided queue depth, if any. /// Outputs: a queue depth that is always large enough to absorb short render /// stalls without turning the GUI thread into a drop storm. /// Why: the render loop is bursty under GTK/winit, so tiny queues create /// needless packet churn and noisy logs. #[must_use] pub fn sanitize_video_queue(queue: Option) -> usize { queue.unwrap_or(256).max(16) } /// Pick the next reconnect delay for camera and microphone streams. /// /// Inputs: the current retry delay. /// Outputs: an exponential backoff capped at 30 seconds. /// Why: repeated reconnect failures should back off quickly without stalling /// recovery for minutes after a transient outage clears. #[must_use] pub fn next_delay(current: Duration) -> Duration { match current.as_secs() { 1..=15 => current * 2, _ => Duration::from_secs(30), } } fn parse_camera_codec(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "mjpeg" | "mjpg" | "jpeg" => Some(CameraCodec::Mjpeg), "h264" => Some(CameraCodec::H264), _ => None, } } #[cfg(test)] mod tests { use super::{camera_config_from_caps, next_delay, resolve_server_addr, sanitize_video_queue}; use crate::handshake::PeerCaps; use crate::input::camera::CameraCodec; use std::time::Duration; #[test] fn resolve_server_addr_prefers_cli_then_env_then_default() { assert_eq!( resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")), "http://cli:1" ); assert_eq!( resolve_server_addr(&[], Some("http://env:2")), "http://env:2" ); assert_eq!(resolve_server_addr(&[], None), "http://127.0.0.1:50051"); } #[test] fn camera_config_from_caps_requires_complete_profile() { let mut caps = PeerCaps { camera: true, microphone: false, camera_output: Some(String::from("uvc")), camera_codec: Some(String::from("mjpeg")), camera_width: Some(1280), camera_height: Some(720), camera_fps: Some(25), }; let config = camera_config_from_caps(&caps).expect("complete caps should map"); assert!(matches!(config.codec, CameraCodec::Mjpeg)); assert_eq!(config.width, 1280); caps.camera_codec = Some(String::from("vp9")); assert!(camera_config_from_caps(&caps).is_none()); } #[test] fn sanitize_video_queue_enforces_floor() { assert_eq!(sanitize_video_queue(None), 256); assert_eq!(sanitize_video_queue(Some(8)), 16); assert_eq!(sanitize_video_queue(Some(512)), 512); } #[test] fn next_delay_doubles_until_capped() { assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2)); assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30)); assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30)); } }