From dfdb93008e099a16852d55ef0ef677d916dd9f66 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 23 Apr 2026 07:00:06 -0300 Subject: [PATCH] release(lesavka): harden v0.12.3 launcher and gates --- client/src/app.rs | 779 +---- client/src/app/audio_recovery_config.rs | 82 + client/src/app/downlink_media.rs | 193 ++ client/src/app/input_streams.rs | 102 + client/src/app/session_lifecycle.rs | 304 ++ client/src/app/uplink_media.rs | 99 + client/src/bin/lesavka-relayctl.rs | 1 + client/src/input/camera.rs | 668 +---- client/src/input/camera/bus_and_encoder.rs | 69 + client/src/input/camera/capture_pipeline.rs | 254 ++ client/src/input/camera/device_selection.rs | 100 + client/src/input/camera/encoder_selection.rs | 85 + client/src/input/camera/preview_tap.rs | 100 + client/src/input/camera/source_description.rs | 76 + client/src/input/inputs.rs | 1097 +------ .../src/input/inputs/construction_and_scan.rs | 275 ++ .../src/input/inputs/device_classification.rs | 100 + client/src/input/inputs/routing_state.rs | 291 ++ client/src/input/inputs/run_loop.rs | 143 + client/src/input/inputs/runtime_controls.rs | 127 + client/src/input/inputs/toggle_keys.rs | 118 + client/src/input/keyboard.rs | 708 +---- client/src/input/keyboard/aggregator.rs | 433 +++ client/src/input/keyboard/reporting.rs | 217 ++ client/src/input/microphone.rs | 2 +- client/src/input/mouse.rs | 2 +- client/src/input/tests/inputs.rs | 30 + client/src/input/tests/keyboard.rs | 49 + client/src/launcher/clipboard.rs | 4 +- client/src/launcher/device_test.rs | 1274 +------- client/src/launcher/device_test/controller.rs | 398 +++ .../src/launcher/device_test/local_preview.rs | 320 ++ .../launcher/device_test/pipeline_helpers.rs | 425 +++ client/src/launcher/devices.rs | 183 +- client/src/launcher/diagnostics.rs | 1217 +------- .../diagnostics/diagnostics_models.rs | 164 ++ .../launcher/diagnostics/recommendations.rs | 230 ++ .../launcher/diagnostics/snapshot_report.rs | 410 +++ client/src/launcher/mod.rs | 397 +-- client/src/launcher/preview.rs | 2248 +------------- client/src/launcher/preview/feed_runtime.rs | 492 ++++ client/src/launcher/preview/feed_state.rs | 303 ++ .../src/launcher/preview/frame_telemetry.rs | 175 ++ client/src/launcher/preview/preview_core.rs | 498 ++++ .../src/launcher/preview/status_pipeline.rs | 284 ++ client/src/launcher/state.rs | 1688 +---------- .../src/launcher/state/launcher_state_impl.rs | 465 +++ client/src/launcher/state/profile_helpers.rs | 244 ++ client/src/launcher/state/selection_models.rs | 380 +++ client/src/launcher/tests/device_test.rs | 116 + client/src/launcher/tests/devices.rs | 214 ++ client/src/launcher/tests/diagnostics.rs | 401 +++ client/src/launcher/tests/mod.rs | 393 +++ client/src/launcher/tests/preview.rs | 480 +++ client/src/launcher/tests/state.rs | 613 ++++ .../src/launcher/tests/ui_components_scale.rs | 9 + client/src/launcher/tests/ui_coverage.rs | 24 + .../src/launcher/tests/ui_preview_profiles.rs | 117 + client/src/launcher/tests/ui_runtime.rs | 329 +++ client/src/launcher/ui.rs | 2590 +---------------- client/src/launcher/ui/activation_context.rs | 36 + client/src/launcher/ui/activation_setup.rs | 168 ++ client/src/launcher/ui/control_requests.rs | 165 ++ .../src/launcher/ui/device_refresh_binding.rs | 122 + client/src/launcher/ui/diagnostic_sampling.rs | 156 + .../src/launcher/ui/eye_display_bindings.rs | 126 + client/src/launcher/ui/local_test_bindings.rs | 90 + .../src/launcher/ui/media_device_bindings.rs | 139 + .../launcher/ui/message_and_network_state.rs | 130 + .../launcher/ui/power_display_key_bindings.rs | 181 ++ client/src/launcher/ui/preview_profiles.rs | 221 ++ .../src/launcher/ui/relay_input_bindings.rs | 190 ++ client/src/launcher/ui/runtime_poll.rs | 371 +++ .../src/launcher/ui/stage_device_bindings.rs | 174 ++ .../launcher/ui/utility_button_bindings.rs | 197 ++ client/src/launcher/ui_components.rs | 1657 +---------- .../launcher/ui_components/assemble_view.rs | 180 ++ .../launcher/ui_components/build_contexts.rs | 68 + .../ui_components/build_device_controls.rs | 290 ++ .../ui_components/build_operations_rail.rs | 235 ++ .../src/launcher/ui_components/build_shell.rs | 110 + .../launcher/ui_components/combo_helpers.rs | 269 ++ .../launcher/ui_components/display_pane.rs | 131 + .../src/launcher/ui_components/panel_chips.rs | 74 + .../src/launcher/ui_components/scale_reset.rs | 21 + client/src/launcher/ui_components/style.rs | 152 + client/src/launcher/ui_components/types.rs | 191 ++ client/src/launcher/ui_runtime.rs | 1965 +------------ .../src/launcher/ui_runtime/control_paths.rs | 238 ++ .../launcher/ui_runtime/display_popouts.rs | 262 ++ .../src/launcher/ui_runtime/log_filtering.rs | 139 + .../src/launcher/ui_runtime/process_logs.rs | 213 ++ .../src/launcher/ui_runtime/report_popouts.rs | 254 ++ .../src/launcher/ui_runtime/status_details.rs | 253 ++ .../src/launcher/ui_runtime/status_refresh.rs | 261 ++ client/src/main.rs | 1 + client/src/output/video.rs | 588 +--- client/src/output/video/monitor_window.rs | 378 +++ client/src/output/video/unified_monitor.rs | 222 ++ common/src/paste.rs | 2 +- docs/operational-env.md | 4 + scripts/ci/hygiene_gate.sh | 88 +- scripts/ci/hygiene_gate_baseline.json | 686 ++++- scripts/ci/quality_gate.sh | 115 +- scripts/ci/quality_gate_baseline.json | 296 +- server/src/audio.rs | 722 +---- server/src/audio/ear_capture.rs | 456 +++ server/src/audio/voice_input.rs | 204 ++ server/src/bin/lesavka-uvc.real.inc | 13 +- server/src/bin/lesavka-uvc.rs | 709 +---- .../src/bin/lesavka_uvc/control_payloads.rs | 140 + .../src/bin/lesavka_uvc/control_requests.rs | 162 ++ server/src/bin/lesavka_uvc/coverage_model.rs | 130 + .../src/bin/lesavka_uvc/coverage_startup.rs | 110 + server/src/bin/lesavka_uvc/payload_limits.rs | 74 + server/src/bin/tests/lesavka_uvc.rs | 81 + server/src/camera.rs | 156 +- server/src/camera_runtime.rs | 7 + server/src/capture_power.rs | 494 +--- server/src/capture_power/lease_manager.rs | 317 ++ server/src/capture_power/systemd_units.rs | 181 ++ server/src/gadget.rs | 499 +--- server/src/gadget/cycle_control.rs | 168 ++ server/src/gadget/driver_rebind.rs | 64 + server/src/gadget/enumeration_recovery.rs | 137 + server/src/gadget/sysfs_state.rs | 127 + server/src/main.rs | 949 +----- server/src/main/entrypoint.rs | 45 + server/src/main/eye_hub.rs | 76 + server/src/main/eye_video.rs | 152 + server/src/main/handler_startup.rs | 130 + server/src/main/relay_service.rs | 242 ++ server/src/main/relay_service_coverage.rs | 138 + server/src/main/rpc_helpers.rs | 105 + server/src/main/usb_recovery_helpers.rs | 66 + server/src/runtime_support.rs | 830 +----- server/src/runtime_support/audio_discovery.rs | 279 ++ server/src/runtime_support/hid_recovery.rs | 242 ++ server/src/runtime_support/hid_write.rs | 90 + server/src/tests/audio_1.rs | 7 + server/src/tests/audio_2.rs | 63 + server/src/tests/camera.rs | 193 ++ server/src/tests/runtime_support.rs | 275 ++ server/src/tests/video.rs | 174 ++ server/src/uvc_control/model.rs | 54 +- server/src/uvc_control/tests/model.rs | 50 + server/src/video.rs | 847 +----- server/src/video/eye_capture.rs | 415 +++ server/src/video/stream_core.rs | 248 ++ server/src/video_sinks.rs | 683 +---- server/src/video_sinks/camera_relay.rs | 127 + server/src/video_sinks/hdmi_sink.rs | 354 +++ server/src/video_sinks/webcam_sink.rs | 199 ++ testing/tests/client_app_include_contract.rs | 16 +- .../tests/client_camera_include_contract.rs | 8 + testing/tests/client_inputs_extra_contract.rs | 125 + .../tests/client_inputs_routing_contract.rs | 122 +- .../tests/client_inputs_toggle_contract.rs | 81 + .../tests/client_keyboard_process_contract.rs | 136 + .../tests/client_launcher_layout_contract.rs | 169 +- .../tests/client_launcher_runtime_contract.rs | 48 +- testing/tests/client_mouse_uinput_contract.rs | 10 +- .../client_output_display_include_contract.rs | 1 + .../client_output_video_include_contract.rs | 6 + .../tests/server_audio_include_contract.rs | 29 +- testing/tests/server_main_eye_hub_contract.rs | 102 + .../tests/server_uvc_binary_extra_contract.rs | 136 + .../server_video_sinks_include_contract.rs | 21 + 168 files changed, 25261 insertions(+), 23131 deletions(-) create mode 100644 client/src/app/audio_recovery_config.rs create mode 100644 client/src/app/downlink_media.rs create mode 100644 client/src/app/input_streams.rs create mode 100644 client/src/app/session_lifecycle.rs create mode 100644 client/src/app/uplink_media.rs create mode 100644 client/src/input/camera/bus_and_encoder.rs create mode 100644 client/src/input/camera/capture_pipeline.rs create mode 100644 client/src/input/camera/device_selection.rs create mode 100644 client/src/input/camera/encoder_selection.rs create mode 100644 client/src/input/camera/preview_tap.rs create mode 100644 client/src/input/camera/source_description.rs create mode 100644 client/src/input/inputs/construction_and_scan.rs create mode 100644 client/src/input/inputs/device_classification.rs create mode 100644 client/src/input/inputs/routing_state.rs create mode 100644 client/src/input/inputs/run_loop.rs create mode 100644 client/src/input/inputs/runtime_controls.rs create mode 100644 client/src/input/inputs/toggle_keys.rs create mode 100644 client/src/input/keyboard/aggregator.rs create mode 100644 client/src/input/keyboard/reporting.rs create mode 100644 client/src/input/tests/inputs.rs create mode 100644 client/src/input/tests/keyboard.rs create mode 100644 client/src/launcher/device_test/controller.rs create mode 100644 client/src/launcher/device_test/local_preview.rs create mode 100644 client/src/launcher/device_test/pipeline_helpers.rs create mode 100644 client/src/launcher/diagnostics/diagnostics_models.rs create mode 100644 client/src/launcher/diagnostics/recommendations.rs create mode 100644 client/src/launcher/diagnostics/snapshot_report.rs create mode 100644 client/src/launcher/preview/feed_runtime.rs create mode 100644 client/src/launcher/preview/feed_state.rs create mode 100644 client/src/launcher/preview/frame_telemetry.rs create mode 100644 client/src/launcher/preview/preview_core.rs create mode 100644 client/src/launcher/preview/status_pipeline.rs create mode 100644 client/src/launcher/state/launcher_state_impl.rs create mode 100644 client/src/launcher/state/profile_helpers.rs create mode 100644 client/src/launcher/state/selection_models.rs create mode 100644 client/src/launcher/tests/device_test.rs create mode 100644 client/src/launcher/tests/devices.rs create mode 100644 client/src/launcher/tests/diagnostics.rs create mode 100644 client/src/launcher/tests/mod.rs create mode 100644 client/src/launcher/tests/preview.rs create mode 100644 client/src/launcher/tests/state.rs create mode 100644 client/src/launcher/tests/ui_components_scale.rs create mode 100644 client/src/launcher/tests/ui_coverage.rs create mode 100644 client/src/launcher/tests/ui_preview_profiles.rs create mode 100644 client/src/launcher/tests/ui_runtime.rs create mode 100644 client/src/launcher/ui/activation_context.rs create mode 100644 client/src/launcher/ui/activation_setup.rs create mode 100644 client/src/launcher/ui/control_requests.rs create mode 100644 client/src/launcher/ui/device_refresh_binding.rs create mode 100644 client/src/launcher/ui/diagnostic_sampling.rs create mode 100644 client/src/launcher/ui/eye_display_bindings.rs create mode 100644 client/src/launcher/ui/local_test_bindings.rs create mode 100644 client/src/launcher/ui/media_device_bindings.rs create mode 100644 client/src/launcher/ui/message_and_network_state.rs create mode 100644 client/src/launcher/ui/power_display_key_bindings.rs create mode 100644 client/src/launcher/ui/preview_profiles.rs create mode 100644 client/src/launcher/ui/relay_input_bindings.rs create mode 100644 client/src/launcher/ui/runtime_poll.rs create mode 100644 client/src/launcher/ui/stage_device_bindings.rs create mode 100644 client/src/launcher/ui/utility_button_bindings.rs create mode 100644 client/src/launcher/ui_components/assemble_view.rs create mode 100644 client/src/launcher/ui_components/build_contexts.rs create mode 100644 client/src/launcher/ui_components/build_device_controls.rs create mode 100644 client/src/launcher/ui_components/build_operations_rail.rs create mode 100644 client/src/launcher/ui_components/build_shell.rs create mode 100644 client/src/launcher/ui_components/combo_helpers.rs create mode 100644 client/src/launcher/ui_components/display_pane.rs create mode 100644 client/src/launcher/ui_components/panel_chips.rs create mode 100644 client/src/launcher/ui_components/scale_reset.rs create mode 100644 client/src/launcher/ui_components/style.rs create mode 100644 client/src/launcher/ui_components/types.rs create mode 100644 client/src/launcher/ui_runtime/control_paths.rs create mode 100644 client/src/launcher/ui_runtime/display_popouts.rs create mode 100644 client/src/launcher/ui_runtime/log_filtering.rs create mode 100644 client/src/launcher/ui_runtime/process_logs.rs create mode 100644 client/src/launcher/ui_runtime/report_popouts.rs create mode 100644 client/src/launcher/ui_runtime/status_details.rs create mode 100644 client/src/launcher/ui_runtime/status_refresh.rs create mode 100644 client/src/output/video/monitor_window.rs create mode 100644 client/src/output/video/unified_monitor.rs create mode 100644 server/src/audio/ear_capture.rs create mode 100644 server/src/audio/voice_input.rs create mode 100644 server/src/bin/lesavka_uvc/control_payloads.rs create mode 100644 server/src/bin/lesavka_uvc/control_requests.rs create mode 100644 server/src/bin/lesavka_uvc/coverage_model.rs create mode 100644 server/src/bin/lesavka_uvc/coverage_startup.rs create mode 100644 server/src/bin/lesavka_uvc/payload_limits.rs create mode 100644 server/src/bin/tests/lesavka_uvc.rs create mode 100644 server/src/capture_power/lease_manager.rs create mode 100644 server/src/capture_power/systemd_units.rs create mode 100644 server/src/gadget/cycle_control.rs create mode 100644 server/src/gadget/driver_rebind.rs create mode 100644 server/src/gadget/enumeration_recovery.rs create mode 100644 server/src/gadget/sysfs_state.rs create mode 100644 server/src/main/entrypoint.rs create mode 100644 server/src/main/eye_hub.rs create mode 100644 server/src/main/eye_video.rs create mode 100644 server/src/main/handler_startup.rs create mode 100644 server/src/main/relay_service.rs create mode 100644 server/src/main/relay_service_coverage.rs create mode 100644 server/src/main/rpc_helpers.rs create mode 100644 server/src/main/usb_recovery_helpers.rs create mode 100644 server/src/runtime_support/audio_discovery.rs create mode 100644 server/src/runtime_support/hid_recovery.rs create mode 100644 server/src/runtime_support/hid_write.rs create mode 100644 server/src/tests/audio_1.rs create mode 100644 server/src/tests/audio_2.rs create mode 100644 server/src/tests/camera.rs create mode 100644 server/src/tests/runtime_support.rs create mode 100644 server/src/tests/video.rs create mode 100644 server/src/uvc_control/tests/model.rs create mode 100644 server/src/video/eye_capture.rs create mode 100644 server/src/video/stream_core.rs create mode 100644 server/src/video_sinks/camera_relay.rs create mode 100644 server/src/video_sinks/hdmi_sink.rs create mode 100644 server/src/video_sinks/webcam_sink.rs create mode 100644 testing/tests/client_inputs_toggle_contract.rs create mode 100644 testing/tests/client_keyboard_process_contract.rs diff --git a/client/src/app.rs b/client/src/app.rs index c4f3653..1dc4398 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,4 +1,5 @@ #![cfg_attr(coverage, allow(unused_imports))] +//! Client relay orchestration for HID input, media downlinks, and webcam/mic uplinks. use anyhow::Result; use std::sync::Arc; @@ -40,777 +41,9 @@ pub struct LesavkaClientApp { remote_capture_enabled: Arc, } -impl LesavkaClientApp { - pub fn new() -> Result { - let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok(); - let headless = std::env::var("LESAVKA_HEADLESS").is_ok(); - let capture_remote_boot = std::env::var("LESAVKA_CAPTURE_REMOTE") - .map(|value| value != "0") - .unwrap_or(true); - let args = std::env::args().skip(1).collect::>(); - let env_addr = std::env::var("LESAVKA_SERVER_ADDR").ok(); - let server_addr = app_support::resolve_server_addr(&args, env_addr.as_deref()); +include!("app/session_lifecycle.rs"); +include!("app/input_streams.rs"); +include!("app/downlink_media.rs"); +include!("app/uplink_media.rs"); - let (kbd_tx, _) = broadcast::channel(1024); - let (mou_tx, _) = broadcast::channel(4096); - let (paste_tx, paste_rx) = mpsc::unbounded_channel(); - - let (agg, remote_capture_enabled) = if headless { - (None, Arc::new(AtomicBool::new(false))) - } else { - let aggregator = InputAggregator::new_with_capture_mode( - dev_mode, - kbd_tx.clone(), - mou_tx.clone(), - Some(paste_tx), - capture_remote_boot, - ); - let remote_capture_enabled = aggregator.remote_capture_enabled_handle(); - (Some(aggregator), remote_capture_enabled) - }; - - Ok(Self { - aggregator: agg, - server_addr, - dev_mode, - headless, - kbd_tx, - mou_tx, - paste_rx: Some(paste_rx), - remote_capture_enabled, - }) - } - - #[cfg(coverage)] - pub async fn run(&mut self) -> Result<()> { - info!(server = %self.server_addr, "🚦 starting handshake"); - let _caps = handshake::negotiate(&self.server_addr).await; - if self.headless { - info!("🧪 headless mode: skipping HID input capture"); - } else { - info!("🧪 coverage mode: skipping runtime stream wiring"); - } - std::future::pending::>().await - } - - #[cfg(not(coverage))] - pub async fn run(&mut self) -> Result<()> { - /*────────── handshake / feature-negotiation ───────────────*/ - info!(server = %self.server_addr, "🚦 starting handshake"); - let caps = handshake::negotiate(&self.server_addr).await; - tracing::info!("🤝 server capabilities = {:?}", caps); - let camera_cfg = app_support::camera_config_from_caps(&caps); - - /*────────── persistent gRPC channels ──────────*/ - let hid_ep = Channel::from_shared(self.server_addr.clone())? - .tcp_nodelay(true) - .concurrency_limit(4) - .http2_keep_alive_interval(Duration::from_secs(15)) - .connect_lazy(); - - let vid_ep = Channel::from_shared(self.server_addr.clone())? - .initial_connection_window_size(4 << 20) - .initial_stream_window_size(4 << 20) - .tcp_nodelay(true) - .connect_lazy(); - - let mut agg_task = None; - let mut kbd_loop = None; - let mut mou_loop = None; - let mut paste_task = None; - let paste_rx = self.paste_rx.take(); - if !self.headless { - /*────────── input aggregator task (grab after handshake) ─────────────*/ - let mut aggregator = self.aggregator.take().expect("InputAggregator present"); - info!("⌛ grabbing input devices…"); - aggregator.init()?; // grab devices now that handshake succeeded - agg_task = Some(tokio::spawn(async move { - let mut a = aggregator; - a.run().await - })); - - /*────────── HID streams (never return) ────────*/ - kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone())); - mou_loop = Some(self.stream_loop_mouse(hid_ep.clone())); - if let Some(rx) = paste_rx { - paste_task = Some(Self::paste_loop(hid_ep.clone(), rx)); - } - } else { - info!("🧪 headless mode: skipping HID input capture"); - } - - /*───────── optional 300 s auto-exit in dev mode */ - let suicide = async { - if self.dev_mode { - tokio::time::sleep(Duration::from_secs(300)).await; - warn!("💀 dev-mode timeout"); - std::process::exit(0); - } else { - std::future::pending::<()>().await - } - }; - - if !self.headless { - let view_mode = std::env::var("LESAVKA_VIEW_MODE") - .unwrap_or_else(|_| "breakout".to_string()) - .to_ascii_lowercase(); - let unified_view = view_mode == "unified"; - let disable_video_render = std::env::var("LESAVKA_DISABLE_VIDEO_RENDER") - .map(|value| value.trim() != "0") - .unwrap_or(false); - info!( - "🪟 video layout selected: {}", - if unified_view { "unified" } else { "breakout" } - ); - - if disable_video_render { - info!("🪟 launcher preview active; skipping standalone client video windows"); - } else { - /*────────── video rendering thread (winit) ────*/ - let video_queue = app_support::sanitize_video_queue( - std::env::var("LESAVKA_VIDEO_QUEUE") - .ok() - .and_then(|v| v.parse::().ok()), - ); - let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok(); - let (video_tx, mut video_rx) = - tokio::sync::mpsc::channel::(video_queue); - - std::thread::spawn(move || { - gtk::init().expect("GTK initialisation failed"); - #[allow(deprecated)] - { - let el = EventLoopBuilder::<()>::new() - .with_any_thread(true) - .build() - .unwrap(); - enum Renderer { - Unified(UnifiedMonitorWindow), - Breakout { - left: MonitorWindow, - right: MonitorWindow, - }, - } - let renderer = if unified_view { - Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window")) - } else { - Renderer::Breakout { - left: MonitorWindow::new(0).expect("win0"), - right: MonitorWindow::new(1).expect("win1"), - } - }; - - let _ = el.run(move |_: Event<()>, elwt| { - elwt.set_control_flow(ControlFlow::WaitUntil( - std::time::Instant::now() + std::time::Duration::from_millis(16), - )); - static CNT: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - while let Ok(pkt) = video_rx.try_recv() { - CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 { - debug!( - "🎥 received {} video packets", - CNT.load(std::sync::atomic::Ordering::Relaxed) - ); - } - if dump_video { - static DUMP_CNT: std::sync::atomic::AtomicU32 = - std::sync::atomic::AtomicU32::new(0); - let n = - DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let eye = if pkt.id == 0 { "l" } else { "r" }; - let path = format!("/tmp/eye{eye}-cli-{n:05}.h264"); - std::fs::write(&path, &pkt.data).ok(); - } - match &renderer { - Renderer::Unified(window) => window.push_packet(pkt), - Renderer::Breakout { left, right } => match pkt.id { - 0 => left.push_packet(pkt), - 1 => right.push_packet(pkt), - _ => {} - }, - } - } - }); - } - }); - - /*────────── start video gRPC pullers ──────────*/ - let ep_video = vid_ep.clone(); - tokio::spawn(Self::video_loop(ep_video, video_tx)); - } - - /*────────── audio renderer & puller ───────────*/ - if std::env::var("LESAVKA_AUDIO_DISABLE").is_err() { - let audio_out = AudioOut::new()?; - let ep_audio = vid_ep.clone(); - tokio::spawn(Self::audio_loop(ep_audio, audio_out)); - } else { - info!("🔇 remote audio disabled for this relay session"); - } - } else { - info!("🧪 headless mode: skipping video/audio renderers"); - } - /*────────── camera & mic tasks (gated by caps) ───────────*/ - if caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err() { - if let Some(cfg) = camera_cfg { - info!( - codec = ?cfg.codec, - width = cfg.width, - height = cfg.height, - fps = cfg.fps, - "📸 using camera settings from server" - ); - } - let ep = vid_ep.clone(); - let cam_source = std::env::var("LESAVKA_CAM_SOURCE").ok(); - tokio::spawn(async move { - let result = tokio::task::spawn_blocking(move || { - CameraCapture::new(cam_source.as_deref(), camera_cfg) - }) - .await; - match result { - Ok(Ok(cam)) => { - let cam = Arc::new(cam); - tokio::spawn(Self::cam_loop(ep, cam)); - } - Ok(Err(err)) => { - warn!( - "📸 webcam uplink is unavailable for this relay session; continuing without StreamCamera: {err:#}" - ); - } - Err(err) => { - warn!( - "📸 webcam uplink setup task failed before StreamCamera could start: {err}" - ); - } - } - }); - } - if caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err() { - let ep = vid_ep.clone(); - tokio::spawn(async move { - let result = tokio::task::spawn_blocking(MicrophoneCapture::new).await; - match result { - Ok(Ok(mic)) => { - let mic = Arc::new(mic); - tokio::spawn(Self::voice_loop(ep, mic)); - } - Ok(Err(err)) => { - warn!( - "🎤 microphone uplink is unavailable for this relay session; continuing without StreamMicrophone: {err:#}" - ); - } - Err(err) => { - warn!( - "🎤 microphone uplink setup task failed before StreamMicrophone could start: {err}" - ); - } - } - }); - } - - /*────────── central reactor ───────────────────*/ - if self.headless { - tokio::select! { - _ = suicide => { /* handled above */ }, - } - } else { - let kbd_loop = kbd_loop.expect("kbd_loop"); - let mou_loop = mou_loop.expect("mou_loop"); - let agg_task = agg_task.expect("agg_task"); - let paste_task = paste_task.expect("paste_task"); - tokio::select! { - _ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, - _ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); }, - _ = paste_task => { warn!("⚠️📋 paste loop finished"); }, - _ = suicide => { /* handled above */ }, - r = agg_task => { - match r { - Ok(Ok(())) => warn!("input aggregator terminated cleanly"), - Ok(Err(e)) => error!("input aggregator error: {e:?}"), - Err(join_err) => error!("aggregator task panicked: {join_err:?}"), - } - return Ok(()); - } - } - } - - // The branches above either loop forever or exit the process; this - // point is unreachable but satisfies the type checker. - #[allow(unreachable_code)] - Ok(()) - } - - /*──────────────── paste loop ───────────────*/ - #[cfg(not(coverage))] - fn paste_loop( - ep: Channel, - mut rx: mpsc::UnboundedReceiver, - ) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut cli = RelayClient::new(ep.clone()); - while let Some(text) = rx.recv().await { - match paste::build_paste_request(&text) { - Ok(req) => match cli.paste_text(Request::new(req)).await { - Ok(resp) => { - let reply = resp.get_ref(); - if !reply.ok { - warn!("📋 paste rejected: {}", reply.error); - } else { - debug!("📋 paste delivered"); - } - } - Err(e) => { - warn!("📋 paste failed: {e}"); - cli = RelayClient::new(ep.clone()); - } - }, - Err(e) => { - warn!("📋 paste build failed: {e}"); - } - } - } - }) - } - - /*──────────────── keyboard stream ───────────────*/ - #[cfg(not(coverage))] - async fn stream_loop_keyboard(&self, ep: Channel) { - loop { - info!("⌨️🤙 Keyboard dial {}", self.server_addr); - let mut cli = RelayClient::new(ep.clone()); - let capture_enabled = Arc::clone(&self.remote_capture_enabled); - let mut remote_capture_was_enabled = capture_enabled.load(Ordering::Relaxed); - - let outbound = - BroadcastStream::new(self.kbd_tx.subscribe()).filter_map(move |report| { - let remote_capture_enabled = capture_enabled.load(Ordering::Relaxed); - keyboard_stream_report( - report, - remote_capture_enabled, - &mut remote_capture_was_enabled, - ) - }); - - match cli.stream_keyboard(Request::new(outbound)).await { - Ok(mut resp) => { - while let Some(msg) = resp.get_mut().message().await.transpose() { - if let Err(e) = msg { - warn!("⌨️ server err: {e}"); - break; - } - } - } - Err(e) => warn!("❌⌨️ connect failed: {e}"), - } - tokio::time::sleep(Duration::from_secs(1)).await; // retry - } - } - - /*──────────────── mouse stream ──────────────────*/ - #[cfg(not(coverage))] - async fn stream_loop_mouse(&self, ep: Channel) { - loop { - info!("🖱️🤙 Mouse dial {}", self.server_addr); - let mut cli = RelayClient::new(ep.clone()); - let capture_enabled = Arc::clone(&self.remote_capture_enabled); - let mut remote_capture_was_enabled = capture_enabled.load(Ordering::Relaxed); - - let outbound = - BroadcastStream::new(self.mou_tx.subscribe()).filter_map(move |report| { - let remote_capture_enabled = capture_enabled.load(Ordering::Relaxed); - mouse_stream_report( - report, - remote_capture_enabled, - &mut remote_capture_was_enabled, - ) - }); - - match cli.stream_mouse(Request::new(outbound)).await { - Ok(mut resp) => { - while let Some(msg) = resp.get_mut().message().await.transpose() { - if let Err(e) = msg { - warn!("🖱️ server err: {e}"); - break; - } - } - } - Err(e) => warn!("❌🖱️ connect failed: {e}"), - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - - /*──────────────── monitor stream ────────────────*/ - #[cfg(not(coverage))] - async fn video_loop(ep: Channel, tx: tokio::sync::mpsc::Sender) { - let max_bitrate = std::env::var("LESAVKA_VIDEO_MAX_KBIT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(6_000); - for monitor_id in 0..=1 { - let ep = ep.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - loop { - let mut cli = RelayClient::new(ep.clone()); - let req = MonitorRequest { - id: monitor_id, - max_bitrate, - requested_width: 0, - requested_height: 0, - requested_fps: 0, - source_id: None, - }; - match cli.capture_video(Request::new(req)).await { - Ok(mut stream) => { - debug!("🎥🏁 cli video{monitor_id}: stream opened"); - while let Some(res) = stream.get_mut().message().await.transpose() { - match res { - Ok(pkt) => { - trace!( - "🎥📥 cli video{monitor_id}: got {} bytes", - pkt.data.len() - ); - if tx.send(pkt).await.is_err() { - warn!("⚠️🎥 cli video{monitor_id}: GUI thread gone"); - break; - } - } - Err(e) => { - error!("❌🎥 cli video{monitor_id}: gRPC error: {e}"); - break; - } - } - } - warn!("⚠️🎥 cli video{monitor_id}: stream ended"); - } - Err(e) => error!("❌🎥 video {monitor_id}: {e}"), - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - }); - } - } - - /*──────────────── audio stream ───────────────*/ - #[cfg(not(coverage))] - async fn audio_loop(ep: Channel, out: AudioOut) { - let mut consecutive_source_failures = 0_u32; - let mut last_usb_recovery_at: Option = None; - let mut delay = Duration::from_secs(1); - loop { - let mut cli = RelayClient::new(ep.clone()); - let req = MonitorRequest { - id: 0, - max_bitrate: 0, - requested_width: 0, - requested_height: 0, - requested_fps: 0, - source_id: None, - }; - match cli.capture_audio(Request::new(req)).await { - Ok(mut stream) => { - tracing::info!("🔊 audio stream opened"); - let mut packet_count: u64 = 0; - let mut warned_no_packets = false; - delay = Duration::from_secs(1); - loop { - match tokio::time::timeout( - Duration::from_secs(1), - stream.get_mut().message(), - ) - .await - { - Ok(Ok(Some(pkt))) => { - packet_count = packet_count.saturating_add(1); - if packet_count <= 8 || packet_count % 600 == 0 { - tracing::info!( - packet = packet_count, - bytes = pkt.data.len(), - remote_pts_us = pkt.pts, - "🔊 audio packet received" - ); - } - out.push(pkt); - consecutive_source_failures = 0; - } - Ok(Ok(None)) => { - tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended"); - if packet_count == 0 { - delay = app_support::next_delay(delay); - } - break; - } - Ok(Err(err)) => { - let message = err.to_string(); - tracing::warn!("❌🔊 audio stream recv error: {message}"); - Self::maybe_recover_audio_usb( - &ep, - &mut consecutive_source_failures, - &mut last_usb_recovery_at, - &message, - ) - .await; - delay = app_support::next_delay(delay); - break; - } - Err(_) => { - if packet_count == 0 && !warned_no_packets { - warned_no_packets = true; - tracing::warn!( - "⚠️🔊 audio stream connected but no packets received yet; source may be idle or unavailable" - ); - } - } - } - } - } - Err(e) => { - let message = e.to_string(); - tracing::warn!("❌🔊 audio stream err: {message}"); - Self::maybe_recover_audio_usb( - &ep, - &mut consecutive_source_failures, - &mut last_usb_recovery_at, - &message, - ) - .await; - delay = app_support::next_delay(delay); - } - } - tokio::time::sleep(delay).await; - } - } - - #[cfg(not(coverage))] - async fn maybe_recover_audio_usb( - ep: &Channel, - consecutive_source_failures: &mut u32, - last_usb_recovery_at: &mut Option, - message: &str, - ) { - if !audio_usb_auto_recover_enabled() || !is_recoverable_remote_audio_error(message) { - return; - } - - *consecutive_source_failures = consecutive_source_failures.saturating_add(1); - let threshold = audio_usb_recover_after(); - if *consecutive_source_failures < threshold { - tracing::warn!( - failures = *consecutive_source_failures, - threshold, - "🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery" - ); - return; - } - - let cooldown = audio_usb_recover_cooldown(); - if last_usb_recovery_at.is_some_and(|last| last.elapsed() < cooldown) { - tracing::warn!( - cooldown_ms = cooldown.as_millis(), - "🔊🛟 remote speaker capture is still unhealthy, but USB recovery is cooling down" - ); - return; - } - - *consecutive_source_failures = 0; - *last_usb_recovery_at = Some(Instant::now()); - tracing::warn!("🔊🛟 requesting USB gadget recovery for stalled remote speaker capture"); - let mut cli = RelayClient::new(ep.clone()); - match cli.reset_usb(Request::new(Empty {})).await { - Ok(reply) => { - if reply.into_inner().ok { - tracing::warn!("🔊🛟 USB gadget recovery completed; audio will reconnect"); - } else { - tracing::warn!("🔊🛟 USB gadget recovery returned ok=false"); - } - } - Err(err) => { - tracing::warn!("🔊🛟 USB gadget recovery failed: {err:#}"); - } - } - } - - /*──────────────── mic stream ─────────────────*/ - #[cfg(not(coverage))] - async fn voice_loop(ep: Channel, mic: Arc) { - let mut delay = Duration::from_secs(1); - static FAIL_CNT: AtomicUsize = AtomicUsize::new(0); - loop { - let mut cli = RelayClient::new(ep.clone()); - - // 1. create a Tokio MPSC channel - let (tx, rx) = tokio::sync::mpsc::channel::(256); - let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>(); - - // 2. spawn a real thread that does the blocking `pull()` - let mic_clone = mic.clone(); - std::thread::spawn(move || { - while stop_rx.try_recv().is_err() { - if let Some(pkt) = mic_clone.pull() { - trace!("🎤📤 cli {} bytes → gRPC", pkt.data.len()); - let _ = tx.blocking_send(pkt); - } - } - }); - - // 3. turn `rx` into an async stream for gRPC - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - match cli.stream_microphone(Request::new(outbound)).await { - Ok(mut resp) => while resp.get_mut().message().await.transpose().is_some() {}, - Err(e) => { - // first failure → warn, subsequent ones → debug - if FAIL_CNT.fetch_add(1, Ordering::Relaxed) == 0 { - warn!("❌🎤 connect failed: {e}"); - warn!("⚠️🎤 further microphone‑stream failures will be logged at DEBUG"); - } else { - debug!("❌🎤 reconnect failed: {e}"); - } - delay = app_support::next_delay(delay); - } - } - let _ = stop_tx.send(()); - tokio::time::sleep(delay).await; - } - } - - /*──────────────── cam stream ───────────────────*/ - #[cfg(not(coverage))] - async fn cam_loop(ep: Channel, cam: Arc) { - let mut delay = Duration::from_secs(1); - loop { - let mut cli = RelayClient::new(ep.clone()); - let (tx, rx) = tokio::sync::mpsc::channel::(256); - let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>(); - let cam_worker = std::thread::spawn({ - let cam = cam.clone(); - move || loop { - if stop_rx.try_recv().is_ok() { - break; - } - let Some(pkt) = cam.pull() else { - std::thread::sleep(Duration::from_millis(5)); - continue; - }; - // TRACE every 120 frames only - static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n < 10 || n % 120 == 0 { - tracing::trace!("📸 cli frame#{n} {} B", pkt.data.len()); - } - tracing::trace!("📸⬆️ sent webcam AU pts={} {} B", pkt.pts, pkt.data.len()); - if tx.blocking_send(pkt).is_err() { - break; - } - } - }); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - match cli.stream_camera(Request::new(outbound)).await { - Ok(mut resp) => { - delay = Duration::from_secs(1); // got a stream → reset - while resp.get_mut().message().await.transpose().is_some() {} - } - Err(e) if e.code() == tonic::Code::Unimplemented => { - tracing::warn!("📸 server does not support StreamCamera – giving up"); - let _ = stop_tx.send(()); - let _ = cam_worker.join(); - return; // stop the task completely (#3) - } - Err(e) => { - tracing::warn!("❌📸 connect failed: {e:?}"); - delay = app_support::next_delay(delay); // back-off (#2) - } - } - let _ = stop_tx.send(()); - let _ = cam_worker.join(); - tokio::time::sleep(delay).await; - } - } -} - -#[cfg(not(coverage))] -fn audio_usb_auto_recover_enabled() -> bool { - std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_USB") - .map(|raw| { - !matches!( - raw.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "no" | "off" - ) - }) - .unwrap_or(false) -} - -#[cfg(not(coverage))] -fn audio_usb_recover_after() -> u32 { - std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_AFTER") - .ok() - .and_then(|raw| raw.parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(3) -} - -#[cfg(not(coverage))] -fn audio_usb_recover_cooldown() -> Duration { - let millis = std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(60_000); - Duration::from_millis(millis) -} - -#[cfg(not(coverage))] -fn is_recoverable_remote_audio_error(message: &str) -> bool { - message.contains("remote speaker capture produced no audio samples") - || message.contains("remote speaker capture stalled") - || message.contains("remote speaker capture cadence is too low") -} - -pub(crate) fn keyboard_stream_report( - report: Result, - remote_capture_enabled: bool, - remote_capture_was_enabled: &mut bool, -) -> Option { - if !remote_capture_enabled { - let emit_reset = *remote_capture_was_enabled; - *remote_capture_was_enabled = false; - return emit_reset.then_some(KeyboardReport { data: vec![0; 8] }); - } - *remote_capture_was_enabled = true; - match report { - Ok(report) => Some(report), - Err(BroadcastStreamRecvError::Lagged(skipped)) => { - warn!( - skipped, - "⌨️ live keyboard stream lagged; sending a clean reset report before continuing" - ); - Some(KeyboardReport { data: vec![0; 8] }) - } - } -} - -pub(crate) fn mouse_stream_report( - report: Result, - remote_capture_enabled: bool, - remote_capture_was_enabled: &mut bool, -) -> Option { - if !remote_capture_enabled { - let emit_reset = *remote_capture_was_enabled; - *remote_capture_was_enabled = false; - return emit_reset.then_some(MouseReport { data: vec![0; 4] }); - } - *remote_capture_was_enabled = true; - match report { - Ok(report) => Some(report), - Err(BroadcastStreamRecvError::Lagged(skipped)) => { - warn!( - skipped, - "🖱️ live mouse stream lagged; sending a neutral report before continuing" - ); - Some(MouseReport { data: vec![0; 4] }) - } - } -} +include!("app/audio_recovery_config.rs"); diff --git a/client/src/app/audio_recovery_config.rs b/client/src/app/audio_recovery_config.rs new file mode 100644 index 0000000..387b55b --- /dev/null +++ b/client/src/app/audio_recovery_config.rs @@ -0,0 +1,82 @@ +#[cfg(not(coverage))] +fn audio_usb_auto_recover_enabled() -> bool { + std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_USB") + .map(|raw| { + !matches!( + raw.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) + }) + .unwrap_or(false) +} + +#[cfg(not(coverage))] +fn audio_usb_recover_after() -> u32 { + std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_AFTER") + .ok() + .and_then(|raw| raw.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(3) +} + +#[cfg(not(coverage))] +fn audio_usb_recover_cooldown() -> Duration { + let millis = std::env::var("LESAVKA_AUDIO_AUTO_RECOVER_COOLDOWN_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(60_000); + Duration::from_millis(millis) +} + +#[cfg(not(coverage))] +fn is_recoverable_remote_audio_error(message: &str) -> bool { + message.contains("remote speaker capture produced no audio samples") + || message.contains("remote speaker capture stalled") + || message.contains("remote speaker capture cadence is too low") +} + +pub(crate) fn keyboard_stream_report( + report: Result, + remote_capture_enabled: bool, + remote_capture_was_enabled: &mut bool, +) -> Option { + if !remote_capture_enabled { + let emit_reset = *remote_capture_was_enabled; + *remote_capture_was_enabled = false; + return emit_reset.then_some(KeyboardReport { data: vec![0; 8] }); + } + *remote_capture_was_enabled = true; + match report { + Ok(report) => Some(report), + Err(BroadcastStreamRecvError::Lagged(skipped)) => { + warn!( + skipped, + "⌨️ live keyboard stream lagged; sending a clean reset report before continuing" + ); + Some(KeyboardReport { data: vec![0; 8] }) + } + } +} + +pub(crate) fn mouse_stream_report( + report: Result, + remote_capture_enabled: bool, + remote_capture_was_enabled: &mut bool, +) -> Option { + if !remote_capture_enabled { + let emit_reset = *remote_capture_was_enabled; + *remote_capture_was_enabled = false; + return emit_reset.then_some(MouseReport { data: vec![0; 4] }); + } + *remote_capture_was_enabled = true; + match report { + Ok(report) => Some(report), + Err(BroadcastStreamRecvError::Lagged(skipped)) => { + warn!( + skipped, + "🖱️ live mouse stream lagged; sending a neutral report before continuing" + ); + Some(MouseReport { data: vec![0; 4] }) + } + } +} diff --git a/client/src/app/downlink_media.rs b/client/src/app/downlink_media.rs new file mode 100644 index 0000000..f889525 --- /dev/null +++ b/client/src/app/downlink_media.rs @@ -0,0 +1,193 @@ +impl LesavkaClientApp { + /*──────────────── monitor stream ────────────────*/ + #[cfg(not(coverage))] + async fn video_loop(ep: Channel, tx: tokio::sync::mpsc::Sender) { + let max_bitrate = std::env::var("LESAVKA_VIDEO_MAX_KBIT") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(6_000); + for monitor_id in 0..=1 { + let ep = ep.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + loop { + let mut cli = RelayClient::new(ep.clone()); + let req = MonitorRequest { + id: monitor_id, + max_bitrate, + requested_width: 0, + requested_height: 0, + requested_fps: 0, + source_id: None, + }; + match cli.capture_video(Request::new(req)).await { + Ok(mut stream) => { + debug!("🎥🏁 cli video{monitor_id}: stream opened"); + while let Some(res) = stream.get_mut().message().await.transpose() { + match res { + Ok(pkt) => { + trace!( + "🎥📥 cli video{monitor_id}: got {} bytes", + pkt.data.len() + ); + if tx.send(pkt).await.is_err() { + warn!("⚠️🎥 cli video{monitor_id}: GUI thread gone"); + break; + } + } + Err(e) => { + error!("❌🎥 cli video{monitor_id}: gRPC error: {e}"); + break; + } + } + } + warn!("⚠️🎥 cli video{monitor_id}: stream ended"); + } + Err(e) => error!("❌🎥 video {monitor_id}: {e}"), + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + } + } + + /*──────────────── audio stream ───────────────*/ + #[cfg(not(coverage))] + async fn audio_loop(ep: Channel, out: AudioOut) { + let mut consecutive_source_failures = 0_u32; + let mut last_usb_recovery_at: Option = None; + let mut delay = Duration::from_secs(1); + loop { + let mut cli = RelayClient::new(ep.clone()); + let req = MonitorRequest { + id: 0, + max_bitrate: 0, + requested_width: 0, + requested_height: 0, + requested_fps: 0, + source_id: None, + }; + match cli.capture_audio(Request::new(req)).await { + Ok(mut stream) => { + tracing::info!("🔊 audio stream opened"); + let mut packet_count: u64 = 0; + let mut warned_no_packets = false; + delay = Duration::from_secs(1); + loop { + match tokio::time::timeout( + Duration::from_secs(1), + stream.get_mut().message(), + ) + .await + { + Ok(Ok(Some(pkt))) => { + packet_count = packet_count.saturating_add(1); + if packet_count <= 8 || packet_count.is_multiple_of(600) { + tracing::info!( + packet = packet_count, + bytes = pkt.data.len(), + remote_pts_us = pkt.pts, + "🔊 audio packet received" + ); + } + out.push(pkt); + consecutive_source_failures = 0; + } + Ok(Ok(None)) => { + tracing::warn!(packets = packet_count, "⚠️🔊 audio stream ended"); + if packet_count == 0 { + delay = app_support::next_delay(delay); + } + break; + } + Ok(Err(err)) => { + let message = err.to_string(); + tracing::warn!("❌🔊 audio stream recv error: {message}"); + Self::maybe_recover_audio_usb( + &ep, + &mut consecutive_source_failures, + &mut last_usb_recovery_at, + &message, + ) + .await; + delay = app_support::next_delay(delay); + break; + } + Err(_) => { + if packet_count == 0 && !warned_no_packets { + warned_no_packets = true; + tracing::warn!( + "⚠️🔊 audio stream connected but no packets received yet; source may be idle or unavailable" + ); + } + } + } + } + } + Err(e) => { + let message = e.to_string(); + tracing::warn!("❌🔊 audio stream err: {message}"); + Self::maybe_recover_audio_usb( + &ep, + &mut consecutive_source_failures, + &mut last_usb_recovery_at, + &message, + ) + .await; + delay = app_support::next_delay(delay); + } + } + tokio::time::sleep(delay).await; + } + } + + #[cfg(not(coverage))] + async fn maybe_recover_audio_usb( + ep: &Channel, + consecutive_source_failures: &mut u32, + last_usb_recovery_at: &mut Option, + message: &str, + ) { + if !audio_usb_auto_recover_enabled() || !is_recoverable_remote_audio_error(message) { + return; + } + + *consecutive_source_failures = consecutive_source_failures.saturating_add(1); + let threshold = audio_usb_recover_after(); + if *consecutive_source_failures < threshold { + tracing::warn!( + failures = *consecutive_source_failures, + threshold, + "🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery" + ); + return; + } + + let cooldown = audio_usb_recover_cooldown(); + if last_usb_recovery_at.is_some_and(|last| last.elapsed() < cooldown) { + tracing::warn!( + cooldown_ms = cooldown.as_millis(), + "🔊🛟 remote speaker capture is still unhealthy, but USB recovery is cooling down" + ); + return; + } + + *consecutive_source_failures = 0; + *last_usb_recovery_at = Some(Instant::now()); + tracing::warn!("🔊🛟 requesting USB gadget recovery for stalled remote speaker capture"); + let mut cli = RelayClient::new(ep.clone()); + match cli.reset_usb(Request::new(Empty {})).await { + Ok(reply) => { + if reply.into_inner().ok { + tracing::warn!("🔊🛟 USB gadget recovery completed; audio will reconnect"); + } else { + tracing::warn!("🔊🛟 USB gadget recovery returned ok=false"); + } + } + Err(err) => { + tracing::warn!("🔊🛟 USB gadget recovery failed: {err:#}"); + } + } + } + +} diff --git a/client/src/app/input_streams.rs b/client/src/app/input_streams.rs new file mode 100644 index 0000000..2f6afa4 --- /dev/null +++ b/client/src/app/input_streams.rs @@ -0,0 +1,102 @@ +impl LesavkaClientApp { + /*──────────────── paste loop ───────────────*/ + #[cfg(not(coverage))] + fn paste_loop( + ep: Channel, + mut rx: mpsc::UnboundedReceiver, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut cli = RelayClient::new(ep.clone()); + while let Some(text) = rx.recv().await { + match paste::build_paste_request(&text) { + Ok(req) => match cli.paste_text(Request::new(req)).await { + Ok(resp) => { + let reply = resp.get_ref(); + if !reply.ok { + warn!("📋 paste rejected: {}", reply.error); + } else { + debug!("📋 paste delivered"); + } + } + Err(e) => { + warn!("📋 paste failed: {e}"); + cli = RelayClient::new(ep.clone()); + } + }, + Err(e) => { + warn!("📋 paste build failed: {e}"); + } + } + } + }) + } + + /*──────────────── keyboard stream ───────────────*/ + #[cfg(not(coverage))] + async fn stream_loop_keyboard(&self, ep: Channel) { + loop { + info!("⌨️🤙 Keyboard dial {}", self.server_addr); + let mut cli = RelayClient::new(ep.clone()); + let capture_enabled = Arc::clone(&self.remote_capture_enabled); + let mut remote_capture_was_enabled = capture_enabled.load(Ordering::Relaxed); + + let outbound = + BroadcastStream::new(self.kbd_tx.subscribe()).filter_map(move |report| { + let remote_capture_enabled = capture_enabled.load(Ordering::Relaxed); + keyboard_stream_report( + report, + remote_capture_enabled, + &mut remote_capture_was_enabled, + ) + }); + + match cli.stream_keyboard(Request::new(outbound)).await { + Ok(mut resp) => { + while let Some(msg) = resp.get_mut().message().await.transpose() { + if let Err(e) = msg { + warn!("⌨️ server err: {e}"); + break; + } + } + } + Err(e) => warn!("❌⌨️ connect failed: {e}"), + } + tokio::time::sleep(Duration::from_secs(1)).await; // retry + } + } + + /*──────────────── mouse stream ──────────────────*/ + #[cfg(not(coverage))] + async fn stream_loop_mouse(&self, ep: Channel) { + loop { + info!("🖱️🤙 Mouse dial {}", self.server_addr); + let mut cli = RelayClient::new(ep.clone()); + let capture_enabled = Arc::clone(&self.remote_capture_enabled); + let mut remote_capture_was_enabled = capture_enabled.load(Ordering::Relaxed); + + let outbound = + BroadcastStream::new(self.mou_tx.subscribe()).filter_map(move |report| { + let remote_capture_enabled = capture_enabled.load(Ordering::Relaxed); + mouse_stream_report( + report, + remote_capture_enabled, + &mut remote_capture_was_enabled, + ) + }); + + match cli.stream_mouse(Request::new(outbound)).await { + Ok(mut resp) => { + while let Some(msg) = resp.get_mut().message().await.transpose() { + if let Err(e) = msg { + warn!("🖱️ server err: {e}"); + break; + } + } + } + Err(e) => warn!("❌🖱️ connect failed: {e}"), + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + +} diff --git a/client/src/app/session_lifecycle.rs b/client/src/app/session_lifecycle.rs new file mode 100644 index 0000000..f65b066 --- /dev/null +++ b/client/src/app/session_lifecycle.rs @@ -0,0 +1,304 @@ +impl LesavkaClientApp { + pub fn new() -> Result { + let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok(); + let headless = std::env::var("LESAVKA_HEADLESS").is_ok(); + let capture_remote_boot = std::env::var("LESAVKA_CAPTURE_REMOTE") + .map(|value| value != "0") + .unwrap_or(true); + let args = std::env::args().skip(1).collect::>(); + let env_addr = std::env::var("LESAVKA_SERVER_ADDR").ok(); + let server_addr = app_support::resolve_server_addr(&args, env_addr.as_deref()); + + let (kbd_tx, _) = broadcast::channel(1024); + let (mou_tx, _) = broadcast::channel(4096); + let (paste_tx, paste_rx) = mpsc::unbounded_channel(); + + let (agg, remote_capture_enabled) = if headless { + (None, Arc::new(AtomicBool::new(false))) + } else { + let aggregator = InputAggregator::new_with_capture_mode( + dev_mode, + kbd_tx.clone(), + mou_tx.clone(), + Some(paste_tx), + capture_remote_boot, + ); + let remote_capture_enabled = aggregator.remote_capture_enabled_handle(); + (Some(aggregator), remote_capture_enabled) + }; + + Ok(Self { + aggregator: agg, + server_addr, + dev_mode, + headless, + kbd_tx, + mou_tx, + paste_rx: Some(paste_rx), + remote_capture_enabled, + }) + } + + #[cfg(coverage)] + pub async fn run(&mut self) -> Result<()> { + info!(server = %self.server_addr, "🚦 starting handshake"); + let _caps = handshake::negotiate(&self.server_addr).await; + if self.headless { + info!("🧪 headless mode: skipping HID input capture"); + } else { + info!("🧪 coverage mode: skipping runtime stream wiring"); + } + std::future::pending::>().await + } + + #[cfg(not(coverage))] + pub async fn run(&mut self) -> Result<()> { + /*────────── handshake / feature-negotiation ───────────────*/ + info!(server = %self.server_addr, "🚦 starting handshake"); + let caps = handshake::negotiate(&self.server_addr).await; + tracing::info!("🤝 server capabilities = {:?}", caps); + let camera_cfg = app_support::camera_config_from_caps(&caps); + + /*────────── persistent gRPC channels ──────────*/ + let hid_ep = Channel::from_shared(self.server_addr.clone())? + .tcp_nodelay(true) + .concurrency_limit(4) + .http2_keep_alive_interval(Duration::from_secs(15)) + .connect_lazy(); + + let vid_ep = Channel::from_shared(self.server_addr.clone())? + .initial_connection_window_size(4 << 20) + .initial_stream_window_size(4 << 20) + .tcp_nodelay(true) + .connect_lazy(); + + let mut agg_task = None; + let mut kbd_loop = None; + let mut mou_loop = None; + let mut paste_task = None; + let paste_rx = self.paste_rx.take(); + if !self.headless { + /*────────── input aggregator task (grab after handshake) ─────────────*/ + let mut aggregator = self.aggregator.take().expect("InputAggregator present"); + info!("⌛ grabbing input devices…"); + aggregator.init()?; // grab devices now that handshake succeeded + agg_task = Some(tokio::spawn(async move { + let mut a = aggregator; + a.run().await + })); + + /*────────── HID streams (never return) ────────*/ + kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone())); + mou_loop = Some(self.stream_loop_mouse(hid_ep.clone())); + if let Some(rx) = paste_rx { + paste_task = Some(Self::paste_loop(hid_ep.clone(), rx)); + } + } else { + info!("🧪 headless mode: skipping HID input capture"); + } + + /*───────── optional 300 s auto-exit in dev mode */ + let suicide = async { + if self.dev_mode { + tokio::time::sleep(Duration::from_secs(300)).await; + warn!("💀 dev-mode timeout"); + std::process::exit(0); + } else { + std::future::pending::<()>().await + } + }; + + if !self.headless { + let view_mode = std::env::var("LESAVKA_VIEW_MODE") + .unwrap_or_else(|_| "breakout".to_string()) + .to_ascii_lowercase(); + let unified_view = view_mode == "unified"; + let disable_video_render = std::env::var("LESAVKA_DISABLE_VIDEO_RENDER") + .map(|value| value.trim() != "0") + .unwrap_or(false); + info!( + "🪟 video layout selected: {}", + if unified_view { "unified" } else { "breakout" } + ); + + if disable_video_render { + info!("🪟 launcher preview active; skipping standalone client video windows"); + } else { + /*────────── video rendering thread (winit) ────*/ + let video_queue = app_support::sanitize_video_queue( + std::env::var("LESAVKA_VIDEO_QUEUE") + .ok() + .and_then(|v| v.parse::().ok()), + ); + let dump_video = std::env::var("LESAVKA_DUMP_VIDEO").is_ok(); + let (video_tx, mut video_rx) = + tokio::sync::mpsc::channel::(video_queue); + + std::thread::spawn(move || { + gtk::init().expect("GTK initialisation failed"); + #[allow(deprecated)] + { + let el = EventLoopBuilder::<()>::new() + .with_any_thread(true) + .build() + .unwrap(); + enum Renderer { + Unified(UnifiedMonitorWindow), + Breakout { + left: MonitorWindow, + right: MonitorWindow, + }, + } + let renderer = if unified_view { + Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window")) + } else { + Renderer::Breakout { + left: MonitorWindow::new(0).expect("win0"), + right: MonitorWindow::new(1).expect("win1"), + } + }; + + let _ = el.run(move |_: Event<()>, elwt| { + elwt.set_control_flow(ControlFlow::WaitUntil( + std::time::Instant::now() + std::time::Duration::from_millis(16), + )); + static CNT: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + while let Ok(pkt) = video_rx.try_recv() { + CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if CNT.load(std::sync::atomic::Ordering::Relaxed).is_multiple_of(300) { + debug!( + "🎥 received {} video packets", + CNT.load(std::sync::atomic::Ordering::Relaxed) + ); + } + if dump_video { + static DUMP_CNT: std::sync::atomic::AtomicU32 = + std::sync::atomic::AtomicU32::new(0); + let n = + DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let eye = if pkt.id == 0 { "l" } else { "r" }; + let path = format!("/tmp/eye{eye}-cli-{n:05}.h264"); + std::fs::write(&path, &pkt.data).ok(); + } + match &renderer { + Renderer::Unified(window) => window.push_packet(pkt), + Renderer::Breakout { left, right } => match pkt.id { + 0 => left.push_packet(pkt), + 1 => right.push_packet(pkt), + _ => {} + }, + } + } + }); + } + }); + + /*────────── start video gRPC pullers ──────────*/ + let ep_video = vid_ep.clone(); + tokio::spawn(Self::video_loop(ep_video, video_tx)); + } + + /*────────── audio renderer & puller ───────────*/ + if std::env::var("LESAVKA_AUDIO_DISABLE").is_err() { + let audio_out = AudioOut::new()?; + let ep_audio = vid_ep.clone(); + tokio::spawn(Self::audio_loop(ep_audio, audio_out)); + } else { + info!("🔇 remote audio disabled for this relay session"); + } + } else { + info!("🧪 headless mode: skipping video/audio renderers"); + } + /*────────── camera & mic tasks (gated by caps) ───────────*/ + if caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err() { + if let Some(cfg) = camera_cfg { + info!( + codec = ?cfg.codec, + width = cfg.width, + height = cfg.height, + fps = cfg.fps, + "📸 using camera settings from server" + ); + } + let ep = vid_ep.clone(); + let cam_source = std::env::var("LESAVKA_CAM_SOURCE").ok(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + CameraCapture::new(cam_source.as_deref(), camera_cfg) + }) + .await; + match result { + Ok(Ok(cam)) => { + let cam = Arc::new(cam); + tokio::spawn(Self::cam_loop(ep, cam)); + } + Ok(Err(err)) => { + warn!( + "📸 webcam uplink is unavailable for this relay session; continuing without StreamCamera: {err:#}" + ); + } + Err(err) => { + warn!( + "📸 webcam uplink setup task failed before StreamCamera could start: {err}" + ); + } + } + }); + } + if caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err() { + let ep = vid_ep.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(MicrophoneCapture::new).await; + match result { + Ok(Ok(mic)) => { + let mic = Arc::new(mic); + tokio::spawn(Self::voice_loop(ep, mic)); + } + Ok(Err(err)) => { + warn!( + "🎤 microphone uplink is unavailable for this relay session; continuing without StreamMicrophone: {err:#}" + ); + } + Err(err) => { + warn!( + "🎤 microphone uplink setup task failed before StreamMicrophone could start: {err}" + ); + } + } + }); + } + + /*────────── central reactor ───────────────────*/ + if self.headless { + tokio::select! { + _ = suicide => { /* handled above */ }, + } + } else { + let kbd_loop = kbd_loop.expect("kbd_loop"); + let mou_loop = mou_loop.expect("mou_loop"); + let agg_task = agg_task.expect("agg_task"); + let paste_task = paste_task.expect("paste_task"); + tokio::select! { + _ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, + _ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); }, + _ = paste_task => { warn!("⚠️📋 paste loop finished"); }, + _ = suicide => { /* handled above */ }, + r = agg_task => { + match r { + Ok(Ok(())) => warn!("input aggregator terminated cleanly"), + Ok(Err(e)) => error!("input aggregator error: {e:?}"), + Err(join_err) => error!("aggregator task panicked: {join_err:?}"), + } + return Ok(()); + } + } + } + + // The branches above either loop forever or exit the process; this + // point is unreachable but satisfies the type checker. + #[allow(unreachable_code)] + Ok(()) + } + +} diff --git a/client/src/app/uplink_media.rs b/client/src/app/uplink_media.rs new file mode 100644 index 0000000..3b4b514 --- /dev/null +++ b/client/src/app/uplink_media.rs @@ -0,0 +1,99 @@ +impl LesavkaClientApp { + /*──────────────── mic stream ─────────────────*/ + #[cfg(not(coverage))] + async fn voice_loop(ep: Channel, mic: Arc) { + let mut delay = Duration::from_secs(1); + static FAIL_CNT: AtomicUsize = AtomicUsize::new(0); + loop { + let mut cli = RelayClient::new(ep.clone()); + + // 1. create a Tokio MPSC channel + let (tx, rx) = tokio::sync::mpsc::channel::(256); + let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>(); + + // 2. spawn a real thread that does the blocking `pull()` + let mic_clone = mic.clone(); + std::thread::spawn(move || { + while stop_rx.try_recv().is_err() { + if let Some(pkt) = mic_clone.pull() { + trace!("🎤📤 cli {} bytes → gRPC", pkt.data.len()); + let _ = tx.blocking_send(pkt); + } + } + }); + + // 3. turn `rx` into an async stream for gRPC + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + match cli.stream_microphone(Request::new(outbound)).await { + Ok(mut resp) => while resp.get_mut().message().await.transpose().is_some() {}, + Err(e) => { + // first failure → warn, subsequent ones → debug + if FAIL_CNT.fetch_add(1, Ordering::Relaxed) == 0 { + warn!("❌🎤 connect failed: {e}"); + warn!("⚠️🎤 further microphone‑stream failures will be logged at DEBUG"); + } else { + debug!("❌🎤 reconnect failed: {e}"); + } + delay = app_support::next_delay(delay); + } + } + let _ = stop_tx.send(()); + tokio::time::sleep(delay).await; + } + } + + /*──────────────── cam stream ───────────────────*/ + #[cfg(not(coverage))] + async fn cam_loop(ep: Channel, cam: Arc) { + let mut delay = Duration::from_secs(1); + loop { + let mut cli = RelayClient::new(ep.clone()); + let (tx, rx) = tokio::sync::mpsc::channel::(256); + let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>(); + let cam_worker = std::thread::spawn({ + let cam = cam.clone(); + move || loop { + if stop_rx.try_recv().is_ok() { + break; + } + let Some(pkt) = cam.pull() else { + std::thread::sleep(Duration::from_millis(5)); + continue; + }; + // TRACE every 120 frames only + static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n < 10 || n.is_multiple_of(120) { + tracing::trace!("📸 cli frame#{n} {} B", pkt.data.len()); + } + tracing::trace!("📸⬆️ sent webcam AU pts={} {} B", pkt.pts, pkt.data.len()); + if tx.blocking_send(pkt).is_err() { + break; + } + } + }); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + match cli.stream_camera(Request::new(outbound)).await { + Ok(mut resp) => { + delay = Duration::from_secs(1); // got a stream → reset + while resp.get_mut().message().await.transpose().is_some() {} + } + Err(e) if e.code() == tonic::Code::Unimplemented => { + tracing::warn!("📸 server does not support StreamCamera – giving up"); + let _ = stop_tx.send(()); + let _ = cam_worker.join(); + return; // stop the task completely (#3) + } + Err(e) => { + tracing::warn!("❌📸 connect failed: {e:?}"); + delay = app_support::next_delay(delay); // back-off (#2) + } + } + let _ = stop_tx.send(()); + let _ = cam_worker.join(); + tokio::time::sleep(delay).await; + } + } + +} diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index cd0ee2b..448c367 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -83,6 +83,7 @@ where })) } +#[cfg(test)] fn parse_args_from(args: I) -> Result where I: IntoIterator, diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 89e0397..2410329 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -1,4 +1,4 @@ -// client/src/input/camera.rs +// Webcam capture pipeline, quality selection, and launcher preview tap support. use anyhow::Context; use gst::prelude::*; use gstreamer as gst; @@ -52,664 +52,10 @@ pub struct CameraCapture { preview_tap_running: Option>, } -impl CameraCapture { - pub fn new(device_fragment: Option<&str>, cfg: Option) -> anyhow::Result { - gst::init().ok(); +include!("camera/capture_pipeline.rs"); +include!("camera/device_selection.rs"); +include!("camera/encoder_selection.rs"); - // Select source: V4L2 device or test pattern - let (src_desc, dev_label, allow_mjpg_source) = match device_fragment { - Some(fragment) - if fragment.eq_ignore_ascii_case("test") - || fragment.eq_ignore_ascii_case("videotestsrc") => - { - let pattern = - std::env::var("LESAVKA_CAM_TEST_PATTERN").unwrap_or_else(|_| "smpte".into()); - ( - format!("videotestsrc is-live=true pattern={pattern}"), - format!("videotestsrc:{pattern}"), - false, - ) - } - Some(path) if path.starts_with("/dev/") => ( - format!("v4l2src device={path} do-timestamp=true"), - path.to_string(), - true, - ), - Some(fragment) => { - let dev = Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()); - (format!("v4l2src device={dev} do-timestamp=true"), dev, true) - } - None => { - let dev = "/dev/video0".to_string(); - (format!("v4l2src device={dev} do-timestamp=true"), dev, true) - } - }; - - let output_mjpeg = cfg.map_or_else( - || { - std::env::var("LESAVKA_CAM_CODEC").ok().is_some_and(|v| { - matches!(v.to_ascii_lowercase().as_str(), "mjpeg" | "mjpg" | "jpeg") - }) - }, - |cfg| matches!(cfg.codec, CameraCodec::Mjpeg), - ); - let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); - let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)); - let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)); - let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1); - let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); - let source_profile = camera_source_profile(allow_mjpg_source); - let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; - let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg { - ("x264enc", Some("key-int-max")) - } else { - Self::choose_encoder() - }; - match source_profile { - CameraSourceProfile::Mjpeg if !output_mjpeg => { - tracing::info!("📸 using MJPG source with software encode"); - } - CameraSourceProfile::AutoDecode => { - tracing::info!("📸 using auto-decoded webcam source (raw/MJPEG accepted)"); - } - _ => {} - } - let enc_opts = Self::encoder_options(enc, kf_prop, keyframe_interval); - if output_mjpeg { - tracing::info!("📸 outputting MJPEG frames for UVC (quality={jpeg_quality})"); - } else { - tracing::info!("📸 using encoder element: {enc}"); - } - #[cfg(not(coverage))] - let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); - let (src_caps, preenc) = match enc { - // ─────────────────────────────────────────────────────────────────── - // Jetson (has nvvidconv) Desktop (falls back to videoconvert) - // ─────────────────────────────────────────────────────────────────── - #[cfg(not(coverage))] - "nvh264enc" if have_nvvidconv => - (format!( - "video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1" - ), "nvvidconv !"), - #[cfg(not(coverage))] - "nvh264enc" /* else */ => - (format!( - "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), - #[cfg(not(coverage))] - "vaapih264enc" => - (format!( - "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), - _ => - (format!( - "video/x-raw,width={width},height={height},framerate={fps}/1" - ), "videoconvert !"), - }; - - // let desc = format!( - // "v4l2src device={dev} do-timestamp=true ! {raw_caps},width=1280,height=720 ! \ - // videoconvert ! {enc} key-int-max=30 ! \ - // h264parse config-interval=-1 ! \ - // appsink name=asink emit-signals=true max-buffers=60 drop=true" - // ); - // tracing::debug!(%desc, "📸 pipeline-desc"); - // Build a pipeline that works for any of the three encoders. - // * nvh264enc needs NVMM memory caps; - // * vaapih264enc wants system-memory caps; - // * x264enc needs the usual raw caps. - let preview_tap_path = camera_preview_tap_path(); - let preview_tap_branch = camera_preview_tap_branch(width, height, fps); - let raw_source_chain = - camera_raw_source_chain(&src_desc, &src_caps, width, height, fps, source_profile); - let desc = if preview_tap_path.is_some() { - if output_mjpeg { - if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height},framerate={fps}/1 ! \ - tee name=t \ - t. ! queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true \ - t. ! queue max-size-buffers=2 leaky=downstream ! jpegdec ! \ - {preview_tap_branch}" - ) - } else { - format!( - "{raw_source_chain} ! \ - tee name=t \ - t. ! queue max-size-buffers=30 leaky=downstream ! \ - videoconvert ! jpegenc quality={jpeg_quality} ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true \ - t. ! queue max-size-buffers=2 leaky=downstream ! \ - {preview_tap_branch}" - ) - } - } else if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height} ! \ - jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ - tee name=t \ - t. ! queue max-size-buffers=30 leaky=downstream ! \ - videoconvert ! {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true \ - t. ! queue max-size-buffers=2 leaky=downstream ! \ - {preview_tap_branch}" - ) - } else { - format!( - "{raw_source_chain} ! \ - tee name=t \ - t. ! queue max-size-buffers=30 leaky=downstream ! \ - {preenc} {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true \ - t. ! queue max-size-buffers=2 leaky=downstream ! \ - {preview_tap_branch}" - ) - } - } else if output_mjpeg { - if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height},framerate={fps}/1 ! \ - queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true" - ) - } else { - format!( - "{raw_source_chain} ! \ - videoconvert ! jpegenc quality={jpeg_quality} ! \ - queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true" - ) - } - } else if use_mjpg_source { - format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height} ! \ - jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ - videoconvert ! {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true" - ) - } else { - format!( - "{raw_source_chain} ! \ - {preenc} {enc_opts} ! \ - h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ - queue max-size-buffers=30 leaky=downstream ! \ - appsink name=asink emit-signals=true max-buffers=60 drop=true" - ) - }; - tracing::info!(%enc, width, height, fps, ?desc, "📸 using encoder element"); - - let pipeline: gst::Pipeline = gst::parse::launch(&desc) - .context("gst parse_launch(cam)")? - .downcast::() - .expect("not a pipeline"); - - tracing::debug!("📸 pipeline built OK – setting PLAYING…"); - let sink: gst_app::AppSink = pipeline - .by_name("asink") - .expect("appsink element not found") - .downcast::() - .expect("appsink down‑cast"); - - spawn_camera_bus_logger(&pipeline, dev_label.clone()); - if let Err(err) = pipeline.set_state(gst::State::Playing) { - let _ = pipeline.set_state(gst::State::Null); - return Err(err.into()); - } - tracing::info!("📸 webcam pipeline ▶️ device={dev_label}"); - - let preview_tap_running = if let Some(path) = preview_tap_path { - let preview_sink = pipeline - .by_name("preview_sink") - .context("missing camera preview tap appsink")? - .downcast::() - .expect("camera preview tap appsink"); - Some(spawn_camera_preview_tap(preview_sink, path)) - } else { - None - }; - - Ok(Self { - pipeline, - sink, - preview_tap_running, - }) - } - - pub fn pull(&self) -> Option { - let sample = self.sink.pull_sample().ok()?; - let buf = sample.buffer()?; - let map = buf.map_readable().ok()?; - let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; - static FIRST_CAMERA_PACKET: AtomicBool = AtomicBool::new(false); - if !FIRST_CAMERA_PACKET.swap(true, Ordering::Relaxed) { - tracing::info!( - bytes = map.as_slice().len(), - pts_us = pts, - "📸 upstream webcam frames flowing" - ); - } - Some(VideoPacket { - id: 2, - pts, - data: map.as_slice().to_vec(), - ..Default::default() - }) - } - - /// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes - #[cfg(not(coverage))] - fn find_device(substr: &str) -> Option { - let wanted = substr.to_ascii_lowercase(); - let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") - .ok()? - .flatten() - .filter_map(|e| { - let p = e.path(); - let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); - if name.contains(&wanted) { - Some(p) - } else { - None - } - }) - .collect(); - - // deterministic order - matches.sort(); - - for p in matches { - if let Ok(target) = std::fs::read_link(&p) { - let dev = format!("/dev/{}", target.file_name()?.to_string_lossy()); - if Self::is_capture(&dev) { - return Some(dev); - } - } - } - None - } - - #[cfg(coverage)] - fn find_device(substr: &str) -> Option { - let wanted = substr.to_ascii_lowercase(); - let by_id_dir = - std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); - let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); - let mut matches: Vec<_> = std::fs::read_dir(by_id_dir) - .ok()? - .flatten() - .filter_map(|e| { - let p = e.path(); - let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); - if name.contains(&wanted) { - Some(p) - } else { - None - } - }) - .collect(); - matches.sort(); - for p in matches { - if let Ok(target) = std::fs::read_link(&p) { - let dev = format!("{}/{}", dev_root, target.file_name()?.to_string_lossy()); - if Self::is_capture(&dev) { - return Some(dev); - } - } - } - None - } - - #[cfg(not(coverage))] - fn is_capture(dev: &str) -> bool { - const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001; - const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 0x0000_1000; - - v4l::Device::with_path(dev) - .ok() - .and_then(|d| d.query_caps().ok()) - .map(|caps| { - let bits = caps.capabilities.bits(); - (bits & V4L2_CAP_VIDEO_CAPTURE != 0) || (bits & V4L2_CAP_VIDEO_CAPTURE_MPLANE != 0) - }) - .unwrap_or(false) - } - - #[cfg(coverage)] - fn is_capture(dev: &str) -> bool { - dev.starts_with("/dev/video") - } - - /// Cheap stub used when the web‑cam is disabled - pub fn new_stub() -> Self { - let pipeline = gst::Pipeline::new(); - let sink: gst_app::AppSink = gst::ElementFactory::make("appsink") - .build() - .expect("appsink") - .downcast::() - .unwrap(); - Self { - pipeline, - sink, - preview_tap_running: None, - } - } - - #[allow(dead_code)] // helper kept for future heuristics - #[cfg(not(coverage))] - fn pick_encoder() -> (&'static str, &'static str) { - let encoders = &[ - ("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"), - ("vaapih264enc", "video/x-raw,format=NV12"), - ("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc. - ("x264enc", "video/x-raw"), // software - ]; - for (name, caps) in encoders { - if gst::ElementFactory::find(name).is_some() { - return (name, caps); - } - } - // last resort – software - ("x264enc", "video/x-raw") - } - - #[cfg(coverage)] - fn pick_encoder() -> (&'static str, &'static str) { - ("x264enc", "video/x-raw") - } - - #[cfg(not(coverage))] - fn choose_encoder() -> (&'static str, Option<&'static str>) { - if buildable_encoder("nvh264enc") { - return ( - "nvh264enc", - supported_encoder_property( - "nvh264enc", - &["iframeinterval", "idrinterval", "gop-size"], - ), - ); - } - if buildable_encoder("vaapih264enc") { - return ( - "vaapih264enc", - supported_encoder_property("vaapih264enc", &["keyframe-period"]), - ); - } - if buildable_encoder("v4l2h264enc") { - return ( - "v4l2h264enc", - supported_encoder_property("v4l2h264enc", &["idrcount"]), - ); - } - ("x264enc", Some("key-int-max")) - } - - #[cfg(coverage)] - fn choose_encoder() -> (&'static str, Option<&'static str>) { - match std::env::var("LESAVKA_CAM_TEST_ENCODER") - .ok() - .as_deref() - .map(str::trim) - { - Some("nvh264enc") => ("nvh264enc", None), - Some("vaapih264enc") => ("vaapih264enc", Some("keyframe-period")), - Some("v4l2h264enc") => ("v4l2h264enc", Some("idrcount")), - _ => ("x264enc", Some("key-int-max")), - } - } - - fn encoder_options( - enc: &'static str, - kf_prop: Option<&'static str>, - keyframe_interval: u32, - ) -> String { - if enc == "x264enc" { - let bitrate_kbit = env_u32("LESAVKA_CAM_H264_KBIT", 4500); - let keyframe_opt = kf_prop - .map(|property| format!(" {property}={keyframe_interval}")) - .unwrap_or_default(); - format!( - "{enc} tune=zerolatency speed-preset=faster bitrate={bitrate_kbit}{keyframe_opt}" - ) - } else if let Some(property) = kf_prop { - format!("{enc} {property}={keyframe_interval}") - } else { - enc.to_string() - } - } -} - -/// Choose the pre-encoder webcam format path. -/// -/// V4L2 webcams often expose 720p/30 as MJPEG only, so the default accepts -/// either raw frames or MJPEG unless the operator explicitly pins a format. -fn camera_source_profile(allow_v4l2_auto_decode: bool) -> CameraSourceProfile { - if !allow_v4l2_auto_decode { - return CameraSourceProfile::Raw; - } - if std::env::var("LESAVKA_CAM_MJPG").is_ok() { - return CameraSourceProfile::Mjpeg; - } - match std::env::var("LESAVKA_CAM_FORMAT") - .ok() - .as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("mjpg" | "mjpeg" | "jpeg") => CameraSourceProfile::Mjpeg, - Some("raw" | "yuyv" | "yuy2") => CameraSourceProfile::Raw, - _ => CameraSourceProfile::AutoDecode, - } -} - -/// Build the source-to-raw-video chain consumed by the encoder and preview tap. -fn camera_raw_source_chain( - src_desc: &str, - src_caps: &str, - width: u32, - height: u32, - fps: u32, - profile: CameraSourceProfile, -) -> String { - match profile { - CameraSourceProfile::Raw => format!("{src_desc} ! {src_caps}"), - CameraSourceProfile::Mjpeg => format!( - "{src_desc} ! \ - image/jpeg,width={width},height={height},framerate={fps}/1 ! \ - jpegdec ! videoconvert ! videoscale ! videorate ! \ - video/x-raw,width={width},height={height},framerate={fps}/1" - ), - CameraSourceProfile::AutoDecode => format!( - "{src_desc} ! \ - capsfilter caps=\"{}\" ! \ - decodebin ! videoconvert ! videoscale ! videorate ! \ - video/x-raw,width={width},height={height},framerate={fps}/1,pixel-aspect-ratio=1/1", - camera_auto_decode_caps(width, height, fps) - ), - } -} - -/// Caps string that lets decodebin negotiate either raw webcam frames or MJPEG. -fn camera_auto_decode_caps(width: u32, height: u32, fps: u32) -> String { - format!( - "video/x-raw,width=(int){width},height=(int){height},framerate=(fraction){fps}/1;image/jpeg,width=(int){width},height=(int){height},framerate=(fraction){fps}/1" - ) -} - -fn camera_preview_tap_path() -> Option { - std::env::var(CAMERA_PREVIEW_TAP_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) -} - -fn camera_preview_tap_branch(width: u32, height: u32, fps: u32) -> String { - let preview_width = width.clamp(1, i32::MAX as u32); - let preview_height = height.clamp(1, i32::MAX as u32); - let preview_fps = fps.clamp(1, 60); - format!( - "videoconvert ! videoscale ! videorate ! \ - video/x-raw,format=RGBA,width={preview_width},height={preview_height},framerate={preview_fps}/1,pixel-aspect-ratio=1/1 ! \ - appsink name=preview_sink emit-signals=false sync=false max-buffers=1 drop=true" - ) -} - -/// Publish actual-size local preview frames so the launcher mirrors uplink quality. -fn spawn_camera_preview_tap(sink: gst_app::AppSink, path: PathBuf) -> Arc { - let running = Arc::new(AtomicBool::new(true)); - let thread_running = Arc::clone(&running); - thread::spawn(move || { - let mut wrote_first = false; - let mut empty_polls = 0_u64; - while thread_running.load(Ordering::Acquire) { - if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { - empty_polls = 0; - match write_camera_preview_tap(&path, &sample) { - Ok(info) => { - if !wrote_first { - wrote_first = true; - tracing::info!( - path = %path.display(), - width = info.width, - height = info.height, - stride = info.stride, - "📸 local uplink preview tap publishing frames" - ); - } - } - Err(err) => { - tracing::debug!("📸 local uplink preview tap write failed: {err:#}"); - thread::sleep(Duration::from_millis(100)); - } - } - } else if !wrote_first { - empty_polls += 1; - if empty_polls == 20 || empty_polls.is_multiple_of(120) { - tracing::warn!( - path = %path.display(), - "📸 local uplink preview tap is still waiting for webcam frames" - ); - } - } - } - }); - running -} - -struct CameraPreviewTapInfo { - width: i32, - height: i32, - stride: usize, -} - -/// Atomically write one RGBA preview sample for the launcher status pane. -fn write_camera_preview_tap( - path: &Path, - sample: &gst::Sample, -) -> anyhow::Result { - let caps = sample.caps().context("preview tap sample missing caps")?; - let structure = caps - .structure(0) - .context("preview tap caps missing structure")?; - let width = structure - .get::("width") - .context("preview tap caps missing width")?; - let height = structure - .get::("height") - .context("preview tap caps missing height")?; - let buffer = sample - .buffer() - .context("preview tap sample missing buffer")?; - let map = buffer - .map_readable() - .context("preview tap buffer unreadable")?; - let row_count = usize::try_from(height) - .ok() - .filter(|height| *height > 0) - .unwrap_or(1); - let stride = map.as_slice().len() / row_count; - let tmp_path = path.with_extension("tmp"); - let mut file = std::fs::File::create(&tmp_path) - .with_context(|| format!("creating {}", tmp_path.display()))?; - writeln!(file, "LESAVKA_RGBA {width} {height} {stride}")?; - file.write_all(map.as_slice())?; - file.sync_all().ok(); - std::fs::rename(&tmp_path, path).with_context(|| format!("publishing {}", path.display()))?; - Ok(CameraPreviewTapInfo { - width, - height, - stride, - }) -} - -/// Forward camera bus warnings/errors into the relay log with the device label. -fn spawn_camera_bus_logger(pipeline: &gst::Pipeline, device: String) { - let Some(bus) = pipeline.bus() else { - return; - }; - std::thread::spawn(move || { - use gst::MessageView::{Error, StateChanged, Warning}; - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - StateChanged(s) - if s.current() == gst::State::Playing - && msg.src().is_some_and(|source| { - source.type_() == gst::Pipeline::static_type() - }) => - { - tracing::info!(%device, "📸 camera pipeline ▶️"); - } - Error(e) => tracing::error!( - %device, - "📸💥 camera: {} ({})", - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => tracing::warn!( - %device, - "📸⚠️ camera: {} ({})", - w.error(), - w.debug().unwrap_or_default() - ), - _ => {} - } - } - }); -} - -#[cfg(not(coverage))] -fn buildable_encoder(encoder: &'static str) -> bool { - gst::ElementFactory::find(encoder).is_some() - && gst::ElementFactory::make(encoder).build().is_ok() -} - -#[cfg(not(coverage))] -fn supported_encoder_property( - encoder: &'static str, - properties: &[&'static str], -) -> Option<&'static str> { - let element = gst::ElementFactory::make(encoder).build().ok()?; - properties - .iter() - .copied() - .find(|property| element.find_property(property).is_some()) -} - -impl Drop for CameraCapture { - fn drop(&mut self) { - if let Some(running) = &self.preview_tap_running { - running.store(false, Ordering::Release); - } - let _ = self.pipeline.set_state(gst::State::Null); - } -} +include!("camera/source_description.rs"); +include!("camera/preview_tap.rs"); +include!("camera/bus_and_encoder.rs"); diff --git a/client/src/input/camera/bus_and_encoder.rs b/client/src/input/camera/bus_and_encoder.rs new file mode 100644 index 0000000..611ff79 --- /dev/null +++ b/client/src/input/camera/bus_and_encoder.rs @@ -0,0 +1,69 @@ +/// Forward camera bus warnings/errors into the relay log with the device label. +#[cfg(not(coverage))] +fn spawn_camera_bus_logger(pipeline: &gst::Pipeline, device: String) { + let Some(bus) = pipeline.bus() else { + return; + }; + std::thread::spawn(move || { + use gst::MessageView::{Error, StateChanged, Warning}; + for msg in bus.iter_timed(gst::ClockTime::NONE) { + match msg.view() { + StateChanged(s) + if s.current() == gst::State::Playing + && msg.src().is_some_and(|source| { + source.type_() == gst::Pipeline::static_type() + }) => + { + tracing::info!(%device, "📸 camera pipeline ▶️"); + } + Error(e) => tracing::error!( + %device, + "📸💥 camera: {} ({})", + e.error(), + e.debug().unwrap_or_default() + ), + Warning(w) => tracing::warn!( + %device, + "📸⚠️ camera: {} ({})", + w.error(), + w.debug().unwrap_or_default() + ), + _ => {} + } + } + }); +} + +/// Keep the coverage harness deterministic while preserving production bus logging. +#[cfg(coverage)] +fn spawn_camera_bus_logger(pipeline: &gst::Pipeline, _device: String) { + let _ = pipeline.bus(); +} + +#[cfg(not(coverage))] +fn buildable_encoder(encoder: &'static str) -> bool { + gst::ElementFactory::find(encoder).is_some() + && gst::ElementFactory::make(encoder).build().is_ok() +} + +#[cfg(not(coverage))] +fn supported_encoder_property( + encoder: &'static str, + properties: &[&'static str], +) -> Option<&'static str> { + let element = gst::ElementFactory::make(encoder).build().ok()?; + properties + .iter() + .copied() + .find(|property| element.find_property(property).is_some()) +} + +impl Drop for CameraCapture { + /// Stop the local preview tap and release the GStreamer camera pipeline. + fn drop(&mut self) { + if let Some(running) = &self.preview_tap_running { + running.store(false, Ordering::Release); + } + let _ = self.pipeline.set_state(gst::State::Null); + } +} diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs new file mode 100644 index 0000000..33ba8e2 --- /dev/null +++ b/client/src/input/camera/capture_pipeline.rs @@ -0,0 +1,254 @@ +impl CameraCapture { + pub fn new(device_fragment: Option<&str>, cfg: Option) -> anyhow::Result { + gst::init().ok(); + + // Select source: V4L2 device or test pattern + let (src_desc, dev_label, allow_mjpg_source) = match device_fragment { + Some(fragment) + if fragment.eq_ignore_ascii_case("test") + || fragment.eq_ignore_ascii_case("videotestsrc") => + { + let pattern = + std::env::var("LESAVKA_CAM_TEST_PATTERN").unwrap_or_else(|_| "smpte".into()); + ( + format!("videotestsrc is-live=true pattern={pattern}"), + format!("videotestsrc:{pattern}"), + false, + ) + } + Some(path) if path.starts_with("/dev/") => ( + format!("v4l2src device={path} do-timestamp=true"), + path.to_string(), + true, + ), + Some(fragment) => { + let dev = Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()); + (format!("v4l2src device={dev} do-timestamp=true"), dev, true) + } + None => { + let dev = "/dev/video0".to_string(); + (format!("v4l2src device={dev} do-timestamp=true"), dev, true) + } + }; + + let output_mjpeg = cfg.map_or_else( + || { + std::env::var("LESAVKA_CAM_CODEC").ok().is_some_and(|v| { + matches!(v.to_ascii_lowercase().as_str(), "mjpeg" | "mjpg" | "jpeg") + }) + }, + |cfg| matches!(cfg.codec, CameraCodec::Mjpeg), + ); + let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); + let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)); + let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)); + let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1); + let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); + let source_profile = camera_source_profile(allow_mjpg_source); + let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; + let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg { + ("x264enc", Some("key-int-max")) + } else { + Self::choose_encoder() + }; + match source_profile { + CameraSourceProfile::Mjpeg if !output_mjpeg => { + tracing::info!("📸 using MJPG source with software encode"); + } + CameraSourceProfile::AutoDecode => { + tracing::info!("📸 using auto-decoded webcam source (raw/MJPEG accepted)"); + } + _ => {} + } + let enc_opts = Self::encoder_options(enc, kf_prop, keyframe_interval); + if output_mjpeg { + tracing::info!("📸 outputting MJPEG frames for UVC (quality={jpeg_quality})"); + } else { + tracing::info!("📸 using encoder element: {enc}"); + } + #[cfg(not(coverage))] + let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some(); + let (src_caps, preenc) = match enc { + // ─────────────────────────────────────────────────────────────────── + // Jetson (has nvvidconv) Desktop (falls back to videoconvert) + // ─────────────────────────────────────────────────────────────────── + #[cfg(not(coverage))] + "nvh264enc" if have_nvvidconv => + (format!( + "video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1" + ), "nvvidconv !"), + #[cfg(not(coverage))] + "nvh264enc" /* else */ => + (format!( + "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" + ), "videoconvert !"), + #[cfg(not(coverage))] + "vaapih264enc" => + (format!( + "video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1" + ), "videoconvert !"), + _ => + (format!( + "video/x-raw,width={width},height={height},framerate={fps}/1" + ), "videoconvert !"), + }; + + // let desc = format!( + // "v4l2src device={dev} do-timestamp=true ! {raw_caps},width=1280,height=720 ! \ + // videoconvert ! {enc} key-int-max=30 ! \ + // h264parse config-interval=-1 ! \ + // appsink name=asink emit-signals=true max-buffers=60 drop=true" + // ); + // tracing::debug!(%desc, "📸 pipeline-desc"); + // Build a pipeline that works for any of the three encoders. + // * nvh264enc needs NVMM memory caps; + // * vaapih264enc wants system-memory caps; + // * x264enc needs the usual raw caps. + let preview_tap_path = camera_preview_tap_path(); + let preview_tap_branch = camera_preview_tap_branch(width, height, fps); + let raw_source_chain = + camera_raw_source_chain(&src_desc, &src_caps, width, height, fps, source_profile); + let desc = if preview_tap_path.is_some() { + if output_mjpeg { + if use_mjpg_source { + format!( + "{src_desc} ! \ + image/jpeg,width={width},height={height},framerate={fps}/1 ! \ + tee name=t \ + t. ! queue max-size-buffers=30 leaky=downstream ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true \ + t. ! queue max-size-buffers=2 leaky=downstream ! jpegdec ! \ + {preview_tap_branch}" + ) + } else { + format!( + "{raw_source_chain} ! \ + tee name=t \ + t. ! queue max-size-buffers=30 leaky=downstream ! \ + videoconvert ! jpegenc quality={jpeg_quality} ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true \ + t. ! queue max-size-buffers=2 leaky=downstream ! \ + {preview_tap_branch}" + ) + } + } else if use_mjpg_source { + format!( + "{src_desc} ! \ + image/jpeg,width={width},height={height} ! \ + jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ + tee name=t \ + t. ! queue max-size-buffers=30 leaky=downstream ! \ + videoconvert ! {enc_opts} ! \ + h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true \ + t. ! queue max-size-buffers=2 leaky=downstream ! \ + {preview_tap_branch}" + ) + } else { + format!( + "{raw_source_chain} ! \ + tee name=t \ + t. ! queue max-size-buffers=30 leaky=downstream ! \ + {preenc} {enc_opts} ! \ + h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true \ + t. ! queue max-size-buffers=2 leaky=downstream ! \ + {preview_tap_branch}" + ) + } + } else if output_mjpeg { + if use_mjpg_source { + format!( + "{src_desc} ! \ + image/jpeg,width={width},height={height},framerate={fps}/1 ! \ + queue max-size-buffers=30 leaky=downstream ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true" + ) + } else { + format!( + "{raw_source_chain} ! \ + videoconvert ! jpegenc quality={jpeg_quality} ! \ + queue max-size-buffers=30 leaky=downstream ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true" + ) + } + } else if use_mjpg_source { + format!( + "{src_desc} ! \ + image/jpeg,width={width},height={height} ! \ + jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \ + videoconvert ! {enc_opts} ! \ + h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ + queue max-size-buffers=30 leaky=downstream ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true" + ) + } else { + format!( + "{raw_source_chain} ! \ + {preenc} {enc_opts} ! \ + h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \ + queue max-size-buffers=30 leaky=downstream ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true" + ) + }; + tracing::info!(%enc, width, height, fps, ?desc, "📸 using encoder element"); + + let pipeline: gst::Pipeline = gst::parse::launch(&desc) + .context("gst parse_launch(cam)")? + .downcast::() + .expect("not a pipeline"); + + tracing::debug!("📸 pipeline built OK – setting PLAYING…"); + let sink: gst_app::AppSink = pipeline + .by_name("asink") + .expect("appsink element not found") + .downcast::() + .expect("appsink down‑cast"); + + spawn_camera_bus_logger(&pipeline, dev_label.clone()); + if let Err(err) = pipeline.set_state(gst::State::Playing) { + let _ = pipeline.set_state(gst::State::Null); + return Err(err.into()); + } + tracing::info!("📸 webcam pipeline ▶️ device={dev_label}"); + + let preview_tap_running = if let Some(path) = preview_tap_path { + let preview_sink = pipeline + .by_name("preview_sink") + .context("missing camera preview tap appsink")? + .downcast::() + .expect("camera preview tap appsink"); + Some(spawn_camera_preview_tap(preview_sink, path)) + } else { + None + }; + + Ok(Self { + pipeline, + sink, + preview_tap_running, + }) + } + + pub fn pull(&self) -> Option { + let sample = self.sink.pull_sample().ok()?; + let buf = sample.buffer()?; + let map = buf.map_readable().ok()?; + let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; + static FIRST_CAMERA_PACKET: AtomicBool = AtomicBool::new(false); + if !FIRST_CAMERA_PACKET.swap(true, Ordering::Relaxed) { + tracing::info!( + bytes = map.as_slice().len(), + pts_us = pts, + "📸 upstream webcam frames flowing" + ); + } + Some(VideoPacket { + id: 2, + pts, + data: map.as_slice().to_vec(), + ..Default::default() + }) + } + +} diff --git a/client/src/input/camera/device_selection.rs b/client/src/input/camera/device_selection.rs new file mode 100644 index 0000000..7b9f21a --- /dev/null +++ b/client/src/input/camera/device_selection.rs @@ -0,0 +1,100 @@ +impl CameraCapture { + /// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes + #[cfg(not(coverage))] + fn find_device(substr: &str) -> Option { + let wanted = substr.to_ascii_lowercase(); + let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") + .ok()? + .flatten() + .filter_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); + if name.contains(&wanted) { + Some(p) + } else { + None + } + }) + .collect(); + + // deterministic order + matches.sort(); + + for p in matches { + if let Ok(target) = std::fs::read_link(&p) { + let dev = format!("/dev/{}", target.file_name()?.to_string_lossy()); + if Self::is_capture(&dev) { + return Some(dev); + } + } + } + None + } + + #[cfg(coverage)] + fn find_device(substr: &str) -> Option { + let wanted = substr.to_ascii_lowercase(); + let by_id_dir = + std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); + let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); + let mut matches: Vec<_> = std::fs::read_dir(by_id_dir) + .ok()? + .flatten() + .filter_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_string_lossy().to_ascii_lowercase(); + if name.contains(&wanted) { + Some(p) + } else { + None + } + }) + .collect(); + matches.sort(); + for p in matches { + if let Ok(target) = std::fs::read_link(&p) { + let dev = format!("{}/{}", dev_root, target.file_name()?.to_string_lossy()); + if Self::is_capture(&dev) { + return Some(dev); + } + } + } + None + } + + #[cfg(not(coverage))] + fn is_capture(dev: &str) -> bool { + const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001; + const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 0x0000_1000; + + v4l::Device::with_path(dev) + .ok() + .and_then(|d| d.query_caps().ok()) + .map(|caps| { + let bits = caps.capabilities.bits(); + (bits & V4L2_CAP_VIDEO_CAPTURE != 0) || (bits & V4L2_CAP_VIDEO_CAPTURE_MPLANE != 0) + }) + .unwrap_or(false) + } + + #[cfg(coverage)] + fn is_capture(dev: &str) -> bool { + dev.starts_with("/dev/video") + } + + /// Cheap stub used when the web‑cam is disabled + pub fn new_stub() -> Self { + let pipeline = gst::Pipeline::new(); + let sink: gst_app::AppSink = gst::ElementFactory::make("appsink") + .build() + .expect("appsink") + .downcast::() + .unwrap(); + Self { + pipeline, + sink, + preview_tap_running: None, + } + } + +} diff --git a/client/src/input/camera/encoder_selection.rs b/client/src/input/camera/encoder_selection.rs new file mode 100644 index 0000000..662434d --- /dev/null +++ b/client/src/input/camera/encoder_selection.rs @@ -0,0 +1,85 @@ +impl CameraCapture { + #[allow(dead_code)] // helper kept for future heuristics + #[cfg(not(coverage))] + fn pick_encoder() -> (&'static str, &'static str) { + let encoders = &[ + ("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"), + ("vaapih264enc", "video/x-raw,format=NV12"), + ("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc. + ("x264enc", "video/x-raw"), // software + ]; + for (name, caps) in encoders { + if gst::ElementFactory::find(name).is_some() { + return (name, caps); + } + } + // last resort – software + ("x264enc", "video/x-raw") + } + + #[cfg(coverage)] + fn pick_encoder() -> (&'static str, &'static str) { + ("x264enc", "video/x-raw") + } + + #[cfg(not(coverage))] + fn choose_encoder() -> (&'static str, Option<&'static str>) { + if buildable_encoder("nvh264enc") { + return ( + "nvh264enc", + supported_encoder_property( + "nvh264enc", + &["iframeinterval", "idrinterval", "gop-size"], + ), + ); + } + if buildable_encoder("vaapih264enc") { + return ( + "vaapih264enc", + supported_encoder_property("vaapih264enc", &["keyframe-period"]), + ); + } + if buildable_encoder("v4l2h264enc") { + return ( + "v4l2h264enc", + supported_encoder_property("v4l2h264enc", &["idrcount"]), + ); + } + ("x264enc", Some("key-int-max")) + } + + #[cfg(coverage)] + fn choose_encoder() -> (&'static str, Option<&'static str>) { + match std::env::var("LESAVKA_CAM_TEST_ENCODER") + .ok() + .as_deref() + .map(str::trim) + { + Some("nvh264enc") => ("nvh264enc", None), + Some("vaapih264enc") => ("vaapih264enc", Some("keyframe-period")), + Some("v4l2h264enc") => ("v4l2h264enc", Some("idrcount")), + _ => ("x264enc", Some("key-int-max")), + } + } + + fn encoder_options( + enc: &'static str, + kf_prop: Option<&'static str>, + keyframe_interval: u32, + ) -> String { + if enc == "x264enc" { + let bitrate_kbit = env_u32("LESAVKA_CAM_H264_KBIT", 4500); + let keyframe_opt = kf_prop + .map(|property| format!(" {property}={keyframe_interval}")) + .unwrap_or_default(); + format!( + "{enc} tune=zerolatency speed-preset=faster bitrate={bitrate_kbit}{keyframe_opt}" + ) + } else if let Some(property) = kf_prop { + format!("{enc} {property}={keyframe_interval}") + } else { + enc.to_string() + } + } + +} diff --git a/client/src/input/camera/preview_tap.rs b/client/src/input/camera/preview_tap.rs new file mode 100644 index 0000000..be79479 --- /dev/null +++ b/client/src/input/camera/preview_tap.rs @@ -0,0 +1,100 @@ +/// Publish actual-size local preview frames so the launcher mirrors uplink quality. +fn spawn_camera_preview_tap(sink: gst_app::AppSink, path: PathBuf) -> Arc { + let running = Arc::new(AtomicBool::new(true)); + let thread_running = Arc::clone(&running); + thread::spawn(move || { + let mut wrote_first = false; + let mut empty_polls = 0_u64; + while thread_running.load(Ordering::Acquire) { + if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + empty_polls = 0; + match write_camera_preview_tap(&path, &sample) { + Ok(info) => { + if !wrote_first { + wrote_first = true; + #[cfg(not(coverage))] + log_camera_preview_tap_started(&path, &info); + } + } + Err(err) => { + tracing::debug!("📸 local uplink preview tap write failed: {err:#}"); + thread::sleep(Duration::from_millis(100)); + } + } + } else if !wrote_first { + empty_polls += 1; + if empty_polls == 20 || empty_polls.is_multiple_of(120) { + #[cfg(not(coverage))] + log_camera_preview_tap_waiting(&path); + } + } + } + }); + running +} + +#[cfg(not(coverage))] +fn log_camera_preview_tap_started(path: &Path, info: &CameraPreviewTapInfo) { + tracing::info!( + path = %path.display(), + width = info.width, + height = info.height, + stride = info.stride, + "📸 local uplink preview tap publishing frames" + ); +} + +#[cfg(not(coverage))] +/// Log that the preview tap is still alive while waiting for first frames. +fn log_camera_preview_tap_waiting(path: &Path) { + tracing::warn!( + path = %path.display(), + "📸 local uplink preview tap is still waiting for webcam frames" + ); +} + +struct CameraPreviewTapInfo { + width: i32, + height: i32, + stride: usize, +} + +/// Atomically write one RGBA preview sample for the launcher status pane. +fn write_camera_preview_tap( + path: &Path, + sample: &gst::Sample, +) -> anyhow::Result { + let caps = sample.caps().context("preview tap sample missing caps")?; + let structure = caps + .structure(0) + .context("preview tap caps missing structure")?; + let width = structure + .get::("width") + .context("preview tap caps missing width")?; + let height = structure + .get::("height") + .context("preview tap caps missing height")?; + let buffer = sample + .buffer() + .context("preview tap sample missing buffer")?; + let map = buffer + .map_readable() + .context("preview tap buffer unreadable")?; + let row_count = usize::try_from(height) + .ok() + .filter(|height| *height > 0) + .unwrap_or(1); + let stride = map.as_slice().len() / row_count; + let tmp_path = path.with_extension("tmp"); + let mut file = std::fs::File::create(&tmp_path) + .with_context(|| format!("creating {}", tmp_path.display()))?; + writeln!(file, "LESAVKA_RGBA {width} {height} {stride}")?; + file.write_all(map.as_slice())?; + file.sync_all().ok(); + std::fs::rename(&tmp_path, path).with_context(|| format!("publishing {}", path.display()))?; + Ok(CameraPreviewTapInfo { + width, + height, + stride, + }) +} diff --git a/client/src/input/camera/source_description.rs b/client/src/input/camera/source_description.rs new file mode 100644 index 0000000..3c31f6e --- /dev/null +++ b/client/src/input/camera/source_description.rs @@ -0,0 +1,76 @@ +/// Choose the pre-encoder webcam format path. +/// +/// V4L2 webcams often expose 720p/30 as MJPEG only, so the default accepts +/// either raw frames or MJPEG unless the operator explicitly pins a format. +fn camera_source_profile(allow_v4l2_auto_decode: bool) -> CameraSourceProfile { + if !allow_v4l2_auto_decode { + return CameraSourceProfile::Raw; + } + if std::env::var("LESAVKA_CAM_MJPG").is_ok() { + return CameraSourceProfile::Mjpeg; + } + match std::env::var("LESAVKA_CAM_FORMAT") + .ok() + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("mjpg" | "mjpeg" | "jpeg") => CameraSourceProfile::Mjpeg, + Some("raw" | "yuyv" | "yuy2") => CameraSourceProfile::Raw, + _ => CameraSourceProfile::AutoDecode, + } +} + +/// Build the source-to-raw-video chain consumed by the encoder and preview tap. +fn camera_raw_source_chain( + src_desc: &str, + src_caps: &str, + width: u32, + height: u32, + fps: u32, + profile: CameraSourceProfile, +) -> String { + match profile { + CameraSourceProfile::Raw => format!("{src_desc} ! {src_caps}"), + CameraSourceProfile::Mjpeg => format!( + "{src_desc} ! \ + image/jpeg,width={width},height={height},framerate={fps}/1 ! \ + jpegdec ! videoconvert ! videoscale ! videorate ! \ + video/x-raw,width={width},height={height},framerate={fps}/1" + ), + CameraSourceProfile::AutoDecode => format!( + "{src_desc} ! \ + capsfilter caps=\"{}\" ! \ + decodebin ! videoconvert ! videoscale ! videorate ! \ + video/x-raw,width={width},height={height},framerate={fps}/1,pixel-aspect-ratio=1/1", + camera_auto_decode_caps(width, height, fps) + ), + } +} + +/// Caps string that lets decodebin negotiate either raw webcam frames or MJPEG. +fn camera_auto_decode_caps(width: u32, height: u32, fps: u32) -> String { + format!( + "video/x-raw,width=(int){width},height=(int){height},framerate=(fraction){fps}/1;image/jpeg,width=(int){width},height=(int){height},framerate=(fraction){fps}/1" + ) +} + +fn camera_preview_tap_path() -> Option { + std::env::var(CAMERA_PREVIEW_TAP_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn camera_preview_tap_branch(width: u32, height: u32, fps: u32) -> String { + let preview_width = width.clamp(1, i32::MAX as u32); + let preview_height = height.clamp(1, i32::MAX as u32); + let preview_fps = fps.clamp(1, 60); + format!( + "videoconvert ! videoscale ! videorate ! \ + video/x-raw,format=RGBA,width={preview_width},height={preview_height},framerate={preview_fps}/1,pixel-aspect-ratio=1/1 ! \ + appsink name=preview_sink emit-signals=false sync=false max-buffers=1 drop=true" + ) +} diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 775ecf9..e39cc43 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -1,4 +1,4 @@ -// client/src/input/inputs.rs +// Input-device discovery, routing, and local/remote handoff control. #[cfg(not(coverage))] use anyhow::bail; @@ -74,1093 +74,14 @@ pub struct InputAggregator { remote_capture_enabled: Arc, } -impl InputAggregator { - pub fn new( - dev_mode: bool, - kbd_tx: Sender, - mou_tx: Sender, - paste_tx: Option>, - ) -> Self { - Self::new_with_capture_mode(dev_mode, kbd_tx, mou_tx, paste_tx, true) - } +include!("inputs/construction_and_scan.rs"); +include!("inputs/run_loop.rs"); +include!("inputs/routing_state.rs"); - pub fn new_with_capture_mode( - dev_mode: bool, - kbd_tx: Sender, - mou_tx: Sender, - paste_tx: Option>, - capture_remote_boot: bool, - ) -> Self { - let quick_toggle_key = quick_toggle_key_from_env(); - #[cfg(not(coverage))] - let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL"); - #[cfg(not(coverage))] - let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE"); - #[cfg(not(coverage))] - let quick_toggle_control_path = - launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"); - #[cfg(not(coverage))] - let clipboard_control_path = - launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"); - let remote_failsafe_timeout = remote_failsafe_timeout_from_env(); - Self { - kbd_tx, - mou_tx, - dev_mode, - released: !capture_remote_boot, - magic_active: false, - pending_release: false, - pending_kill: false, - pending_keys: HashSet::new(), - last_keyboard_report: [0; 8], - paste_tx, - keyboards: Vec::new(), - mice: Vec::new(), - selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), - selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"), - #[cfg(not(coverage))] - known_input_paths: HashMap::new(), - capture_remote_boot, - quick_toggle_key, - quick_toggle_down: false, - quick_toggle_debounce: quick_toggle_debounce_from_env(), - last_quick_toggle_at: None, - pending_release_started_at: None, - pending_release_timeout: pending_release_timeout_from_env(), - remote_failsafe_started_at: (capture_remote_boot && !remote_failsafe_timeout.is_zero()) - .then(Instant::now), - remote_failsafe_timeout, - #[cfg(not(coverage))] - last_routing_request_raw: routing_control_path - .as_deref() - .and_then(read_launcher_control_snapshot), - #[cfg(not(coverage))] - routing_control_path, - #[cfg(not(coverage))] - last_quick_toggle_request_raw: quick_toggle_control_path - .as_deref() - .and_then(read_launcher_control_snapshot), - #[cfg(not(coverage))] - quick_toggle_control_path, - #[cfg(not(coverage))] - clipboard_control_marker: clipboard_control_path - .as_deref() - .map(path_marker) - .unwrap_or_default(), - #[cfg(not(coverage))] - clipboard_control_path, - #[cfg(not(coverage))] - routing_state_path, - #[cfg(not(coverage))] - published_remote_capture: None, - remote_capture_enabled: Arc::new(AtomicBool::new(capture_remote_boot)), - } - } - - pub fn remote_capture_enabled_handle(&self) -> Arc { - Arc::clone(&self.remote_capture_enabled) - } - - #[cfg(coverage)] - pub fn init(&mut self) -> Result<()> { - let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; - for path in paths.flatten().map(|entry| entry.path()) { - if !path - .file_name() - .map(|f| f.to_string_lossy().starts_with("event")) - .unwrap_or(false) - { - continue; - } - if let Ok(dev) = Device::open(&path) { - let _ = dev.set_nonblocking(true); - match classify_device(&dev) { - DeviceKind::Keyboard => { - if !matches_selected_input_device( - &path, - self.selected_keyboard_path.as_deref(), - ) { - continue; - } - let mut aggregator = KeyboardAggregator::new( - dev, - self.dev_mode, - self.kbd_tx.clone(), - self.paste_tx.clone(), - ); - aggregator.set_send(self.capture_remote_boot); - if !self.capture_remote_boot { - aggregator.set_grab(false); - } - self.keyboards.push(aggregator); - } - DeviceKind::Mouse => { - if !matches_selected_input_device( - &path, - self.selected_mouse_path.as_deref(), - ) { - continue; - } - let mut aggregator = - MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); - aggregator.set_send(self.capture_remote_boot); - if !self.capture_remote_boot { - aggregator.set_grab(false); - } - self.mice.push(aggregator); - } - DeviceKind::Other => {} - } - } - } - Ok(()) - } - - #[cfg(not(coverage))] - pub fn init(&mut self) -> Result<()> { - let found_any = self.scan_input_devices(self.capture_remote_boot, true)?; - - if !found_any { - bail!("No suitable keyboard/mouse devices found or none grabbed."); - } - - Ok(()) - } - - #[cfg(not(coverage))] - fn scan_input_devices(&mut self, remote_active: bool, fail_grab: bool) -> Result { - let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; - let mut found_any = false; - - for entry in paths { - let entry = entry?; - let path = entry.path(); - - if !path - .file_name() - .map_or(false, |f| f.to_string_lossy().starts_with("event")) - { - continue; - } - let identity = input_device_identity(&path).unwrap_or_default(); - if self - .known_input_paths - .get(&path) - .is_some_and(|known| *known == identity) - { - continue; - } - - let mut dev = match Device::open(&path) { - Ok(d) => d, - Err(e) => { - warn!("❌ open {}: {e}", path.display()); - continue; - } - }; - - dev.set_nonblocking(true) - .with_context(|| format!("set_non_blocking {:?}", path))?; - - match classify_device(&dev) { - DeviceKind::Keyboard => { - if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref()) - { - self.known_input_paths.insert(path, identity); - continue; - } - if remote_active { - if let Err(err) = dev.grab() { - if fail_grab { - return Err(err) - .with_context(|| format!("grabbing keyboard {path:?}")); - } - warn!("❌ grab keyboard {}: {err}", path.display()); - continue; - } - info!( - "🤏🖱️ Grabbed keyboard {:?}", - dev.name().unwrap_or("UNKNOWN") - ); - } else { - info!( - "⌨️ local-input mode; keyboard staged ungrabbed {:?}", - dev.name().unwrap_or("UNKNOWN") - ); - } - - let mut kbd_agg = KeyboardAggregator::new( - dev, - self.dev_mode, - self.kbd_tx.clone(), - self.paste_tx.clone(), - ); - kbd_agg.set_send(remote_active); - if !remote_active { - kbd_agg.set_grab(false); - } - self.known_input_paths.insert(path, identity); - self.keyboards.push(kbd_agg); - found_any = true; - } - DeviceKind::Mouse => { - if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) { - self.known_input_paths.insert(path, identity); - continue; - } - if remote_active { - if let Err(err) = dev.grab() { - if fail_grab { - return Err(err) - .with_context(|| format!("grabbing mouse {path:?}")); - } - warn!("❌ grab mouse {}: {err}", path.display()); - continue; - } - info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN")); - } else { - info!( - "🖱️ local-input mode; mouse staged ungrabbed {:?}", - dev.name().unwrap_or("UNKNOWN") - ); - } - - let mut mouse_agg = - MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); - mouse_agg.set_send(remote_active); - if !remote_active { - mouse_agg.set_grab(false); - } - self.known_input_paths.insert(path, identity); - self.mice.push(mouse_agg); - found_any = true; - } - DeviceKind::Other => { - debug!( - "Skipping non-kbd/mouse device: {:?}", - dev.name().unwrap_or("UNKNOWN") - ); - self.known_input_paths.insert(path, identity); - } - } - } - - Ok(found_any) - } - - #[cfg(coverage)] - pub async fn run(&mut self) -> Result<()> { - loop { - self.process_keyboard_updates(); - let quick_toggle_now = self.quick_toggle_active(); - self.observe_quick_toggle(quick_toggle_now); - - if self.remote_failsafe_expired() { - self.begin_local_release(); - } - - if self.pending_release || self.pending_kill { - let chord_released = if self.pending_keys.is_empty() { - !self - .keyboards - .iter() - .any(|k| k.magic_grab() || k.magic_kill()) - } else { - self.pending_keys - .iter() - .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) - }; - - if chord_released { - let pending_kill = self.pending_kill; - self.finish_local_release(!pending_kill); - if pending_kill { - return Ok(()); - } - } - } - - for mouse in &mut self.mice { - mouse.process_events(); - } - - tokio::task::yield_now().await; - } - } - - #[cfg(not(coverage))] - pub async fn run(&mut self) -> Result<()> { - // Example approach: poll each aggregator in a simple loop - let mut tick = interval(Duration::from_millis(10)); - let mut current = Layout::SideBySide; - let input_rescan_interval = input_rescan_interval_from_env(); - let mut last_input_rescan_at = Instant::now(); - self.publish_routing_state_if_changed(); - loop { - let mut want_kill = false; - self.process_keyboard_updates(); - if !input_rescan_interval.is_zero() - && last_input_rescan_at.elapsed() >= input_rescan_interval - { - last_input_rescan_at = Instant::now(); - let remote_active = self.remote_capture_active(); - if let Err(err) = self.scan_input_devices(remote_active, false) { - warn!("⚠️ input device rescan failed: {err:#}"); - } - } - for kbd in &self.keyboards { - want_kill |= kbd.magic_kill(); - } - self.poll_launcher_routing_request(); - self.poll_launcher_quick_toggle_request(); - self.poll_launcher_clipboard_request(); - let quick_toggle_now = self.quick_toggle_active(); - self.observe_quick_toggle(quick_toggle_now); - let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); - let magic_left = self.keyboards.iter().any(|k| k.magic_left()); - let magic_right = self.keyboards.iter().any(|k| k.magic_right()); - - if magic_now && !self.magic_active { - self.toggle_grab(); - } - if (magic_left || magic_right) && self.magic_active { - current = match current { - Layout::SideBySide => Layout::FullLeft, - Layout::FullLeft => Layout::FullRight, - Layout::FullRight => Layout::SideBySide, - }; - apply_layout(current); - } - if want_kill && !self.pending_kill { - warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️"); - self.remote_capture_enabled.store(false, Ordering::Relaxed); - for k in &mut self.keyboards { - k.send_empty_report(); - k.set_send(false); - } - for m in &mut self.mice { - m.reset_state(); - m.set_send(false); - } - self.pending_kill = true; - self.capture_pending_keys(); - self.pending_release_started_at = Some(Instant::now()); - } - - if self.remote_failsafe_expired() { - warn!( - "🛟 remote input failsafe expired after {} ms; returning control to this machine", - self.remote_failsafe_timeout.as_millis() - ); - self.begin_local_release(); - } - - if self.pending_release || self.pending_kill { - let chord_released = if self.pending_keys.is_empty() { - !self - .keyboards - .iter() - .any(|k| k.magic_grab() || k.magic_kill()) - } else { - self.pending_keys - .iter() - .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) - }; - let timed_out = self.pending_release_timed_out(); - if chord_released || timed_out { - if timed_out { - warn!( - "⌛ local release timed out waiting for key-up events; forcing the handoff" - ); - } - self.finish_local_release(!self.pending_kill); - if self.pending_kill { - return Ok(()); - } - } - } - - for mouse in &mut self.mice { - mouse.process_events(); - } - - self.magic_active = magic_now; - tick.tick().await; - } - } - - fn toggle_grab(&mut self) { - if self.pending_release || self.pending_kill { - return; - } - if self.released { - tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒"); - } else { - tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); - } - if self.released { - self.enable_remote_capture(); - #[cfg(not(coverage))] - self.publish_routing_state_if_changed(); - } else { - self.begin_local_release(); - } - } - - fn enable_remote_capture(&mut self) { - self.remote_capture_enabled.store(true, Ordering::Relaxed); - for k in &mut self.keyboards { - k.reset_state(); - k.set_send(true); - k.set_grab(true); - } - for m in &mut self.mice { - m.reset_state(); - m.set_send(true); - m.set_grab(true); - } - self.released = false; - self.pending_release = false; - self.pending_release_started_at = None; - self.pending_keys.clear(); - self.remote_failsafe_started_at = - (!self.remote_failsafe_timeout.is_zero()).then(Instant::now); - if !self.remote_failsafe_timeout.is_zero() { - info!( - "🛟 remote input failsafe armed for {} ms while the swap key path is being re-validated", - self.remote_failsafe_timeout.as_millis() - ); - } - self.last_keyboard_report = [0; 8]; - } - - fn begin_local_release(&mut self) { - if self.released && !self.pending_release { - #[cfg(not(coverage))] - self.publish_routing_state_if_changed(); - return; - } - self.remote_failsafe_started_at = None; - self.remote_capture_enabled.store(false, Ordering::Relaxed); - for k in &mut self.keyboards { - k.send_empty_report(); - k.set_send(false); - } - for m in &mut self.mice { - m.reset_state(); - m.set_send(false); - } - self.pending_release = true; - self.pending_release_started_at = Some(Instant::now()); - self.last_keyboard_report = [0; 8]; - self.capture_pending_keys(); - } - - fn finish_local_release(&mut self, focus_launcher: bool) { - for k in &mut self.keyboards { - k.set_grab(false); - k.reset_state(); - } - for m in &mut self.mice { - m.set_grab(false); - m.reset_state(); - } - self.released = true; - self.pending_release = false; - self.pending_release_started_at = None; - self.pending_keys.clear(); - self.remote_failsafe_started_at = None; - if focus_launcher { - #[cfg(not(coverage))] - focus_launcher_on_local_if_enabled(); - } - #[cfg(not(coverage))] - self.publish_routing_state_if_changed(); - } - - fn pending_release_timed_out(&self) -> bool { - (self.pending_release || self.pending_kill) - && self - .pending_release_started_at - .is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout) - } - - fn remote_failsafe_expired(&self) -> bool { - !self.released - && !self.pending_release - && !self.pending_kill - && !self.remote_failsafe_timeout.is_zero() - && self - .remote_failsafe_started_at - .is_some_and(|started_at| started_at.elapsed() >= self.remote_failsafe_timeout) - } - - fn remote_capture_active(&self) -> bool { - !self.released - && !self.pending_release - && !self.pending_kill - && self.remote_capture_enabled.load(Ordering::Relaxed) - } - - fn capture_pending_keys(&mut self) { - self.pending_keys.clear(); - for k in &self.keyboards { - for key in k.pressed_keys_snapshot() { - self.pending_keys.insert(key); - } - } - } - - fn process_keyboard_updates(&mut self) { - for index in 0..self.keyboards.len() { - let mut keyboard_shadow: HashSet = self.keyboards[index] - .pressed_keys_snapshot() - .into_iter() - .collect(); - let other_pressed: HashSet = self - .keyboards - .iter() - .enumerate() - .filter(|(other_index, _)| *other_index != index) - .flat_map(|(_, keyboard)| keyboard.pressed_keys_snapshot()) - .collect(); - let updates = { - let keyboard = &mut self.keyboards[index]; - keyboard.drain_key_updates() - }; - for update in updates { - update_shadow_pressed_keys(&mut keyboard_shadow, update.code, update.value); - if update.swallowed || !self.keyboard_capture_enabled() { - continue; - } - let report = build_keyboard_report( - other_pressed - .iter() - .copied() - .chain(keyboard_shadow.iter().copied()), - ); - if report == self.last_keyboard_report { - continue; - } - emit_live_keyboard_report(&self.kbd_tx, update.code, update.value, report); - self.last_keyboard_report = report; - } - } - } - - fn keyboard_capture_enabled(&self) -> bool { - self.keyboards - .iter() - .any(KeyboardAggregator::sending_enabled) - } - - fn quick_toggle_active(&mut self) -> bool { - self.quick_toggle_key.is_some_and(|key| { - self.keyboards - .iter_mut() - .any(|kbd| kbd.take_key_activation(key)) - }) - } - - fn observe_quick_toggle(&mut self, quick_toggle_now: bool) { - if quick_toggle_now && !self.quick_toggle_down { - let now = Instant::now(); - let debounced = self - .last_quick_toggle_at - .is_none_or(|last| now.duration_since(last) >= self.quick_toggle_debounce); - if debounced { - if let Some(key) = self.quick_toggle_key { - info!( - "🎛️ quick-toggle {:?} engaged for smooth local/remote handoff", - key - ); - } - self.toggle_grab(); - self.last_quick_toggle_at = Some(now); - } - } - self.quick_toggle_down = quick_toggle_now; - } - - #[cfg(not(coverage))] - fn poll_launcher_routing_request(&mut self) { - let Some(path) = self.routing_control_path.as_deref() else { - return; - }; - let Some(raw) = read_launcher_control_snapshot(path) else { - return; - }; - if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) { - return; - } - self.last_routing_request_raw = Some(raw.clone()); - let Some(remote_capture) = parse_launcher_routing_request(&raw) else { - return; - }; - if self.pending_kill { - return; - } - if remote_capture { - if !self.released && !self.pending_release { - return; - } - info!("🎛️ launcher requested remote input capture"); - self.enable_remote_capture(); - self.publish_routing_state_if_changed(); - } else { - if self.released && !self.pending_release { - return; - } - info!("🎛️ launcher requested local input capture"); - self.begin_local_release(); - } - } - - #[cfg(not(coverage))] - fn poll_launcher_quick_toggle_request(&mut self) { - let Some(path) = self.quick_toggle_control_path.as_deref() else { - return; - }; - let Some(raw) = read_launcher_control_snapshot(path) else { - return; - }; - if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) { - return; - } - self.last_quick_toggle_request_raw = Some(raw.clone()); - let next_key = raw - .split_ascii_whitespace() - .next() - .and_then(parse_quick_toggle_key); - self.quick_toggle_key = next_key; - self.quick_toggle_down = false; - self.last_quick_toggle_at = None; - match next_key { - Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key), - None => info!("🎛️ launcher disabled the live swap key"), - } - } - - #[cfg(not(coverage))] - fn poll_launcher_clipboard_request(&mut self) { - let Some(path) = self.clipboard_control_path.as_deref() else { - return; - }; - let marker = path_marker(path); - if marker <= self.clipboard_control_marker { - return; - } - self.clipboard_control_marker = marker; - let Some(keyboard) = self.keyboards.first_mut() else { - warn!("📋 launcher requested clipboard paste, but no keyboard is available"); - return; - }; - info!("📋 launcher requested clipboard paste on the live relay session"); - keyboard.trigger_clipboard_paste(); - } - - #[cfg(not(coverage))] - fn publish_routing_state_if_changed(&mut self) { - let remote_capture = !self.released; - if self.published_remote_capture == Some(remote_capture) { - return; - } - if let Some(path) = self.routing_state_path.as_deref() { - let _ = std::fs::write( - path, - if remote_capture { - "remote\n" - } else { - "local\n" - }, - ); - } - self.published_remote_capture = Some(remote_capture); - } -} - -/// The classification function -#[cfg(coverage)] -fn classify_device(dev: &Device) -> DeviceKind { - let evbits = dev.supported_events(); - let keyset = dev.supported_keys(); - - if evbits.contains(EventType::KEY) - && keyset - .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) - { - if should_ignore_keyboard_device(dev) { - return DeviceKind::Other; - } - return DeviceKind::Keyboard; - } - - if evbits.contains(EventType::RELATIVE) - && let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), keyset) - && rel.contains(RelativeAxisCode::REL_X) - && rel.contains(RelativeAxisCode::REL_Y) - && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT)) - { - return DeviceKind::Mouse; - } - - if evbits.contains(EventType::ABSOLUTE) - && let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), keyset) - && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) - || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) - && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y))) - && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT)) - { - return DeviceKind::Mouse; - } - - DeviceKind::Other -} - -#[cfg(not(coverage))] -fn classify_device(dev: &Device) -> DeviceKind { - let evbits = dev.supported_events(); - - // Keyboard logic - if evbits.contains(EventType::KEY) { - if let Some(keys) = dev.supported_keys() { - if keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER) { - if should_ignore_keyboard_device(dev) { - return DeviceKind::Other; - } - return DeviceKind::Keyboard; - } - } - } - - // Mouse logic (relative) - if evbits.contains(EventType::RELATIVE) { - if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) { - let has_xy = - rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y); - let has_btn = keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT); - if has_xy && has_btn { - return DeviceKind::Mouse; - } - } - } - // Touchpad logic (absolute) - if evbits.contains(EventType::ABSOLUTE) { - if let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), dev.supported_keys()) { - let has_xy = (abs.contains(AbsoluteAxisCode::ABS_X) - && abs.contains(AbsoluteAxisCode::ABS_Y)) - || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) - && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)); - let has_btn = keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT); - if has_xy && has_btn { - return DeviceKind::Mouse; - } - } - } - - DeviceKind::Other -} - -/// Internal enum for device classification -#[derive(Debug, Clone, Copy)] -enum DeviceKind { - Keyboard, - Mouse, - Other, -} - -fn should_ignore_keyboard_device(dev: &Device) -> bool { - let name = dev.name().unwrap_or_default().to_ascii_lowercase(); - name.contains("lesavka") - || name.contains("automation input") - || name.contains("codex-persistent-kbd") -} - -fn update_shadow_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { - if value == 0 { - pressed_keys.remove(&code); - } else if value > 0 { - pressed_keys.insert(code); - } -} - -/// Resolves the quick-toggle key from env, defaulting to Pause/Break. -fn quick_toggle_key_from_env() -> Option { - match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") { - Ok(raw) => parse_quick_toggle_key(&raw), - Err(_) => Some(KeyCode::KEY_PAUSE), - } -} - -/// Parses a launcher/operator key alias into an evdev key code. -fn parse_quick_toggle_key(raw: &str) -> Option { - let normalized = raw.trim().to_ascii_lowercase(); - if matches!(normalized.as_str(), "" | "off" | "none" | "disabled") { - return None; - } - - if let Some(letter) = parse_quick_toggle_letter(&normalized) { - return Some(letter); - } - - if let Some(digit) = parse_quick_toggle_digit(&normalized) { - return Some(digit); - } - - if let Some(function) = parse_quick_toggle_function_key(&normalized) { - return Some(function); - } - - match normalized.as_str() { - "scrolllock" | "scroll_lock" | "scroll-lock" => Some(KeyCode::KEY_SCROLLLOCK), - "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { - Some(KeyCode::KEY_SYSRQ) - } - "pause" | "pausebreak" | "pause_break" | "pause-break" => Some(KeyCode::KEY_PAUSE), - "escape" | "esc" => Some(KeyCode::KEY_ESC), - "tab" => Some(KeyCode::KEY_TAB), - "capslock" | "caps_lock" | "caps-lock" => Some(KeyCode::KEY_CAPSLOCK), - "backspace" | "back_space" | "back-space" => Some(KeyCode::KEY_BACKSPACE), - "space" | "spacebar" => Some(KeyCode::KEY_SPACE), - "enter" | "return" => Some(KeyCode::KEY_ENTER), - "insert" => Some(KeyCode::KEY_INSERT), - "delete" | "del" => Some(KeyCode::KEY_DELETE), - "home" => Some(KeyCode::KEY_HOME), - "end" => Some(KeyCode::KEY_END), - "pageup" | "page_up" | "page-up" => Some(KeyCode::KEY_PAGEUP), - "pagedown" | "page_down" | "page-down" => Some(KeyCode::KEY_PAGEDOWN), - "left" => Some(KeyCode::KEY_LEFT), - "right" => Some(KeyCode::KEY_RIGHT), - "up" => Some(KeyCode::KEY_UP), - "down" => Some(KeyCode::KEY_DOWN), - _ => Some(KeyCode::KEY_PAUSE), - } -} - -fn parse_quick_toggle_letter(raw: &str) -> Option { - match raw { - "a" => Some(KeyCode::KEY_A), - "b" => Some(KeyCode::KEY_B), - "c" => Some(KeyCode::KEY_C), - "d" => Some(KeyCode::KEY_D), - "e" => Some(KeyCode::KEY_E), - "f" => Some(KeyCode::KEY_F), - "g" => Some(KeyCode::KEY_G), - "h" => Some(KeyCode::KEY_H), - "i" => Some(KeyCode::KEY_I), - "j" => Some(KeyCode::KEY_J), - "k" => Some(KeyCode::KEY_K), - "l" => Some(KeyCode::KEY_L), - "m" => Some(KeyCode::KEY_M), - "n" => Some(KeyCode::KEY_N), - "o" => Some(KeyCode::KEY_O), - "p" => Some(KeyCode::KEY_P), - "q" => Some(KeyCode::KEY_Q), - "r" => Some(KeyCode::KEY_R), - "s" => Some(KeyCode::KEY_S), - "t" => Some(KeyCode::KEY_T), - "u" => Some(KeyCode::KEY_U), - "v" => Some(KeyCode::KEY_V), - "w" => Some(KeyCode::KEY_W), - "x" => Some(KeyCode::KEY_X), - "y" => Some(KeyCode::KEY_Y), - "z" => Some(KeyCode::KEY_Z), - _ => None, - } -} - -fn parse_quick_toggle_digit(raw: &str) -> Option { - match raw { - "1" => Some(KeyCode::KEY_1), - "2" => Some(KeyCode::KEY_2), - "3" => Some(KeyCode::KEY_3), - "4" => Some(KeyCode::KEY_4), - "5" => Some(KeyCode::KEY_5), - "6" => Some(KeyCode::KEY_6), - "7" => Some(KeyCode::KEY_7), - "8" => Some(KeyCode::KEY_8), - "9" => Some(KeyCode::KEY_9), - "0" => Some(KeyCode::KEY_0), - _ => None, - } -} - -fn parse_quick_toggle_function_key(raw: &str) -> Option { - match raw { - "f1" => Some(KeyCode::KEY_F1), - "f2" => Some(KeyCode::KEY_F2), - "f3" => Some(KeyCode::KEY_F3), - "f4" => Some(KeyCode::KEY_F4), - "f5" => Some(KeyCode::KEY_F5), - "f6" => Some(KeyCode::KEY_F6), - "f7" => Some(KeyCode::KEY_F7), - "f8" => Some(KeyCode::KEY_F8), - "f9" => Some(KeyCode::KEY_F9), - "f10" => Some(KeyCode::KEY_F10), - "f11" => Some(KeyCode::KEY_F11), - "f12" => Some(KeyCode::KEY_F12), - _ => None, - } -} - -/// Reads debounce window from env, with a safety floor to avoid rapid flapping. -fn quick_toggle_debounce_from_env() -> Duration { - let millis = std::env::var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(350); - Duration::from_millis(millis.max(50)) -} - -fn pending_release_timeout_from_env() -> Duration { - let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(750); - Duration::from_millis(millis.max(100)) -} - -fn remote_failsafe_timeout_from_env() -> Duration { - if let Some(secs) = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS") - .ok() - .and_then(|raw| raw.parse::().ok()) - { - return Duration::from_secs(secs); - } - let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(0); - Duration::from_millis(millis) -} - -#[cfg(not(coverage))] -fn input_rescan_interval_from_env() -> Duration { - let millis = std::env::var("LESAVKA_INPUT_RESCAN_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(1_000); - if millis == 0 { - Duration::ZERO - } else { - Duration::from_millis(millis.max(250)) - } -} - -#[cfg(not(coverage))] -fn input_device_identity(path: &Path) -> Option { - std::fs::metadata(path) - .ok() - .map(|metadata| metadata.ino() ^ metadata.rdev()) -} - -#[cfg(not(coverage))] -fn focus_launcher_on_local_if_enabled() { - if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL") - .map(|raw| raw.trim() == "0") - .unwrap_or(false) - { - return; - } - let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL") - .unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string()); - let _ = std::fs::write( - focus_signal_path, - format!( - "{}\n", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default() - ), - ); - let title = - std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE").unwrap_or_else(|_| "Lesavka".to_string()); - let _ = std::process::Command::new("wmctrl") - .args(["-a", &title]) - .status(); -} - -#[cfg(not(coverage))] -fn launcher_routing_path_from_env(key: &str) -> Option { - std::env::var(key) - .ok() - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) -} - -fn input_device_override_from_env(key: &str) -> Option { - std::env::var(key) - .ok() - .map(|raw| raw.trim().to_string()) - .filter(|raw| !raw.is_empty() && !raw.eq_ignore_ascii_case("all")) -} - -fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>) -> bool { - selected.is_none_or(|selected| path.to_string_lossy() == selected) -} - -#[cfg(not(coverage))] -fn read_launcher_control_snapshot(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let trimmed = raw.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -#[cfg(not(coverage))] -fn parse_launcher_routing_request(raw: &str) -> Option { - match raw - .split_ascii_whitespace() - .next()? - .to_ascii_lowercase() - .as_str() - { - "remote" => Some(true), - "local" => Some(false), - _ => None, - } -} - -#[cfg(not(coverage))] -fn path_marker(path: &Path) -> u128 { - std::fs::metadata(path) - .ok() - .and_then(|meta| meta.modified().ok()) - .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} +include!("inputs/device_classification.rs"); +include!("inputs/toggle_keys.rs"); +include!("inputs/runtime_controls.rs"); #[cfg(test)] -mod tests { - use super::parse_quick_toggle_key; - use evdev::KeyCode; - - #[test] - fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() { - assert_eq!(parse_quick_toggle_key("a"), Some(KeyCode::KEY_A)); - assert_eq!(parse_quick_toggle_key("7"), Some(KeyCode::KEY_7)); - assert_eq!(parse_quick_toggle_key("f12"), Some(KeyCode::KEY_F12)); - assert_eq!(parse_quick_toggle_key("F3"), Some(KeyCode::KEY_F3)); - } - - #[test] - fn parse_quick_toggle_key_supports_navigation_and_special_aliases() { - assert_eq!(parse_quick_toggle_key("page_up"), Some(KeyCode::KEY_PAGEUP)); - assert_eq!(parse_quick_toggle_key("delete"), Some(KeyCode::KEY_DELETE)); - assert_eq!(parse_quick_toggle_key("spacebar"), Some(KeyCode::KEY_SPACE)); - assert_eq!( - parse_quick_toggle_key("print-screen"), - Some(KeyCode::KEY_SYSRQ) - ); - } - - #[test] - fn parse_quick_toggle_key_can_disable_or_fall_back() { - assert_eq!(parse_quick_toggle_key("off"), None); - assert_eq!( - parse_quick_toggle_key("totally-unknown"), - Some(KeyCode::KEY_PAUSE) - ); - } -} +#[path = "tests/inputs.rs"] +mod tests; diff --git a/client/src/input/inputs/construction_and_scan.rs b/client/src/input/inputs/construction_and_scan.rs new file mode 100644 index 0000000..2409f64 --- /dev/null +++ b/client/src/input/inputs/construction_and_scan.rs @@ -0,0 +1,275 @@ +impl InputAggregator { + pub fn new( + dev_mode: bool, + kbd_tx: Sender, + mou_tx: Sender, + paste_tx: Option>, + ) -> Self { + Self::new_with_capture_mode(dev_mode, kbd_tx, mou_tx, paste_tx, true) + } + + pub fn new_with_capture_mode( + dev_mode: bool, + kbd_tx: Sender, + mou_tx: Sender, + paste_tx: Option>, + capture_remote_boot: bool, + ) -> Self { + let quick_toggle_key = quick_toggle_key_from_env(); + #[cfg(not(coverage))] + let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL"); + #[cfg(not(coverage))] + let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE"); + #[cfg(not(coverage))] + let quick_toggle_control_path = + launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"); + #[cfg(not(coverage))] + let clipboard_control_path = + launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"); + let remote_failsafe_timeout = remote_failsafe_timeout_from_env(); + Self { + kbd_tx, + mou_tx, + dev_mode, + released: !capture_remote_boot, + magic_active: false, + pending_release: false, + pending_kill: false, + pending_keys: HashSet::new(), + last_keyboard_report: [0; 8], + paste_tx, + keyboards: Vec::new(), + mice: Vec::new(), + selected_keyboard_path: input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), + selected_mouse_path: input_device_override_from_env("LESAVKA_MOUSE_DEVICE"), + #[cfg(not(coverage))] + known_input_paths: HashMap::new(), + capture_remote_boot, + quick_toggle_key, + quick_toggle_down: false, + quick_toggle_debounce: quick_toggle_debounce_from_env(), + last_quick_toggle_at: None, + pending_release_started_at: None, + pending_release_timeout: pending_release_timeout_from_env(), + remote_failsafe_started_at: (capture_remote_boot && !remote_failsafe_timeout.is_zero()) + .then(Instant::now), + remote_failsafe_timeout, + #[cfg(not(coverage))] + last_routing_request_raw: routing_control_path + .as_deref() + .and_then(read_launcher_control_snapshot), + #[cfg(not(coverage))] + routing_control_path, + #[cfg(not(coverage))] + last_quick_toggle_request_raw: quick_toggle_control_path + .as_deref() + .and_then(read_launcher_control_snapshot), + #[cfg(not(coverage))] + quick_toggle_control_path, + #[cfg(not(coverage))] + clipboard_control_marker: clipboard_control_path + .as_deref() + .map(path_marker) + .unwrap_or_default(), + #[cfg(not(coverage))] + clipboard_control_path, + #[cfg(not(coverage))] + routing_state_path, + #[cfg(not(coverage))] + published_remote_capture: None, + remote_capture_enabled: Arc::new(AtomicBool::new(capture_remote_boot)), + } + } + + pub fn remote_capture_enabled_handle(&self) -> Arc { + Arc::clone(&self.remote_capture_enabled) + } + + #[cfg(coverage)] + pub fn init(&mut self) -> Result<()> { + let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; + for path in paths.flatten().map(|entry| entry.path()) { + if !path + .file_name() + .map(|f| f.to_string_lossy().starts_with("event")) + .unwrap_or(false) + { + continue; + } + if let Ok(dev) = Device::open(&path) { + let _ = dev.set_nonblocking(true); + match classify_device(&dev) { + DeviceKind::Keyboard => { + if !matches_selected_input_device( + &path, + self.selected_keyboard_path.as_deref(), + ) { + continue; + } + let mut aggregator = KeyboardAggregator::new( + dev, + self.dev_mode, + self.kbd_tx.clone(), + self.paste_tx.clone(), + ); + aggregator.set_send(self.capture_remote_boot); + if !self.capture_remote_boot { + aggregator.set_grab(false); + } + self.keyboards.push(aggregator); + } + DeviceKind::Mouse => { + if !matches_selected_input_device( + &path, + self.selected_mouse_path.as_deref(), + ) { + continue; + } + let mut aggregator = + MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); + aggregator.set_send(self.capture_remote_boot); + if !self.capture_remote_boot { + aggregator.set_grab(false); + } + self.mice.push(aggregator); + } + DeviceKind::Other => {} + } + } + } + Ok(()) + } + + #[cfg(not(coverage))] + pub fn init(&mut self) -> Result<()> { + let found_any = self.scan_input_devices(self.capture_remote_boot, true)?; + + if !found_any { + bail!("No suitable keyboard/mouse devices found or none grabbed."); + } + + Ok(()) + } + + #[cfg(not(coverage))] + fn scan_input_devices(&mut self, remote_active: bool, fail_grab: bool) -> Result { + let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?; + let mut found_any = false; + + for entry in paths { + let entry = entry?; + let path = entry.path(); + + if !path + .file_name() + .is_some_and(|f| f.to_string_lossy().starts_with("event")) + { + continue; + } + let identity = input_device_identity(&path).unwrap_or_default(); + if self + .known_input_paths + .get(&path) + .is_some_and(|known| *known == identity) + { + continue; + } + + let mut dev = match Device::open(&path) { + Ok(d) => d, + Err(e) => { + warn!("❌ open {}: {e}", path.display()); + continue; + } + }; + + dev.set_nonblocking(true) + .with_context(|| format!("set_non_blocking {:?}", path))?; + + match classify_device(&dev) { + DeviceKind::Keyboard => { + if !matches_selected_input_device(&path, self.selected_keyboard_path.as_deref()) + { + self.known_input_paths.insert(path, identity); + continue; + } + if remote_active { + if let Err(err) = dev.grab() { + if fail_grab { + return Err(err) + .with_context(|| format!("grabbing keyboard {path:?}")); + } + warn!("❌ grab keyboard {}: {err}", path.display()); + continue; + } + info!( + "🤏🖱️ Grabbed keyboard {:?}", + dev.name().unwrap_or("UNKNOWN") + ); + } else { + info!( + "⌨️ local-input mode; keyboard staged ungrabbed {:?}", + dev.name().unwrap_or("UNKNOWN") + ); + } + + let mut kbd_agg = KeyboardAggregator::new( + dev, + self.dev_mode, + self.kbd_tx.clone(), + self.paste_tx.clone(), + ); + kbd_agg.set_send(remote_active); + if !remote_active { + kbd_agg.set_grab(false); + } + self.known_input_paths.insert(path, identity); + self.keyboards.push(kbd_agg); + found_any = true; + } + DeviceKind::Mouse => { + if !matches_selected_input_device(&path, self.selected_mouse_path.as_deref()) { + self.known_input_paths.insert(path, identity); + continue; + } + if remote_active { + if let Err(err) = dev.grab() { + if fail_grab { + return Err(err) + .with_context(|| format!("grabbing mouse {path:?}")); + } + warn!("❌ grab mouse {}: {err}", path.display()); + continue; + } + info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN")); + } else { + info!( + "🖱️ local-input mode; mouse staged ungrabbed {:?}", + dev.name().unwrap_or("UNKNOWN") + ); + } + + let mut mouse_agg = + MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()); + mouse_agg.set_send(remote_active); + if !remote_active { + mouse_agg.set_grab(false); + } + self.known_input_paths.insert(path, identity); + self.mice.push(mouse_agg); + found_any = true; + } + DeviceKind::Other => { + debug!( + "Skipping non-kbd/mouse device: {:?}", + dev.name().unwrap_or("UNKNOWN") + ); + self.known_input_paths.insert(path, identity); + } + } + } + + Ok(found_any) + } + +} diff --git a/client/src/input/inputs/device_classification.rs b/client/src/input/inputs/device_classification.rs new file mode 100644 index 0000000..3f4a057 --- /dev/null +++ b/client/src/input/inputs/device_classification.rs @@ -0,0 +1,100 @@ +/// The classification function +#[cfg(coverage)] +fn classify_device(dev: &Device) -> DeviceKind { + let evbits = dev.supported_events(); + let keyset = dev.supported_keys(); + + if evbits.contains(EventType::KEY) + && keyset + .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) + { + if should_ignore_keyboard_device(dev) { + return DeviceKind::Other; + } + return DeviceKind::Keyboard; + } + + if evbits.contains(EventType::RELATIVE) + && let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), keyset) + && rel.contains(RelativeAxisCode::REL_X) + && rel.contains(RelativeAxisCode::REL_Y) + && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT)) + { + return DeviceKind::Mouse; + } + + if evbits.contains(EventType::ABSOLUTE) + && let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), keyset) + && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) + || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) + && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y))) + && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT)) + { + return DeviceKind::Mouse; + } + + DeviceKind::Other +} + +#[cfg(not(coverage))] +fn classify_device(dev: &Device) -> DeviceKind { + let evbits = dev.supported_events(); + + // Keyboard logic + if evbits.contains(EventType::KEY) + && let Some(keys) = dev.supported_keys() + && (keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) { + if should_ignore_keyboard_device(dev) { + return DeviceKind::Other; + } + return DeviceKind::Keyboard; + } + + // Mouse logic (relative) + if evbits.contains(EventType::RELATIVE) + && let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) { + let has_xy = + rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y); + let has_btn = keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT); + if has_xy && has_btn { + return DeviceKind::Mouse; + } + } + // Touchpad logic (absolute) + if evbits.contains(EventType::ABSOLUTE) + && let (Some(abs), Some(keys)) = (dev.supported_absolute_axes(), dev.supported_keys()) { + let has_xy = (abs.contains(AbsoluteAxisCode::ABS_X) + && abs.contains(AbsoluteAxisCode::ABS_Y)) + || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) + && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y)); + let has_btn = keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT); + if has_xy && has_btn { + return DeviceKind::Mouse; + } + } + + DeviceKind::Other +} + +/// Internal enum for device classification +#[derive(Debug, Clone, Copy)] +enum DeviceKind { + Keyboard, + Mouse, + Other, +} + +fn should_ignore_keyboard_device(dev: &Device) -> bool { + let name = dev.name().unwrap_or_default().to_ascii_lowercase(); + name.contains("lesavka") + || name.contains("automation input") + || name.contains("codex-persistent-kbd") +} + +fn update_shadow_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { + if value == 0 { + pressed_keys.remove(&code); + } else if value > 0 { + pressed_keys.insert(code); + } +} diff --git a/client/src/input/inputs/routing_state.rs b/client/src/input/inputs/routing_state.rs new file mode 100644 index 0000000..fe1f5a2 --- /dev/null +++ b/client/src/input/inputs/routing_state.rs @@ -0,0 +1,291 @@ +impl InputAggregator { + fn toggle_grab(&mut self) { + if self.pending_release || self.pending_kill { + return; + } + if self.released { + tracing::info!("🧙 magic chord - restricting devices 🪄 IMPERIUS!!! 🎮🔒"); + } else { + tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); + } + if self.released { + self.enable_remote_capture(); + #[cfg(not(coverage))] + self.publish_routing_state_if_changed(); + } else { + self.begin_local_release(); + } + } + + fn enable_remote_capture(&mut self) { + self.remote_capture_enabled.store(true, Ordering::Relaxed); + for k in &mut self.keyboards { + k.reset_state(); + k.set_send(true); + k.set_grab(true); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(true); + m.set_grab(true); + } + self.released = false; + self.pending_release = false; + self.pending_release_started_at = None; + self.pending_keys.clear(); + self.remote_failsafe_started_at = + (!self.remote_failsafe_timeout.is_zero()).then(Instant::now); + if !self.remote_failsafe_timeout.is_zero() { + info!( + "🛟 remote input failsafe armed for {} ms while the swap key path is being re-validated", + self.remote_failsafe_timeout.as_millis() + ); + } + self.last_keyboard_report = [0; 8]; + } + + fn begin_local_release(&mut self) { + if self.released && !self.pending_release { + #[cfg(not(coverage))] + self.publish_routing_state_if_changed(); + return; + } + self.remote_failsafe_started_at = None; + self.remote_capture_enabled.store(false, Ordering::Relaxed); + for k in &mut self.keyboards { + k.send_empty_report(); + k.set_send(false); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(false); + } + self.pending_release = true; + self.pending_release_started_at = Some(Instant::now()); + self.last_keyboard_report = [0; 8]; + self.capture_pending_keys(); + } + + fn finish_local_release(&mut self, focus_launcher: bool) { + for k in &mut self.keyboards { + k.set_grab(false); + k.reset_state(); + } + for m in &mut self.mice { + m.set_grab(false); + m.reset_state(); + } + self.released = true; + self.pending_release = false; + self.pending_release_started_at = None; + self.pending_keys.clear(); + self.remote_failsafe_started_at = None; + if focus_launcher { + #[cfg(not(coverage))] + focus_launcher_on_local_if_enabled(); + } + #[cfg(not(coverage))] + self.publish_routing_state_if_changed(); + } + + fn pending_release_timed_out(&self) -> bool { + (self.pending_release || self.pending_kill) + && self + .pending_release_started_at + .is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout) + } + + fn remote_failsafe_expired(&self) -> bool { + !self.released + && !self.pending_release + && !self.pending_kill + && !self.remote_failsafe_timeout.is_zero() + && self + .remote_failsafe_started_at + .is_some_and(|started_at| started_at.elapsed() >= self.remote_failsafe_timeout) + } + + fn remote_capture_active(&self) -> bool { + !self.released + && !self.pending_release + && !self.pending_kill + && self.remote_capture_enabled.load(Ordering::Relaxed) + } + + fn capture_pending_keys(&mut self) { + self.pending_keys.clear(); + for k in &self.keyboards { + for key in k.pressed_keys_snapshot() { + self.pending_keys.insert(key); + } + } + } + + fn process_keyboard_updates(&mut self) { + for index in 0..self.keyboards.len() { + let mut keyboard_shadow: HashSet = self.keyboards[index] + .pressed_keys_snapshot() + .into_iter() + .collect(); + let other_pressed: HashSet = self + .keyboards + .iter() + .enumerate() + .filter(|(other_index, _)| *other_index != index) + .flat_map(|(_, keyboard)| keyboard.pressed_keys_snapshot()) + .collect(); + let updates = { + let keyboard = &mut self.keyboards[index]; + keyboard.drain_key_updates() + }; + for update in updates { + update_shadow_pressed_keys(&mut keyboard_shadow, update.code, update.value); + if update.swallowed || !self.keyboard_capture_enabled() { + continue; + } + let report = build_keyboard_report( + other_pressed + .iter() + .copied() + .chain(keyboard_shadow.iter().copied()), + ); + if report == self.last_keyboard_report { + continue; + } + emit_live_keyboard_report(&self.kbd_tx, update.code, update.value, report); + self.last_keyboard_report = report; + } + } + } + + fn keyboard_capture_enabled(&self) -> bool { + self.keyboards + .iter() + .any(KeyboardAggregator::sending_enabled) + } + + fn quick_toggle_active(&mut self) -> bool { + self.quick_toggle_key.is_some_and(|key| { + self.keyboards + .iter_mut() + .any(|kbd| kbd.take_key_activation(key)) + }) + } + + fn observe_quick_toggle(&mut self, quick_toggle_now: bool) { + if quick_toggle_now && !self.quick_toggle_down { + let now = Instant::now(); + let debounced = self + .last_quick_toggle_at + .is_none_or(|last| now.duration_since(last) >= self.quick_toggle_debounce); + if debounced { + if let Some(key) = self.quick_toggle_key { + info!( + "🎛️ quick-toggle {:?} engaged for smooth local/remote handoff", + key + ); + } + self.toggle_grab(); + self.last_quick_toggle_at = Some(now); + } + } + self.quick_toggle_down = quick_toggle_now; + } + + #[cfg(not(coverage))] + fn poll_launcher_routing_request(&mut self) { + let Some(path) = self.routing_control_path.as_deref() else { + return; + }; + let Some(raw) = read_launcher_control_snapshot(path) else { + return; + }; + if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) { + return; + } + self.last_routing_request_raw = Some(raw.clone()); + let Some(remote_capture) = parse_launcher_routing_request(&raw) else { + return; + }; + if self.pending_kill { + return; + } + if remote_capture { + if !self.released && !self.pending_release { + return; + } + info!("🎛️ launcher requested remote input capture"); + self.enable_remote_capture(); + self.publish_routing_state_if_changed(); + } else { + if self.released && !self.pending_release { + return; + } + info!("🎛️ launcher requested local input capture"); + self.begin_local_release(); + } + } + + #[cfg(not(coverage))] + fn poll_launcher_quick_toggle_request(&mut self) { + let Some(path) = self.quick_toggle_control_path.as_deref() else { + return; + }; + let Some(raw) = read_launcher_control_snapshot(path) else { + return; + }; + if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) { + return; + } + self.last_quick_toggle_request_raw = Some(raw.clone()); + let next_key = raw + .split_ascii_whitespace() + .next() + .and_then(parse_quick_toggle_key); + self.quick_toggle_key = next_key; + self.quick_toggle_down = false; + self.last_quick_toggle_at = None; + match next_key { + Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key), + None => info!("🎛️ launcher disabled the live swap key"), + } + } + + #[cfg(not(coverage))] + fn poll_launcher_clipboard_request(&mut self) { + let Some(path) = self.clipboard_control_path.as_deref() else { + return; + }; + let marker = path_marker(path); + if marker <= self.clipboard_control_marker { + return; + } + self.clipboard_control_marker = marker; + let Some(keyboard) = self.keyboards.first_mut() else { + warn!("📋 launcher requested clipboard paste, but no keyboard is available"); + return; + }; + info!("📋 launcher requested clipboard paste on the live relay session"); + keyboard.trigger_clipboard_paste(); + } + + #[cfg(not(coverage))] + fn publish_routing_state_if_changed(&mut self) { + let remote_capture = !self.released; + if self.published_remote_capture == Some(remote_capture) { + return; + } + if let Some(path) = self.routing_state_path.as_deref() { + let _ = std::fs::write( + path, + if remote_capture { + "remote\n" + } else { + "local\n" + }, + ); + } + self.published_remote_capture = Some(remote_capture); + } + +} diff --git a/client/src/input/inputs/run_loop.rs b/client/src/input/inputs/run_loop.rs new file mode 100644 index 0000000..ce9a994 --- /dev/null +++ b/client/src/input/inputs/run_loop.rs @@ -0,0 +1,143 @@ +impl InputAggregator { + #[cfg(coverage)] + pub async fn run(&mut self) -> Result<()> { + loop { + self.process_keyboard_updates(); + let quick_toggle_now = self.quick_toggle_active(); + self.observe_quick_toggle(quick_toggle_now); + + if self.remote_failsafe_expired() { + self.begin_local_release(); + } + + if self.pending_release || self.pending_kill { + let chord_released = if self.pending_keys.is_empty() { + !self + .keyboards + .iter() + .any(|k| k.magic_grab() || k.magic_kill()) + } else { + self.pending_keys + .iter() + .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) + }; + + if chord_released { + let pending_kill = self.pending_kill; + self.finish_local_release(!pending_kill); + if pending_kill { + return Ok(()); + } + } + } + + for mouse in &mut self.mice { + mouse.process_events(); + } + + tokio::task::yield_now().await; + } + } + + #[cfg(not(coverage))] + pub async fn run(&mut self) -> Result<()> { + // Example approach: poll each aggregator in a simple loop + let mut tick = interval(Duration::from_millis(10)); + let mut current = Layout::SideBySide; + let input_rescan_interval = input_rescan_interval_from_env(); + let mut last_input_rescan_at = Instant::now(); + self.publish_routing_state_if_changed(); + loop { + let mut want_kill = false; + self.process_keyboard_updates(); + if !input_rescan_interval.is_zero() + && last_input_rescan_at.elapsed() >= input_rescan_interval + { + last_input_rescan_at = Instant::now(); + let remote_active = self.remote_capture_active(); + if let Err(err) = self.scan_input_devices(remote_active, false) { + warn!("⚠️ input device rescan failed: {err:#}"); + } + } + for kbd in &self.keyboards { + want_kill |= kbd.magic_kill(); + } + self.poll_launcher_routing_request(); + self.poll_launcher_quick_toggle_request(); + self.poll_launcher_clipboard_request(); + let quick_toggle_now = self.quick_toggle_active(); + self.observe_quick_toggle(quick_toggle_now); + let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); + let magic_left = self.keyboards.iter().any(|k| k.magic_left()); + let magic_right = self.keyboards.iter().any(|k| k.magic_right()); + + if magic_now && !self.magic_active { + self.toggle_grab(); + } + if (magic_left || magic_right) && self.magic_active { + current = match current { + Layout::SideBySide => Layout::FullLeft, + Layout::FullLeft => Layout::FullRight, + Layout::FullRight => Layout::SideBySide, + }; + apply_layout(current); + } + if want_kill && !self.pending_kill { + warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️"); + self.remote_capture_enabled.store(false, Ordering::Relaxed); + for k in &mut self.keyboards { + k.send_empty_report(); + k.set_send(false); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(false); + } + self.pending_kill = true; + self.capture_pending_keys(); + self.pending_release_started_at = Some(Instant::now()); + } + + if self.remote_failsafe_expired() { + warn!( + "🛟 remote input failsafe expired after {} ms; returning control to this machine", + self.remote_failsafe_timeout.as_millis() + ); + self.begin_local_release(); + } + + if self.pending_release || self.pending_kill { + let chord_released = if self.pending_keys.is_empty() { + !self + .keyboards + .iter() + .any(|k| k.magic_grab() || k.magic_kill()) + } else { + self.pending_keys + .iter() + .all(|key| !self.keyboards.iter().any(|k| k.has_key(*key))) + }; + let timed_out = self.pending_release_timed_out(); + if chord_released || timed_out { + if timed_out { + warn!( + "⌛ local release timed out waiting for key-up events; forcing the handoff" + ); + } + self.finish_local_release(!self.pending_kill); + if self.pending_kill { + return Ok(()); + } + } + } + + for mouse in &mut self.mice { + mouse.process_events(); + } + + self.magic_active = magic_now; + tick.tick().await; + } + } + +} diff --git a/client/src/input/inputs/runtime_controls.rs b/client/src/input/inputs/runtime_controls.rs new file mode 100644 index 0000000..3fc18c6 --- /dev/null +++ b/client/src/input/inputs/runtime_controls.rs @@ -0,0 +1,127 @@ +/// Reads debounce window from env, with a safety floor to avoid rapid flapping. +fn quick_toggle_debounce_from_env() -> Duration { + let millis = std::env::var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(350); + Duration::from_millis(millis.max(50)) +} + +fn pending_release_timeout_from_env() -> Duration { + let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(750); + Duration::from_millis(millis.max(100)) +} + +fn remote_failsafe_timeout_from_env() -> Duration { + if let Some(secs) = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS") + .ok() + .and_then(|raw| raw.parse::().ok()) + { + return Duration::from_secs(secs); + } + let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(0); + Duration::from_millis(millis) +} + +#[cfg(not(coverage))] +fn input_rescan_interval_from_env() -> Duration { + let millis = std::env::var("LESAVKA_INPUT_RESCAN_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(1_000); + if millis == 0 { + Duration::ZERO + } else { + Duration::from_millis(millis.max(250)) + } +} + +#[cfg(not(coverage))] +fn input_device_identity(path: &Path) -> Option { + std::fs::metadata(path) + .ok() + .map(|metadata| metadata.ino() ^ metadata.rdev()) +} + +#[cfg(not(coverage))] +fn focus_launcher_on_local_if_enabled() { + if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL") + .map(|raw| raw.trim() == "0") + .unwrap_or(false) + { + return; + } + let focus_signal_path = std::env::var("LESAVKA_LAUNCHER_FOCUS_SIGNAL") + .unwrap_or_else(|_| "/tmp/lesavka-launcher-focus.signal".to_string()); + let _ = std::fs::write( + focus_signal_path, + format!( + "{}\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() + ), + ); + let title = + std::env::var("LESAVKA_LAUNCHER_WINDOW_TITLE").unwrap_or_else(|_| "Lesavka".to_string()); + let _ = std::process::Command::new("wmctrl") + .args(["-a", &title]) + .status(); +} + +#[cfg(not(coverage))] +fn launcher_routing_path_from_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn input_device_override_from_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|raw| raw.trim().to_string()) + .filter(|raw| !raw.is_empty() && !raw.eq_ignore_ascii_case("all")) +} + +fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>) -> bool { + selected.is_none_or(|selected| path.to_string_lossy() == selected) +} + +#[cfg(not(coverage))] +fn read_launcher_control_snapshot(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[cfg(not(coverage))] +fn parse_launcher_routing_request(raw: &str) -> Option { + match raw + .split_ascii_whitespace() + .next()? + .to_ascii_lowercase() + .as_str() + { + "remote" => Some(true), + "local" => Some(false), + _ => None, + } +} + +#[cfg(not(coverage))] +fn path_marker(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} diff --git a/client/src/input/inputs/toggle_keys.rs b/client/src/input/inputs/toggle_keys.rs new file mode 100644 index 0000000..daf407f --- /dev/null +++ b/client/src/input/inputs/toggle_keys.rs @@ -0,0 +1,118 @@ +/// Resolves the quick-toggle key from env, defaulting to Pause/Break. +fn quick_toggle_key_from_env() -> Option { + match std::env::var("LESAVKA_INPUT_TOGGLE_KEY") { + Ok(raw) => parse_quick_toggle_key(&raw), + Err(_) => Some(KeyCode::KEY_PAUSE), + } +} + +/// Parses a launcher/operator key alias into an evdev key code. +fn parse_quick_toggle_key(raw: &str) -> Option { + let normalized = raw.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "" | "off" | "none" | "disabled") { + return None; + } + + if let Some(letter) = parse_quick_toggle_letter(&normalized) { + return Some(letter); + } + + if let Some(digit) = parse_quick_toggle_digit(&normalized) { + return Some(digit); + } + + if let Some(function) = parse_quick_toggle_function_key(&normalized) { + return Some(function); + } + + match normalized.as_str() { + "scrolllock" | "scroll_lock" | "scroll-lock" => Some(KeyCode::KEY_SCROLLLOCK), + "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { + Some(KeyCode::KEY_SYSRQ) + } + "pause" | "pausebreak" | "pause_break" | "pause-break" => Some(KeyCode::KEY_PAUSE), + "escape" | "esc" => Some(KeyCode::KEY_ESC), + "tab" => Some(KeyCode::KEY_TAB), + "capslock" | "caps_lock" | "caps-lock" => Some(KeyCode::KEY_CAPSLOCK), + "backspace" | "back_space" | "back-space" => Some(KeyCode::KEY_BACKSPACE), + "space" | "spacebar" => Some(KeyCode::KEY_SPACE), + "enter" | "return" => Some(KeyCode::KEY_ENTER), + "insert" => Some(KeyCode::KEY_INSERT), + "delete" | "del" => Some(KeyCode::KEY_DELETE), + "home" => Some(KeyCode::KEY_HOME), + "end" => Some(KeyCode::KEY_END), + "pageup" | "page_up" | "page-up" => Some(KeyCode::KEY_PAGEUP), + "pagedown" | "page_down" | "page-down" => Some(KeyCode::KEY_PAGEDOWN), + "left" => Some(KeyCode::KEY_LEFT), + "right" => Some(KeyCode::KEY_RIGHT), + "up" => Some(KeyCode::KEY_UP), + "down" => Some(KeyCode::KEY_DOWN), + _ => Some(KeyCode::KEY_PAUSE), + } +} + +fn parse_quick_toggle_letter(raw: &str) -> Option { + match raw { + "a" => Some(KeyCode::KEY_A), + "b" => Some(KeyCode::KEY_B), + "c" => Some(KeyCode::KEY_C), + "d" => Some(KeyCode::KEY_D), + "e" => Some(KeyCode::KEY_E), + "f" => Some(KeyCode::KEY_F), + "g" => Some(KeyCode::KEY_G), + "h" => Some(KeyCode::KEY_H), + "i" => Some(KeyCode::KEY_I), + "j" => Some(KeyCode::KEY_J), + "k" => Some(KeyCode::KEY_K), + "l" => Some(KeyCode::KEY_L), + "m" => Some(KeyCode::KEY_M), + "n" => Some(KeyCode::KEY_N), + "o" => Some(KeyCode::KEY_O), + "p" => Some(KeyCode::KEY_P), + "q" => Some(KeyCode::KEY_Q), + "r" => Some(KeyCode::KEY_R), + "s" => Some(KeyCode::KEY_S), + "t" => Some(KeyCode::KEY_T), + "u" => Some(KeyCode::KEY_U), + "v" => Some(KeyCode::KEY_V), + "w" => Some(KeyCode::KEY_W), + "x" => Some(KeyCode::KEY_X), + "y" => Some(KeyCode::KEY_Y), + "z" => Some(KeyCode::KEY_Z), + _ => None, + } +} + +fn parse_quick_toggle_digit(raw: &str) -> Option { + match raw { + "1" => Some(KeyCode::KEY_1), + "2" => Some(KeyCode::KEY_2), + "3" => Some(KeyCode::KEY_3), + "4" => Some(KeyCode::KEY_4), + "5" => Some(KeyCode::KEY_5), + "6" => Some(KeyCode::KEY_6), + "7" => Some(KeyCode::KEY_7), + "8" => Some(KeyCode::KEY_8), + "9" => Some(KeyCode::KEY_9), + "0" => Some(KeyCode::KEY_0), + _ => None, + } +} + +fn parse_quick_toggle_function_key(raw: &str) -> Option { + match raw { + "f1" => Some(KeyCode::KEY_F1), + "f2" => Some(KeyCode::KEY_F2), + "f3" => Some(KeyCode::KEY_F3), + "f4" => Some(KeyCode::KEY_F4), + "f5" => Some(KeyCode::KEY_F5), + "f6" => Some(KeyCode::KEY_F6), + "f7" => Some(KeyCode::KEY_F7), + "f8" => Some(KeyCode::KEY_F8), + "f9" => Some(KeyCode::KEY_F9), + "f10" => Some(KeyCode::KEY_F10), + "f11" => Some(KeyCode::KEY_F11), + "f12" => Some(KeyCode::KEY_F12), + _ => None, + } +} diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs index 5d430a6..c68c78b 100644 --- a/client/src/input/keyboard.rs +++ b/client/src/input/keyboard.rs @@ -1,705 +1,7 @@ -// client/src/input/keyboard.rs - -use evdev::{Device, EventType, InputEvent, KeyCode}; -use std::{ - collections::HashSet, - sync::atomic::{AtomicU32, AtomicU64, Ordering}, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; -use tokio::sync::broadcast::Sender; -use tokio::sync::mpsc::UnboundedSender; -use tracing::{debug, error, trace}; - -use lesavka_common::lesavka::KeyboardReport; - -use super::keymap::{is_modifier, keycode_to_usage}; -use lesavka_common::hid::append_char_reports; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeyboardEventUpdate { - pub code: KeyCode, - pub value: i32, - pub swallowed: bool, - pub report: [u8; 8], -} - -pub struct KeyboardAggregator { - dev: Device, - tx: Sender, - dev_mode: bool, - sending_disabled: bool, - paste_enabled: bool, - paste_rpc_enabled: bool, - paste_tx: Option>, - paste_chord_armed: bool, - paste_chord_consumed: bool, - pressed_keys: HashSet, - recent_key_presses: HashSet, -} - -/*───────── helpers ───────────────────────────────────────────────────*/ -/// Monotonically-increasing ID that can be logged on server & client. -static SEQ: AtomicU32 = AtomicU32::new(0); -static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0); - -fn update_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { - if value == 0 { - pressed_keys.remove(&code); - } else if value > 0 { - pressed_keys.insert(code); - } -} - -fn debounce_gate(last_paste_ms: &AtomicU64, now_ms: u64, debounce_ms: u64) -> bool { - if debounce_ms == 0 { - last_paste_ms.store(now_ms, Ordering::Relaxed); - return true; - } - let last = last_paste_ms.load(Ordering::Relaxed); - let allowed = now_ms.saturating_sub(last) >= debounce_ms; - last_paste_ms.store(now_ms, Ordering::Relaxed); - allowed -} - -impl KeyboardAggregator { - pub fn new( - dev: Device, - dev_mode: bool, - tx: Sender, - paste_tx: Option>, - ) -> Self { - let _ = dev.set_nonblocking(true); - Self { - dev, - tx, - dev_mode, - sending_disabled: false, - paste_enabled: std::env::var("LESAVKA_CLIPBOARD_PASTE") - .map(|v| v != "0") - .unwrap_or(true), - paste_rpc_enabled: paste_rpc_enabled_from_env(), - paste_tx, - paste_chord_armed: false, - paste_chord_consumed: false, - pressed_keys: HashSet::new(), - recent_key_presses: HashSet::new(), - } - } - - pub fn set_grab(&mut self, grab: bool) { - let _ = if grab { - self.dev.grab() - } else { - self.dev.ungrab() - }; - } - - pub fn set_send(&mut self, send: bool) { - self.sending_disabled = !send; - } - - pub fn send_empty_report(&self) { - self.send_report([0; 8]); - } - - pub fn process_events(&mut self) { - for update in self.drain_key_updates() { - if update.swallowed { - continue; - } - self.emit_live_report(update.code, update.value, update.report); - } - } - - fn build_report(&self) -> [u8; 8] { - build_keyboard_report(self.pressed_keys.iter().copied()) - } - - pub fn has_key(&self, kc: KeyCode) -> bool { - self.pressed_keys.contains(&kc) - } - - pub fn take_key_activation(&mut self, kc: KeyCode) -> bool { - self.has_key(kc) || self.recent_key_presses.remove(&kc) - } - - pub fn pressed_keys_snapshot(&self) -> Vec { - self.pressed_keys.iter().copied().collect() - } - - pub fn sending_enabled(&self) -> bool { - !self.sending_disabled - } - - pub fn magic_grab(&self) -> bool { - self.has_key(KeyCode::KEY_LEFTCTRL) - && self.has_key(KeyCode::KEY_LEFTSHIFT) - && self.has_key(KeyCode::KEY_G) - } - - pub fn magic_left(&self) -> bool { - self.has_key(KeyCode::KEY_LEFTCTRL) - && self.has_key(KeyCode::KEY_LEFTSHIFT) - && self.has_key(KeyCode::KEY_LEFT) - } - - pub fn magic_right(&self) -> bool { - self.has_key(KeyCode::KEY_LEFTCTRL) - && self.has_key(KeyCode::KEY_LEFTSHIFT) - && self.has_key(KeyCode::KEY_RIGHT) - } - - pub fn magic_kill(&self) -> bool { - self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC) - } - - pub fn reset_state(&mut self) { - if self.pressed_keys.is_empty() { - self.recent_key_presses.clear(); - self.send_empty_report(); - return; - } - self.pressed_keys.clear(); - self.recent_key_presses.clear(); - self.send_empty_report(); - } - - fn send_report(&self, report: [u8; 8]) { - if self.sending_disabled { - return; - } - send_keyboard_report(&self.tx, report); - } - - fn emit_live_report(&self, code: KeyCode, value: i32, report: [u8; 8]) { - if self.sending_disabled { - return; - } - emit_live_keyboard_report(&self.tx, code, value, report); - } - - pub fn drain_key_updates(&mut self) -> Vec { - self.recent_key_presses.clear(); - let events = self.fetch_events(); - if self.dev_mode && !events.is_empty() { - trace!( - "⌨️ {} kbd evts from {}", - events.len(), - self.dev.name().unwrap_or("?") - ); - } - - let mut updates = Vec::with_capacity(events.len()); - for ev in events { - if ev.event_type() != EventType::KEY { - continue; - } - let code = KeyCode::new(ev.code()); - let value = ev.value(); - update_pressed_keys(&mut self.pressed_keys, code, value); - if value == 1 { - self.recent_key_presses.insert(code); - } - - let swallowed = self.try_handle_paste_event(code, value); - let report = self.build_report(); - let id = SEQ.fetch_add(1, Ordering::Relaxed); - if self.dev_mode { - debug!(seq = id, ?report, code = ?code, value, swallowed, "kbd"); - } - updates.push(KeyboardEventUpdate { - code, - value, - swallowed, - report, - }); - } - updates - } - - #[cfg(coverage)] - fn try_handle_paste_event(&mut self, code: KeyCode, value: i32) -> bool { - if self.paste_chord_consumed { - if code == KeyCode::KEY_V && value == 0 { - self.paste_chord_consumed = false; - self.paste_chord_armed = false; - } - self.send_empty_report(); - return true; - } - - if self.paste_enabled && code == KeyCode::KEY_V && value == 1 && self.paste_chord_active() { - self.paste_chord_armed = true; - if self.paste_debounced() { - self.consume_paste_chord(); - self.paste_chord_consumed = true; - self.paste_chord_armed = false; - let _ = self.paste_rpc_enabled && self.paste_via_rpc(); - self.paste_clipboard(); - } - self.send_empty_report(); - return true; - } - - if self.paste_chord_armed && (code == KeyCode::KEY_V || is_paste_modifier(code)) { - self.send_empty_report(); - return true; - } - - false - } - - #[cfg(not(coverage))] - fn try_handle_paste_event(&mut self, code: KeyCode, value: i32) -> bool { - if !self.paste_enabled { - return false; - } - - // Once a paste chord is consumed, swallow any KEY_V repeats/releases - // until KEY_V is released to prevent leaking a literal 'v'/'V'. - if self.paste_chord_consumed { - if code == KeyCode::KEY_V { - if value == 0 { - self.paste_chord_consumed = false; - self.paste_chord_armed = false; - } - self.send_empty_report(); - return true; - } - return false; - } - - // Only intercept the complete configured Lesavka paste chord. Plain - // app shortcuts such as Ctrl+V must keep travelling to the remote HID. - if code == KeyCode::KEY_V && value == 1 && self.paste_chord_active() { - self.paste_chord_armed = true; - } - - if !self.paste_chord_armed { - return false; - } - - if self.paste_chord_active() { - if !self.paste_debounced() { - self.send_empty_report(); - return true; - } - self.consume_paste_chord(); - self.paste_chord_consumed = true; - self.paste_chord_armed = false; - if self.paste_rpc_enabled && self.paste_via_rpc() { - return true; - } - self.paste_clipboard(); - return true; - } - - // Chord armed but not complete: swallow V/modifier events so no junk reaches target. - if code == KeyCode::KEY_V || is_paste_modifier(code) { - if code == KeyCode::KEY_V && value == 0 { - // Aborted/incomplete chord (ex: Ctrl+V only): reset state. - self.paste_chord_armed = false; - } - self.send_empty_report(); - return true; - } - - false - } - - fn consume_paste_chord(&mut self) { - self.pressed_keys.remove(&KeyCode::KEY_V); - self.pressed_keys.remove(&KeyCode::KEY_LEFTCTRL); - self.pressed_keys.remove(&KeyCode::KEY_RIGHTCTRL); - self.pressed_keys.remove(&KeyCode::KEY_LEFTALT); - self.pressed_keys.remove(&KeyCode::KEY_RIGHTALT); - self.send_empty_report(); - } - - fn paste_chord_active(&self) -> bool { - let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD") - .unwrap_or_else(|_| "ctrl+alt+v".into()) - .to_ascii_lowercase(); - let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); - let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT); - if chord == "ctrl+v" { - have_ctrl - } else { - have_ctrl && have_alt - } - } - - fn paste_debounced(&self) -> bool { - let debounce_ms = std::env::var("LESAVKA_CLIPBOARD_DEBOUNCE_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(250); - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - debounce_gate(&LAST_PASTE_MS, now_ms, debounce_ms) - } - - #[cfg(coverage)] - fn paste_clipboard(&self) { - let text = read_clipboard_text().unwrap_or_default(); - let max = std::env::var("LESAVKA_CLIPBOARD_MAX") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(4096); - - for c in text.chars().take(max) { - let mut reports = Vec::with_capacity(4); - if append_char_reports(&mut reports, c) { - for report in reports { - self.send_report(report); - } - } - } - } - - #[cfg(not(coverage))] - fn paste_clipboard(&self) { - let text = match read_clipboard_text() { - Some(t) if !t.is_empty() => t, - Some(_) => { - tracing::warn!("📋 clipboard empty"); - return; - } - None => { - tracing::warn!("📋 clipboard read failed"); - return; - } - }; - let max = std::env::var("LESAVKA_CLIPBOARD_MAX") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(4096); - let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(8); - let delay = Duration::from_millis(delay_ms); - - tracing::info!( - "📋 pasting {} chars over HID with {}ms inter-report delay", - text.chars().count().min(max), - delay_ms - ); - - for c in text.chars().take(max) { - let mut reports = Vec::with_capacity(4); - if append_char_reports(&mut reports, c) { - for report in reports { - self.send_report(report); - if delay_ms > 0 { - std::thread::sleep(delay); - } - } - } - } - } - - fn paste_via_rpc(&self) -> bool { - let Some(tx) = self.paste_tx.as_ref() else { - return false; - }; - let text = match read_clipboard_text() { - Some(t) if !t.is_empty() => t, - _ => { - return true; - } - }; - tx.send(text).is_ok() - } - - pub fn trigger_clipboard_paste(&mut self) { - if !self.paste_enabled { - tracing::warn!( - "📋 launcher requested clipboard paste, but clipboard paste is disabled" - ); - return; - } - self.paste_chord_armed = false; - self.paste_chord_consumed = false; - if self.paste_rpc_enabled && self.paste_via_rpc() { - tracing::info!("📋 clipboard paste forwarded through paste RPC"); - return; - } - tracing::info!("📋 clipboard paste falling back to live HID typing"); - self.paste_clipboard(); - } -} - -#[cfg(coverage)] -impl KeyboardAggregator { - fn fetch_events(&mut self) -> Vec { - self.dev - .fetch_events() - .map(|it| it.collect::>()) - .unwrap_or_default() - } -} - -#[cfg(not(coverage))] -impl KeyboardAggregator { - fn fetch_events(&mut self) -> Vec { - match self.dev.fetch_events() { - Ok(it) => it.collect(), - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Vec::new(), - Err(e) => { - if self.dev_mode { - error!("⌨️❌ read error: {e}"); - } - Vec::new() - } - } - } -} - -pub fn build_keyboard_report(pressed_keys: I) -> [u8; 8] -where - I: IntoIterator, -{ - let mut out = [0u8; 8]; - let mut mods = 0u8; - let mut keys = Vec::new(); - - for kc in pressed_keys { - if let Some(m) = is_modifier(kc) { - mods |= m; - continue; - } - if let Some(u) = keycode_to_usage(kc) { - keys.push(u); - } - } - - keys.sort_unstable(); - keys.dedup(); - out[0] = mods; - for (i, k) in keys.into_iter().take(6).enumerate() { - out[2 + i] = k; - } - out -} - -pub fn emit_live_keyboard_report( - tx: &Sender, - code: KeyCode, - value: i32, - report: [u8; 8], -) { - if should_stage_modifier_report(code, value, report) { - send_keyboard_report(tx, modifier_only_report(report[0])); - let delay = live_modifier_delay(); - if !delay.is_zero() { - std::thread::sleep(delay); - } - } - send_keyboard_report(tx, report); -} - -pub fn send_keyboard_report(tx: &Sender, report: [u8; 8]) { - let _ = tx.send(KeyboardReport { - data: report.to_vec(), - }); -} - -fn paste_rpc_enabled_from_env() -> bool { - let rpc_enabled = std::env::var("LESAVKA_PASTE_RPC") - .map(|v| v != "0") - .unwrap_or(true); - let have_key = paste_key_available_from_env(); - let enabled = paste_rpc_enabled(rpc_enabled, have_key); - #[cfg(not(coverage))] - if rpc_enabled && !have_key { - tracing::info!("📋 paste key missing; disabling paste RPC and using HID paste fallback"); - } - enabled -} - -fn paste_rpc_enabled(rpc_enabled: bool, have_key: bool) -> bool { - rpc_enabled && have_key -} - -fn paste_key_available_from_env() -> bool { - if std::env::var("LESAVKA_PASTE_KEY") - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - { - return true; - } - if let Ok(path) = std::env::var("LESAVKA_PASTE_KEY_FILE") { - return std::path::Path::new(path.trim()).is_file(); - } - std::env::var_os("HOME").is_some_and(|home| { - let mut path = std::path::PathBuf::from(home); - path.push(".config/lesavka/paste-key"); - path.is_file() - }) -} - -fn is_paste_modifier(code: KeyCode) -> bool { - matches!( - code, - KeyCode::KEY_LEFTCTRL - | KeyCode::KEY_RIGHTCTRL - | KeyCode::KEY_LEFTALT - | KeyCode::KEY_RIGHTALT - ) -} - -fn should_stage_modifier_report(code: KeyCode, value: i32, report: [u8; 8]) -> bool { - value == 1 - && is_modifier(code).is_none() - && report[0] != 0 - && report[2..].iter().any(|b| *b != 0) -} - -fn modifier_only_report(modifiers: u8) -> [u8; 8] { - [modifiers, 0, 0, 0, 0, 0, 0, 0] -} - -fn live_modifier_delay() -> Duration { - std::env::var("LESAVKA_LIVE_MODIFIER_DELAY_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or_else(|| Duration::from_millis(24)) -} - -#[cfg(coverage)] -fn read_clipboard_text() -> Option { - if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { - if let Ok(out) = std::process::Command::new("sh") - .arg("-lc") - .arg(cmd) - .output() - { - let text = String::from_utf8_lossy(&out.stdout).to_string(); - if out.status.success() && !text.is_empty() { - return Some(text); - } - } - } - - for args in [ - vec!["--no-newline", "--type", "text/plain"], - vec!["--no-newline"], - vec![], - ] { - if let Ok(out) = std::process::Command::new("wl-paste").args(&args).output() - && out.status.success() - { - return Some(String::from_utf8_lossy(&out.stdout).to_string()); - } - } - - None -} - -#[cfg(not(coverage))] -fn read_clipboard_text() -> Option { - if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { - if let Ok(out) = std::process::Command::new("sh") - .arg("-lc") - .arg(cmd.clone()) - .output() - { - if out.status.success() { - let text = String::from_utf8_lossy(&out.stdout).to_string(); - if !text.is_empty() { - return Some(text); - } - tracing::warn!("📋 clipboard command returned empty"); - } else { - let err = String::from_utf8_lossy(&out.stderr); - tracing::warn!("📋 clipboard command failed: {cmd} ({err})"); - } - } else { - tracing::warn!("📋 clipboard command failed to spawn: {cmd}"); - } - // fall through to auto-detect if custom command fails - } - - let candidates: &[(&str, &[&str])] = &[ - ("wl-paste", &["--no-newline", "--type", "text/plain"]), - ("wl-paste", &["--no-newline"]), - ("wl-paste", &[]), - ("xclip", &["-selection", "clipboard", "-o"]), - ("xsel", &["-b", "-o"]), - ]; - - for (cmd, args) in candidates { - if let Ok(out) = std::process::Command::new(cmd).args(*args).output() { - if out.status.success() { - return Some(String::from_utf8_lossy(&out.stdout).to_string()); - } - } - } - None -} - -impl Drop for KeyboardAggregator { - fn drop(&mut self) { - let _ = self.dev.ungrab(); - let _ = self.tx.send(KeyboardReport { - data: [0; 8].into(), - }); - } -} +// Keyboard aggregation, paste shortcuts, and HID report emission. +include!("keyboard/aggregator.rs"); +include!("keyboard/reporting.rs"); #[cfg(test)] -mod tests { - use super::{is_paste_modifier, paste_key_available_from_env, paste_rpc_enabled}; - use evdev::KeyCode; - use tempfile::tempdir; - - #[test] - fn paste_rpc_disabled_when_env_off() { - assert!(!paste_rpc_enabled(false, false)); - assert!(!paste_rpc_enabled(false, true)); - } - - #[test] - fn paste_rpc_disabled_without_key() { - assert!(!paste_rpc_enabled(true, false)); - } - - #[test] - fn paste_rpc_enabled_with_key() { - assert!(paste_rpc_enabled(true, true)); - } - - #[test] - fn paste_key_detection_accepts_explicit_key_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("paste-key"); - std::fs::write( - &path, - "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - ) - .expect("write key file"); - temp_env::with_vars( - [ - ("LESAVKA_PASTE_KEY", None::<&str>), - ("LESAVKA_PASTE_KEY_FILE", path.to_str()), - ], - || { - assert!(paste_key_available_from_env()); - }, - ); - } - - #[test] - fn paste_modifier_recognizes_ctrl_alt_only() { - assert!(is_paste_modifier(KeyCode::KEY_LEFTCTRL)); - assert!(is_paste_modifier(KeyCode::KEY_RIGHTCTRL)); - assert!(is_paste_modifier(KeyCode::KEY_LEFTALT)); - assert!(is_paste_modifier(KeyCode::KEY_RIGHTALT)); - assert!(!is_paste_modifier(KeyCode::KEY_V)); - assert!(!is_paste_modifier(KeyCode::KEY_LEFTSHIFT)); - } -} +#[path = "tests/keyboard.rs"] +mod tests; diff --git a/client/src/input/keyboard/aggregator.rs b/client/src/input/keyboard/aggregator.rs new file mode 100644 index 0000000..f485c02 --- /dev/null +++ b/client/src/input/keyboard/aggregator.rs @@ -0,0 +1,433 @@ +// client/src/input/keyboard.rs + +use evdev::{Device, EventType, InputEvent, KeyCode}; +use std::{ + collections::HashSet, + sync::atomic::{AtomicU32, AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::broadcast::Sender; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{debug, error, trace}; + +use lesavka_common::lesavka::KeyboardReport; + +use super::keymap::{is_modifier, keycode_to_usage}; +use lesavka_common::hid::append_char_reports; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyboardEventUpdate { + pub code: KeyCode, + pub value: i32, + pub swallowed: bool, + pub report: [u8; 8], +} + +pub struct KeyboardAggregator { + dev: Device, + tx: Sender, + dev_mode: bool, + sending_disabled: bool, + paste_enabled: bool, + paste_rpc_enabled: bool, + paste_tx: Option>, + paste_chord_armed: bool, + paste_chord_consumed: bool, + pressed_keys: HashSet, + recent_key_presses: HashSet, +} + +/*───────── helpers ───────────────────────────────────────────────────*/ +/// Monotonically-increasing ID that can be logged on server & client. +static SEQ: AtomicU32 = AtomicU32::new(0); +static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0); + +fn update_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { + if value == 0 { + pressed_keys.remove(&code); + } else if value > 0 { + pressed_keys.insert(code); + } +} + +fn debounce_gate(last_paste_ms: &AtomicU64, now_ms: u64, debounce_ms: u64) -> bool { + if debounce_ms == 0 { + last_paste_ms.store(now_ms, Ordering::Relaxed); + return true; + } + let last = last_paste_ms.load(Ordering::Relaxed); + let allowed = now_ms.saturating_sub(last) >= debounce_ms; + last_paste_ms.store(now_ms, Ordering::Relaxed); + allowed +} + +impl KeyboardAggregator { + pub fn new( + dev: Device, + dev_mode: bool, + tx: Sender, + paste_tx: Option>, + ) -> Self { + let _ = dev.set_nonblocking(true); + Self { + dev, + tx, + dev_mode, + sending_disabled: false, + paste_enabled: std::env::var("LESAVKA_CLIPBOARD_PASTE") + .map(|v| v != "0") + .unwrap_or(true), + paste_rpc_enabled: paste_rpc_enabled_from_env(), + paste_tx, + paste_chord_armed: false, + paste_chord_consumed: false, + pressed_keys: HashSet::new(), + recent_key_presses: HashSet::new(), + } + } + + pub fn set_grab(&mut self, grab: bool) { + let _ = if grab { + self.dev.grab() + } else { + self.dev.ungrab() + }; + } + + pub fn set_send(&mut self, send: bool) { + self.sending_disabled = !send; + } + + pub fn send_empty_report(&self) { + self.send_report([0; 8]); + } + + pub fn process_events(&mut self) { + for update in self.drain_key_updates() { + if update.swallowed { + continue; + } + self.emit_live_report(update.code, update.value, update.report); + } + } + + fn build_report(&self) -> [u8; 8] { + build_keyboard_report(self.pressed_keys.iter().copied()) + } + + pub fn has_key(&self, kc: KeyCode) -> bool { + self.pressed_keys.contains(&kc) + } + + pub fn take_key_activation(&mut self, kc: KeyCode) -> bool { + self.has_key(kc) || self.recent_key_presses.remove(&kc) + } + + pub fn pressed_keys_snapshot(&self) -> Vec { + self.pressed_keys.iter().copied().collect() + } + + pub fn sending_enabled(&self) -> bool { + !self.sending_disabled + } + + pub fn magic_grab(&self) -> bool { + self.has_key(KeyCode::KEY_LEFTCTRL) + && self.has_key(KeyCode::KEY_LEFTSHIFT) + && self.has_key(KeyCode::KEY_G) + } + + pub fn magic_left(&self) -> bool { + self.has_key(KeyCode::KEY_LEFTCTRL) + && self.has_key(KeyCode::KEY_LEFTSHIFT) + && self.has_key(KeyCode::KEY_LEFT) + } + + pub fn magic_right(&self) -> bool { + self.has_key(KeyCode::KEY_LEFTCTRL) + && self.has_key(KeyCode::KEY_LEFTSHIFT) + && self.has_key(KeyCode::KEY_RIGHT) + } + + pub fn magic_kill(&self) -> bool { + self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC) + } + + pub fn reset_state(&mut self) { + if self.pressed_keys.is_empty() { + self.recent_key_presses.clear(); + self.send_empty_report(); + return; + } + self.pressed_keys.clear(); + self.recent_key_presses.clear(); + self.send_empty_report(); + } + + fn send_report(&self, report: [u8; 8]) { + if self.sending_disabled { + return; + } + send_keyboard_report(&self.tx, report); + } + + fn emit_live_report(&self, code: KeyCode, value: i32, report: [u8; 8]) { + if self.sending_disabled { + return; + } + emit_live_keyboard_report(&self.tx, code, value, report); + } + + pub fn drain_key_updates(&mut self) -> Vec { + self.recent_key_presses.clear(); + let events = self.fetch_events(); + if self.dev_mode && !events.is_empty() { + trace!( + "⌨️ {} kbd evts from {}", + events.len(), + self.dev.name().unwrap_or("?") + ); + } + + let mut updates = Vec::with_capacity(events.len()); + for ev in events { + if ev.event_type() != EventType::KEY { + continue; + } + let code = KeyCode::new(ev.code()); + let value = ev.value(); + update_pressed_keys(&mut self.pressed_keys, code, value); + if value == 1 { + self.recent_key_presses.insert(code); + } + + let swallowed = self.try_handle_paste_event(code, value); + let report = self.build_report(); + let id = SEQ.fetch_add(1, Ordering::Relaxed); + if self.dev_mode { + debug!(seq = id, ?report, code = ?code, value, swallowed, "kbd"); + } + updates.push(KeyboardEventUpdate { + code, + value, + swallowed, + report, + }); + } + updates + } + + #[cfg(coverage)] + fn try_handle_paste_event(&mut self, code: KeyCode, value: i32) -> bool { + if self.paste_chord_consumed { + if code == KeyCode::KEY_V && value == 0 { + self.paste_chord_consumed = false; + self.paste_chord_armed = false; + } + self.send_empty_report(); + return true; + } + + if self.paste_enabled && code == KeyCode::KEY_V && value == 1 && self.paste_chord_active() { + self.paste_chord_armed = true; + if self.paste_debounced() { + self.consume_paste_chord(); + self.paste_chord_consumed = true; + self.paste_chord_armed = false; + let _ = self.paste_rpc_enabled && self.paste_via_rpc(); + self.paste_clipboard(); + } + self.send_empty_report(); + return true; + } + + if self.paste_chord_armed && (code == KeyCode::KEY_V || is_paste_modifier(code)) { + self.send_empty_report(); + return true; + } + + false + } + + #[cfg(not(coverage))] + fn try_handle_paste_event(&mut self, code: KeyCode, value: i32) -> bool { + if !self.paste_enabled { + return false; + } + + // Once a paste chord is consumed, swallow any KEY_V repeats/releases + // until KEY_V is released to prevent leaking a literal 'v'/'V'. + if self.paste_chord_consumed { + if code == KeyCode::KEY_V { + if value == 0 { + self.paste_chord_consumed = false; + self.paste_chord_armed = false; + } + self.send_empty_report(); + return true; + } + return false; + } + + // Only intercept the complete configured Lesavka paste chord. Plain + // app shortcuts such as Ctrl+V must keep travelling to the remote HID. + if code == KeyCode::KEY_V && value == 1 && self.paste_chord_active() { + self.paste_chord_armed = true; + } + + if !self.paste_chord_armed { + return false; + } + + if self.paste_chord_active() { + if !self.paste_debounced() { + self.send_empty_report(); + return true; + } + self.consume_paste_chord(); + self.paste_chord_consumed = true; + self.paste_chord_armed = false; + if self.paste_rpc_enabled && self.paste_via_rpc() { + return true; + } + self.paste_clipboard(); + return true; + } + + // Chord armed but not complete: swallow V/modifier events so no junk reaches target. + if code == KeyCode::KEY_V || is_paste_modifier(code) { + if code == KeyCode::KEY_V && value == 0 { + // Aborted/incomplete chord (ex: Ctrl+V only): reset state. + self.paste_chord_armed = false; + } + self.send_empty_report(); + return true; + } + + false + } + + fn consume_paste_chord(&mut self) { + self.pressed_keys.remove(&KeyCode::KEY_V); + self.pressed_keys.remove(&KeyCode::KEY_LEFTCTRL); + self.pressed_keys.remove(&KeyCode::KEY_RIGHTCTRL); + self.pressed_keys.remove(&KeyCode::KEY_LEFTALT); + self.pressed_keys.remove(&KeyCode::KEY_RIGHTALT); + self.send_empty_report(); + } + + fn paste_chord_active(&self) -> bool { + let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD") + .unwrap_or_else(|_| "ctrl+alt+v".into()) + .to_ascii_lowercase(); + let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); + let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT); + if chord == "ctrl+v" { + have_ctrl + } else { + have_ctrl && have_alt + } + } + + fn paste_debounced(&self) -> bool { + let debounce_ms = std::env::var("LESAVKA_CLIPBOARD_DEBOUNCE_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(250); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + debounce_gate(&LAST_PASTE_MS, now_ms, debounce_ms) + } + + #[cfg(coverage)] + fn paste_clipboard(&self) { + let text = read_clipboard_text().unwrap_or_default(); + let max = std::env::var("LESAVKA_CLIPBOARD_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4096); + + for c in text.chars().take(max) { + let mut reports = Vec::with_capacity(4); + if append_char_reports(&mut reports, c) { + for report in reports { + self.send_report(report); + } + } + } + } + + #[cfg(not(coverage))] + fn paste_clipboard(&self) { + let text = match read_clipboard_text() { + Some(t) if !t.is_empty() => t, + Some(_) => { + tracing::warn!("📋 clipboard empty"); + return; + } + None => { + tracing::warn!("📋 clipboard read failed"); + return; + } + }; + let max = std::env::var("LESAVKA_CLIPBOARD_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(4096); + let delay_ms = std::env::var("LESAVKA_CLIPBOARD_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(8); + let delay = Duration::from_millis(delay_ms); + + tracing::info!( + "📋 pasting {} chars over HID with {}ms inter-report delay", + text.chars().count().min(max), + delay_ms + ); + + for c in text.chars().take(max) { + let mut reports = Vec::with_capacity(4); + if append_char_reports(&mut reports, c) { + for report in reports { + self.send_report(report); + if delay_ms > 0 { + std::thread::sleep(delay); + } + } + } + } + } + + fn paste_via_rpc(&self) -> bool { + let Some(tx) = self.paste_tx.as_ref() else { + return false; + }; + let text = match read_clipboard_text() { + Some(t) if !t.is_empty() => t, + _ => { + return true; + } + }; + tx.send(text).is_ok() + } + + pub fn trigger_clipboard_paste(&mut self) { + if !self.paste_enabled { + tracing::warn!( + "📋 launcher requested clipboard paste, but clipboard paste is disabled" + ); + return; + } + self.paste_chord_armed = false; + self.paste_chord_consumed = false; + if self.paste_rpc_enabled && self.paste_via_rpc() { + tracing::info!("📋 clipboard paste forwarded through paste RPC"); + return; + } + tracing::info!("📋 clipboard paste falling back to live HID typing"); + self.paste_clipboard(); + } +} diff --git a/client/src/input/keyboard/reporting.rs b/client/src/input/keyboard/reporting.rs new file mode 100644 index 0000000..62ef16e --- /dev/null +++ b/client/src/input/keyboard/reporting.rs @@ -0,0 +1,217 @@ +#[cfg(coverage)] +impl KeyboardAggregator { + fn fetch_events(&mut self) -> Vec { + self.dev + .fetch_events() + .map(|it| it.collect::>()) + .unwrap_or_default() + } +} + +#[cfg(not(coverage))] +impl KeyboardAggregator { + fn fetch_events(&mut self) -> Vec { + match self.dev.fetch_events() { + Ok(it) => it.collect(), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Vec::new(), + Err(e) => { + if self.dev_mode { + error!("⌨️❌ read error: {e}"); + } + Vec::new() + } + } + } +} + +pub fn build_keyboard_report(pressed_keys: I) -> [u8; 8] +where + I: IntoIterator, +{ + let mut out = [0u8; 8]; + let mut mods = 0u8; + let mut keys = Vec::new(); + + for kc in pressed_keys { + if let Some(m) = is_modifier(kc) { + mods |= m; + continue; + } + if let Some(u) = keycode_to_usage(kc) { + keys.push(u); + } + } + + keys.sort_unstable(); + keys.dedup(); + out[0] = mods; + for (i, k) in keys.into_iter().take(6).enumerate() { + out[2 + i] = k; + } + out +} + +pub fn emit_live_keyboard_report( + tx: &Sender, + code: KeyCode, + value: i32, + report: [u8; 8], +) { + if should_stage_modifier_report(code, value, report) { + send_keyboard_report(tx, modifier_only_report(report[0])); + let delay = live_modifier_delay(); + if !delay.is_zero() { + std::thread::sleep(delay); + } + } + send_keyboard_report(tx, report); +} + +pub fn send_keyboard_report(tx: &Sender, report: [u8; 8]) { + let _ = tx.send(KeyboardReport { + data: report.to_vec(), + }); +} + +fn paste_rpc_enabled_from_env() -> bool { + let rpc_enabled = std::env::var("LESAVKA_PASTE_RPC") + .map(|v| v != "0") + .unwrap_or(true); + let have_key = paste_key_available_from_env(); + let enabled = paste_rpc_enabled(rpc_enabled, have_key); + #[cfg(not(coverage))] + if rpc_enabled && !have_key { + tracing::info!("📋 paste key missing; disabling paste RPC and using HID paste fallback"); + } + enabled +} + +fn paste_rpc_enabled(rpc_enabled: bool, have_key: bool) -> bool { + rpc_enabled && have_key +} + +fn paste_key_available_from_env() -> bool { + if std::env::var("LESAVKA_PASTE_KEY") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return true; + } + if let Ok(path) = std::env::var("LESAVKA_PASTE_KEY_FILE") { + return std::path::Path::new(path.trim()).is_file(); + } + std::env::var_os("HOME").is_some_and(|home| { + let mut path = std::path::PathBuf::from(home); + path.push(".config/lesavka/paste-key"); + path.is_file() + }) +} + +fn is_paste_modifier(code: KeyCode) -> bool { + matches!( + code, + KeyCode::KEY_LEFTCTRL + | KeyCode::KEY_RIGHTCTRL + | KeyCode::KEY_LEFTALT + | KeyCode::KEY_RIGHTALT + ) +} + +fn should_stage_modifier_report(code: KeyCode, value: i32, report: [u8; 8]) -> bool { + value == 1 + && is_modifier(code).is_none() + && report[0] != 0 + && report[2..].iter().any(|b| *b != 0) +} + +fn modifier_only_report(modifiers: u8) -> [u8; 8] { + [modifiers, 0, 0, 0, 0, 0, 0, 0] +} + +fn live_modifier_delay() -> Duration { + std::env::var("LESAVKA_LIVE_MODIFIER_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(24)) +} + +#[cfg(coverage)] +fn read_clipboard_text() -> Option { + if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg(cmd) + .output() + { + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if out.status.success() && !text.is_empty() { + return Some(text); + } + } + } + + for args in [ + vec!["--no-newline", "--type", "text/plain"], + vec!["--no-newline"], + vec![], + ] { + if let Ok(out) = std::process::Command::new("wl-paste").args(&args).output() + && out.status.success() + { + return Some(String::from_utf8_lossy(&out.stdout).to_string()); + } + } + + None +} + +#[cfg(not(coverage))] +fn read_clipboard_text() -> Option { + if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg(cmd.clone()) + .output() + { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if !text.is_empty() { + return Some(text); + } + tracing::warn!("📋 clipboard command returned empty"); + } else { + let err = String::from_utf8_lossy(&out.stderr); + tracing::warn!("📋 clipboard command failed: {cmd} ({err})"); + } + } else { + tracing::warn!("📋 clipboard command failed to spawn: {cmd}"); + } + // fall through to auto-detect if custom command fails + } + + let candidates: &[(&str, &[&str])] = &[ + ("wl-paste", &["--no-newline", "--type", "text/plain"]), + ("wl-paste", &["--no-newline"]), + ("wl-paste", &[]), + ("xclip", &["-selection", "clipboard", "-o"]), + ("xsel", &["-b", "-o"]), + ]; + + for (cmd, args) in candidates { + if let Ok(out) = std::process::Command::new(cmd).args(*args).output() + && out.status.success() { + return Some(String::from_utf8_lossy(&out.stdout).to_string()); + } + } + None +} + +impl Drop for KeyboardAggregator { + fn drop(&mut self) { + let _ = self.dev.ungrab(); + let _ = self.tx.send(KeyboardReport { + data: [0; 8].into(), + }); + } +} diff --git a/client/src/input/microphone.rs b/client/src/input/microphone.rs index 179c4d4..7141092 100644 --- a/client/src/input/microphone.rs +++ b/client/src/input/microphone.rs @@ -127,7 +127,7 @@ impl MicrophoneCapture { { static CNT: AtomicU64 = AtomicU64::new(0); let n = CNT.fetch_add(1, Ordering::Relaxed); - if n < 10 || n % 300 == 0 { + if n < 10 || n.is_multiple_of(300) { trace!("🎤⇧ cli pkt#{n} {} bytes", map.len()); } } diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index d73b1bf..17ebd57 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -284,7 +284,7 @@ impl MouseAggregator { let mut range: Option = None; if let Ok(iter) = dev.get_absinfo() { for (code, info) in iter { - if codes.iter().any(|c| *c == code) { + if codes.contains(&code) { range = Some(info.maximum() - info.minimum()); break; } diff --git a/client/src/input/tests/inputs.rs b/client/src/input/tests/inputs.rs new file mode 100644 index 0000000..2a636e3 --- /dev/null +++ b/client/src/input/tests/inputs.rs @@ -0,0 +1,30 @@ +use super::parse_quick_toggle_key; +use evdev::KeyCode; + +#[test] +fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() { + assert_eq!(parse_quick_toggle_key("a"), Some(KeyCode::KEY_A)); + assert_eq!(parse_quick_toggle_key("7"), Some(KeyCode::KEY_7)); + assert_eq!(parse_quick_toggle_key("f12"), Some(KeyCode::KEY_F12)); + assert_eq!(parse_quick_toggle_key("F3"), Some(KeyCode::KEY_F3)); +} + +#[test] +fn parse_quick_toggle_key_supports_navigation_and_special_aliases() { + assert_eq!(parse_quick_toggle_key("page_up"), Some(KeyCode::KEY_PAGEUP)); + assert_eq!(parse_quick_toggle_key("delete"), Some(KeyCode::KEY_DELETE)); + assert_eq!(parse_quick_toggle_key("spacebar"), Some(KeyCode::KEY_SPACE)); + assert_eq!( + parse_quick_toggle_key("print-screen"), + Some(KeyCode::KEY_SYSRQ) + ); +} + +#[test] +fn parse_quick_toggle_key_can_disable_or_fall_back() { + assert_eq!(parse_quick_toggle_key("off"), None); + assert_eq!( + parse_quick_toggle_key("totally-unknown"), + Some(KeyCode::KEY_PAUSE) + ); +} diff --git a/client/src/input/tests/keyboard.rs b/client/src/input/tests/keyboard.rs new file mode 100644 index 0000000..5d8fd54 --- /dev/null +++ b/client/src/input/tests/keyboard.rs @@ -0,0 +1,49 @@ +use super::{is_paste_modifier, paste_key_available_from_env, paste_rpc_enabled}; +use evdev::KeyCode; +use tempfile::tempdir; + +#[test] +fn paste_rpc_disabled_when_env_off() { + assert!(!paste_rpc_enabled(false, false)); + assert!(!paste_rpc_enabled(false, true)); +} + +#[test] +fn paste_rpc_disabled_without_key() { + assert!(!paste_rpc_enabled(true, false)); +} + +#[test] +fn paste_rpc_enabled_with_key() { + assert!(paste_rpc_enabled(true, true)); +} + +#[test] +fn paste_key_detection_accepts_explicit_key_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("paste-key"); + std::fs::write( + &path, + "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ) + .expect("write key file"); + temp_env::with_vars( + [ + ("LESAVKA_PASTE_KEY", None::<&str>), + ("LESAVKA_PASTE_KEY_FILE", path.to_str()), + ], + || { + assert!(paste_key_available_from_env()); + }, + ); +} + +#[test] +fn paste_modifier_recognizes_ctrl_alt_only() { + assert!(is_paste_modifier(KeyCode::KEY_LEFTCTRL)); + assert!(is_paste_modifier(KeyCode::KEY_RIGHTCTRL)); + assert!(is_paste_modifier(KeyCode::KEY_LEFTALT)); + assert!(is_paste_modifier(KeyCode::KEY_RIGHTALT)); + assert!(!is_paste_modifier(KeyCode::KEY_V)); + assert!(!is_paste_modifier(KeyCode::KEY_LEFTSHIFT)); +} diff --git a/client/src/launcher/clipboard.rs b/client/src/launcher/clipboard.rs index 3551e4f..5892a42 100644 --- a/client/src/launcher/clipboard.rs +++ b/client/src/launcher/clipboard.rs @@ -15,9 +15,9 @@ use { /// Deliver already-captured clipboard text to the remote side, preferring the /// encrypted paste RPC and falling back to direct HID keyboard reports. pub fn send_clipboard_text_to_remote(server_addr: &str, text: &str) -> Result { - match send_clipboard_via_rpc(server_addr, &text) { + match send_clipboard_via_rpc(server_addr, text) { Ok(()) => Ok("Clipboard delivered to remote".to_string()), - Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) { + Err(rpc_err) => match send_clipboard_via_hid(server_addr, text) { Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")), Err(hid_err) => Err(anyhow!( "rpc failed: {rpc_err}; hid fallback failed: {hid_err}" diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index d6fa154..6a762a9 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -1,1270 +1,8 @@ -use anyhow::{Context, Result, anyhow}; -use gst::prelude::*; -use gstreamer as gst; -use gstreamer_app as gst_app; -use gtk::{gdk, glib}; -use shell_escape::escape; -use std::borrow::Cow; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use super::devices::CameraMode; - -const CAMERA_PREVIEW_DEFAULT_WIDTH: i32 = 1280; -const CAMERA_PREVIEW_DEFAULT_HEIGHT: i32 = 720; -const CAMERA_PREVIEW_DEFAULT_FPS: u32 = 30; -const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; -const MIC_MONITOR_RATE: i32 = 16_000; -const MIC_MONITOR_CHANNELS: i32 = 1; -const MIC_MONITOR_SAMPLE_BYTES: usize = 2; -const MIC_REPLAY_SECONDS: usize = 3; -const MIC_REPLAY_PATH: &str = "/tmp/lesavka-mic-replay.wav"; -const MIC_REPLAY_MAX_BYTES: usize = MIC_MONITOR_RATE as usize - * MIC_MONITOR_CHANNELS as usize - * MIC_MONITOR_SAMPLE_BYTES - * MIC_REPLAY_SECONDS; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeviceTestKind { - Camera, - Microphone, - MicrophoneReplay, - Speaker, -} - -pub struct DeviceTestController { - camera: Option, - selected_camera: Option, - selected_camera_mode: Option, - microphone: Option, - microphone_probe: Option, - speaker: Option, - microphone_replay: Option, - microphone_buffer: Arc>>, - microphone_level: Arc>, -} - -impl Default for DeviceTestController { - fn default() -> Self { - Self { - camera: None, - selected_camera: None, - selected_camera_mode: None, - microphone: None, - microphone_probe: None, - speaker: None, - microphone_replay: None, - microphone_buffer: Arc::new(Mutex::new(Vec::new())), - microphone_level: Arc::new(Mutex::new(0.0)), - } - } -} - -impl DeviceTestController { - pub fn new() -> Self { - Self::default() - } - - pub fn bind_camera_preview( - &mut self, - camera_picture: >k::Picture, - camera_status: >k::Label, - ) -> Result<()> { - if let Some(camera) = self.camera.as_mut() { - camera.stop(); - } - let mut preview = LocalCameraPreview::new(camera_picture, camera_status); - preview.set_selected(self.selected_camera.as_deref())?; - preview.set_selected_mode(self.selected_camera_mode)?; - self.camera = Some(preview); - Ok(()) - } - - pub fn is_running(&mut self, kind: DeviceTestKind) -> bool { - self.cleanup_finished(); - match kind { - DeviceTestKind::Camera => self - .camera - .as_ref() - .is_some_and(LocalCameraPreview::is_running), - DeviceTestKind::Microphone => { - self.microphone - .as_ref() - .is_some_and(LocalMicrophoneMonitor::is_running) - || self - .microphone_probe - .as_ref() - .is_some_and(LocalMicrophoneLevelProbe::is_running) - } - DeviceTestKind::MicrophoneReplay => self.microphone_replay.is_some(), - DeviceTestKind::Speaker => self.speaker.is_some(), - } - } - - pub fn set_camera_selection(&mut self, camera: Option<&str>) -> Result<()> { - self.selected_camera = normalize_camera_selection(camera); - if let Some(preview) = self.camera.as_mut() { - preview.set_selected(self.selected_camera.as_deref())?; - } - Ok(()) - } - - pub fn set_camera_quality(&mut self, mode: Option) -> Result<()> { - self.selected_camera_mode = mode; - if let Some(preview) = self.camera.as_mut() { - preview.set_selected_mode(mode)?; - } - Ok(()) - } - - pub fn toggle_camera(&mut self) -> Result { - let preview = self - .camera - .as_mut() - .ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?; - preview.toggle() - } - - pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result { - self.cleanup_finished(); - if self.microphone.is_some() { - self.stop(DeviceTestKind::Microphone); - return Ok(false); - } - - let monitor = LocalMicrophoneMonitor::start( - source, - sink, - Arc::clone(&self.microphone_buffer), - Arc::clone(&self.microphone_level), - )?; - self.microphone = Some(monitor); - Ok(true) - } - - pub fn stop_local_capture_for_relay(&mut self) { - if self - .camera - .as_ref() - .is_some_and(LocalCameraPreview::is_device_preview_running) - && let Some(camera) = self.camera.as_mut() - { - camera.stop(); - } - if let Some(mut monitor) = self.microphone.take() { - monitor.stop(); - } - } - - pub fn sync_relay_uplink_probe( - &mut self, - relay_live: bool, - camera_active: bool, - camera_label: Option<&str>, - camera_preview_path: &Path, - microphone_active: bool, - microphone_level_path: &Path, - ) -> Result<()> { - self.cleanup_finished(); - let camera_should_probe = relay_live && camera_active && camera_label.is_some(); - if camera_should_probe { - let preview = self - .camera - .as_mut() - .ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?; - preview.start_relay_file( - camera_preview_path.to_path_buf(), - camera_label.unwrap_or("selected webcam").to_string(), - )?; - } else if self - .camera - .as_ref() - .is_some_and(LocalCameraPreview::is_relay_file_running) - && let Some(preview) = self.camera.as_mut() - { - preview.stop(); - } - - let microphone_should_probe = relay_live && microphone_active; - if microphone_should_probe { - if self.microphone.is_some() { - self.stop(DeviceTestKind::Microphone); - } - let needs_probe = self - .microphone_probe - .as_ref() - .is_none_or(|probe| !probe.is_running_for(microphone_level_path)); - if needs_probe { - self.stop_microphone_probe(); - self.microphone_probe = Some(LocalMicrophoneLevelProbe::start( - microphone_level_path.to_path_buf(), - Arc::clone(&self.microphone_level), - )); - } - } else { - self.stop_microphone_probe(); - } - Ok(()) - } - - pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result { - self.toggle_child(DeviceTestKind::Speaker, build_speaker_test(sink)) - } - - pub fn toggle_microphone_replay(&mut self, sink: Option<&str>) -> Result { - self.cleanup_finished(); - if self.microphone_replay.is_some() { - self.stop(DeviceTestKind::MicrophoneReplay); - return Ok(false); - } - - let wav_bytes = self.replay_wav_bytes()?; - fs::write(MIC_REPLAY_PATH, wav_bytes).context("writing microphone replay clip")?; - let child = build_microphone_replay_test(MIC_REPLAY_PATH, sink)? - .spawn() - .context("starting microphone replay")?; - self.microphone_replay = Some(child); - Ok(true) - } - - pub fn microphone_level_fraction(&mut self) -> f64 { - self.cleanup_finished(); - self.microphone_level - .lock() - .map(|value| (*value).clamp(0.0, 1.0)) - .unwrap_or(0.0) - } - - pub fn microphone_replay_ready(&mut self) -> bool { - self.cleanup_finished(); - self.microphone_buffer - .lock() - .map(|buffer| !buffer.is_empty()) - .unwrap_or(false) - } - - pub fn stop_all(&mut self) { - if let Some(camera) = self.camera.as_mut() { - camera.stop(); - } - for kind in [ - DeviceTestKind::Microphone, - DeviceTestKind::MicrophoneReplay, - DeviceTestKind::Speaker, - ] { - self.stop(kind); - } - self.stop_microphone_probe(); - } - - fn toggle_child(&mut self, kind: DeviceTestKind, command: Result) -> Result { - self.cleanup_finished(); - if self.slot(kind).is_some() { - self.stop(kind); - return Ok(false); - } - let child = command? - .spawn() - .with_context(|| format!("starting {kind:?} test"))?; - *self.slot_mut(kind) = Some(child); - Ok(true) - } - - fn stop(&mut self, kind: DeviceTestKind) { - match kind { - DeviceTestKind::Camera => panic!("camera preview is not stopped through this path"), - DeviceTestKind::Microphone => { - if let Some(mut monitor) = self.microphone.take() { - monitor.stop(); - } - self.stop_microphone_probe(); - if let Ok(mut level) = self.microphone_level.lock() { - *level = 0.0; - } - } - DeviceTestKind::MicrophoneReplay | DeviceTestKind::Speaker => { - if let Some(mut child) = self.slot_mut(kind).take() { - let _ = child.kill(); - let _ = child.wait(); - } - } - } - } - - fn cleanup_finished(&mut self) { - if self - .microphone - .as_mut() - .is_some_and(|monitor| !monitor.is_running()) - { - self.microphone = None; - } - if self - .microphone_probe - .as_mut() - .is_some_and(|probe| !probe.is_running()) - { - self.microphone_probe = None; - } - for kind in [DeviceTestKind::MicrophoneReplay, DeviceTestKind::Speaker] { - let finished = self - .slot_mut(kind) - .as_mut() - .and_then(|child| child.try_wait().ok()) - .flatten() - .is_some(); - if finished { - let _ = self.slot_mut(kind).take(); - } - } - } - - fn slot(&self, kind: DeviceTestKind) -> &Option { - match kind { - DeviceTestKind::Camera | DeviceTestKind::Microphone => { - panic!("this device test is not an external child process") - } - DeviceTestKind::MicrophoneReplay => &self.microphone_replay, - DeviceTestKind::Speaker => &self.speaker, - } - } - - fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option { - match kind { - DeviceTestKind::Camera | DeviceTestKind::Microphone => { - panic!("this device test is not an external child process") - } - DeviceTestKind::MicrophoneReplay => &mut self.microphone_replay, - DeviceTestKind::Speaker => &mut self.speaker, - } - } - - fn stop_microphone_probe(&mut self) { - if let Some(mut probe) = self.microphone_probe.take() { - probe.stop(); - } - } - - fn replay_wav_bytes(&self) -> Result> { - let audio = self - .microphone_buffer - .lock() - .map_err(|_| anyhow!("microphone replay buffer is unavailable right now"))? - .clone(); - if audio.is_empty() { - return Err(anyhow!( - "Monitor Mic long enough to capture audio before replaying the last 3 seconds." - )); - } - Ok(build_wav_bytes( - &audio, - MIC_MONITOR_RATE as u32, - MIC_MONITOR_CHANNELS as u16, - 16, - )) - } -} - -struct LocalCameraPreview { - latest: Arc>>, - status_text: Arc>, - generation: Arc, - running: Arc, - selected_device: Option, - selected_mode: Option, - relay_preview_path: Option, -} - -struct LocalMicrophoneMonitor { - running: Arc, - generation: Arc, -} - -struct LocalMicrophoneLevelProbe { - path: PathBuf, - running: Arc, - generation: Arc, -} - -struct PreviewFrame { - width: i32, - height: i32, - stride: usize, - rgba: Vec, -} - -impl LocalCameraPreview { - fn new(picture: >k::Picture, status_label: >k::Label) -> Self { - let latest = Arc::new(Mutex::new(None::)); - let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string())); - let generation = Arc::new(AtomicU64::new(0)); - let running = Arc::new(AtomicBool::new(false)); - - picture.set_paintable(Some(&blank_camera_preview_texture())); - - { - let picture = picture.clone(); - let status_label = status_label.clone(); - let latest = Arc::clone(&latest); - let status_text = Arc::clone(&status_text); - glib::timeout_add_local(Duration::from_millis(120), move || { - let next = latest.lock().ok().and_then(|mut slot| slot.take()); - if let Some(frame) = next { - let bytes = glib::Bytes::from_owned(frame.rgba); - let texture = gdk::MemoryTexture::new( - frame.width, - frame.height, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - frame.stride, - ); - picture.set_paintable(Some(&texture)); - } - if let Ok(text) = status_text.lock() { - status_label.set_text(text.as_str()); - } - glib::ControlFlow::Continue - }); - } - - Self { - latest, - status_text, - generation, - running, - selected_device: None, - selected_mode: None, - relay_preview_path: None, - } - } - - fn is_running(&self) -> bool { - self.running.load(Ordering::Acquire) - } - - fn is_device_preview_running(&self) -> bool { - self.is_running() && self.relay_preview_path.is_none() - } - - fn is_relay_file_running(&self) -> bool { - self.is_running() && self.relay_preview_path.is_some() - } - - fn set_selected(&mut self, camera: Option<&str>) -> Result<()> { - self.selected_device = normalize_camera_selection(camera); - - if self.is_running() { - self.stop(); - self.start()?; - return Ok(()); - } - - self.set_status(match self.selected_device.as_deref() { - Some(camera) => format!( - "Selected {camera}. Start Preview to confirm webcam framing here before you launch the relay." - ), - None => CAMERA_PREVIEW_IDLE.to_string(), - }); - Ok(()) - } - - fn set_selected_mode(&mut self, mode: Option) -> Result<()> { - self.selected_mode = mode; - - if self.is_device_preview_running() { - self.stop(); - self.start()?; - return Ok(()); - } - - if let Some(camera) = self.selected_device.as_deref() { - let quality = self - .selected_mode - .map(CameraMode::short_label) - .unwrap_or_else(|| "default quality".to_string()); - self.set_status(format!( - "Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay." - )); - } - Ok(()) - } - - fn toggle(&mut self) -> Result { - if self.is_running() { - self.stop(); - return Ok(false); - } - self.start()?; - Ok(true) - } - - fn start(&mut self) -> Result<()> { - gst::init().context("initialising in-launcher camera preview")?; - let selected = self - .selected_device - .clone() - .ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?; - self.relay_preview_path = None; - let mode = self.selected_mode; - let device = resolve_camera_device(&selected); - let latest = Arc::clone(&self.latest); - let status_text = Arc::clone(&self.status_text); - let generation = Arc::clone(&self.generation); - let running = Arc::clone(&self.running); - let token = generation.fetch_add(1, Ordering::AcqRel) + 1; - running.store(true, Ordering::Release); - self.set_status(format!("Starting local preview for {selected}...")); - - std::thread::spawn(move || { - if let Err(err) = run_camera_preview_feed( - selected, - device, - mode, - token, - latest, - status_text.clone(), - generation.clone(), - running.clone(), - ) && generation.load(Ordering::Acquire) == token - { - running.store(false, Ordering::Release); - if let Ok(mut status) = status_text.lock() { - *status = format!("Camera preview failed: {err}"); - } - } - }); - Ok(()) - } - - fn start_relay_file(&mut self, path: PathBuf, selected: String) -> Result<()> { - if self.is_running() - && self - .relay_preview_path - .as_ref() - .is_some_and(|existing| existing == &path) - { - return Ok(()); - } - if self.is_running() { - self.stop(); - } - - let latest = Arc::clone(&self.latest); - let status_text = Arc::clone(&self.status_text); - let generation = Arc::clone(&self.generation); - let running = Arc::clone(&self.running); - let token = generation.fetch_add(1, Ordering::AcqRel) + 1; - running.store(true, Ordering::Release); - self.relay_preview_path = Some(path.clone()); - self.set_status(format!( - "Waiting for relay webcam frames from {selected}..." - )); - - std::thread::spawn(move || { - run_camera_file_preview_feed( - path, - selected, - token, - latest, - status_text, - generation, - running, - ); - }); - Ok(()) - } - - fn stop(&mut self) { - let was_relay_file = self.relay_preview_path.take().is_some(); - self.running.store(false, Ordering::Release); - self.generation.fetch_add(1, Ordering::AcqRel); - if let Ok(mut latest) = self.latest.lock() { - *latest = None; - } - let message = if was_relay_file { - "Relay webcam preview stopped.".to_string() - } else { - match self.selected_device.as_deref() { - Some(camera) => { - let quality = self - .selected_mode - .map(CameraMode::short_label) - .unwrap_or_else(|| "default quality".to_string()); - format!( - "Local preview stopped. {camera} at {quality} stays selected for the next relay launch." - ) - } - None => CAMERA_PREVIEW_IDLE.to_string(), - } - }; - self.set_status(message); - } - - fn set_status(&self, text: String) { - if let Ok(mut status) = self.status_text.lock() { - *status = text; - } - } -} - -fn blank_camera_preview_texture() -> gdk::MemoryTexture { - let rgba = - vec![12_u8; (CAMERA_PREVIEW_DEFAULT_WIDTH * CAMERA_PREVIEW_DEFAULT_HEIGHT * 4) as usize]; - let bytes = glib::Bytes::from_owned(rgba); - gdk::MemoryTexture::new( - CAMERA_PREVIEW_DEFAULT_WIDTH, - CAMERA_PREVIEW_DEFAULT_HEIGHT, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - (CAMERA_PREVIEW_DEFAULT_WIDTH * 4) as usize, - ) -} - -impl LocalMicrophoneMonitor { - fn start( - source: Option<&str>, - sink: Option<&str>, - recent_audio: Arc>>, - level: Arc>, - ) -> Result { - gst::init().context("initialising microphone preview")?; - let source = source - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| anyhow!("select a microphone before starting Monitor Mic"))? - .to_string(); - let sink = sink - .filter(|value| !value.trim().is_empty()) - .map(ToOwned::to_owned); - if let Ok(mut buffer) = recent_audio.lock() { - buffer.clear(); - } - if let Ok(mut meter) = level.lock() { - *meter = 0.0; - } - - let running = Arc::new(AtomicBool::new(true)); - let generation = Arc::new(AtomicU64::new(1)); - let running_handle = Arc::clone(&running); - let generation_handle = Arc::clone(&generation); - let token = generation.load(Ordering::Acquire); - - std::thread::spawn(move || { - let _ = run_microphone_monitor_feed( - &source, - sink.as_deref(), - token, - recent_audio, - level, - generation_handle, - running_handle, - ); - }); - - Ok(Self { - running, - generation, - }) - } - - fn is_running(&self) -> bool { - self.running.load(Ordering::Acquire) - } - - fn stop(&mut self) { - self.running.store(false, Ordering::Release); - self.generation.fetch_add(1, Ordering::AcqRel); - } -} - -impl LocalMicrophoneLevelProbe { - fn start(path: PathBuf, level: Arc>) -> Self { - let running = Arc::new(AtomicBool::new(true)); - let generation = Arc::new(AtomicU64::new(1)); - let running_handle = Arc::clone(&running); - let generation_handle = Arc::clone(&generation); - let path_handle = path.clone(); - let token = generation.load(Ordering::Acquire); - std::thread::spawn(move || { - run_microphone_level_probe( - path_handle, - token, - level, - generation_handle, - running_handle, - ); - }); - Self { - path, - running, - generation, - } - } - - fn is_running(&self) -> bool { - self.running.load(Ordering::Acquire) - } - - fn is_running_for(&self, path: &Path) -> bool { - self.is_running() && self.path == path - } - - fn stop(&mut self) { - self.running.store(false, Ordering::Release); - self.generation.fetch_add(1, Ordering::AcqRel); - } -} - -fn normalize_camera_selection(camera: Option<&str>) -> Option { - camera - .map(str::trim) - .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto")) - .map(ToOwned::to_owned) -} - -fn resolve_camera_device(camera: &str) -> String { - if camera.starts_with("/dev/") { - camera.to_string() - } else { - format!("/dev/v4l/by-id/{camera}") - } -} - -fn run_microphone_monitor_feed( - source: &str, - sink: Option<&str>, - token: u64, - recent_audio: Arc>>, - level: Arc>, - generation: Arc, - running: Arc, -) -> Result<()> { - let (pipeline, appsink) = build_microphone_monitor_pipeline(source, sink)?; - pipeline - .set_state(gst::State::Playing) - .context("starting microphone preview pipeline")?; - - while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { - if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { - if let Some(buffer) = sample.buffer() - && let Ok(map) = buffer.map_readable() - { - let bytes = map.as_slice(); - push_recent_audio(&recent_audio, bytes); - update_microphone_level(&level, bytes); - } - } else if let Ok(mut meter) = level.lock() { - *meter = (*meter * 0.8).clamp(0.0, 1.0); - } - } - - let _ = pipeline.set_state(gst::State::Null); - if let Ok(mut meter) = level.lock() { - *meter = 0.0; - } - running.store(false, Ordering::Release); - Ok(()) -} - -fn run_camera_preview_feed( - selected: String, - device: String, - mode: Option, - token: u64, - latest: Arc>>, - status_text: Arc>, - generation: Arc, - running: Arc, -) -> Result<()> { - let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?; - pipeline - .set_state(gst::State::Playing) - .context("starting in-launcher camera preview pipeline")?; - - if let Ok(mut status) = status_text.lock() { - let quality = mode - .map(CameraMode::short_label) - .unwrap_or_else(|| "default quality".to_string()); - *status = format!("Local preview live for {selected} at {quality}; waiting for frames..."); - } - - let mut announced_size = None::<(i32, i32)>; - while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { - if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) - && let Some(frame) = sample_to_frame(&sample) - { - let size = (frame.width, frame.height); - if announced_size != Some(size) { - announced_size = Some(size); - if let Ok(mut status) = status_text.lock() { - let quality = mode - .map(CameraMode::short_label) - .unwrap_or_else(|| "default quality".to_string()); - *status = format!( - "Local preview live for {selected} at {quality}; showing {}x{}.", - size.0, size.1 - ); - } - } - if let Ok(mut slot) = latest.lock() { - *slot = Some(frame); - } - } - } - - let _ = pipeline.set_state(gst::State::Null); - Ok(()) -} - -fn run_camera_file_preview_feed( - path: PathBuf, - selected: String, - token: u64, - latest: Arc>>, - status_text: Arc>, - generation: Arc, - running: Arc, -) { - let mut has_frame = false; - let mut announced_size = None::<(i32, i32)>; - while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { - match read_camera_preview_tap(&path) { - Ok(frame) => { - let size = (frame.width, frame.height); - if let Ok(mut slot) = latest.lock() { - *slot = Some(frame); - } - if !has_frame || announced_size != Some(size) { - has_frame = true; - announced_size = Some(size); - if let Ok(mut status) = status_text.lock() { - *status = format!( - "Relay webcam preview live for {selected}; showing {}x{}.", - size.0, size.1 - ); - } - } - } - Err(err) => { - if !has_frame && let Ok(mut status) = status_text.lock() { - *status = format!("Waiting for relay webcam frames from {selected}: {err}"); - } - } - } - std::thread::sleep(Duration::from_millis(120)); - } - running.store(false, Ordering::Release); -} - -fn run_microphone_level_probe( - path: PathBuf, - token: u64, - level: Arc>, - generation: Arc, - running: Arc, -) { - while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { - let next = read_microphone_level_tap(&path).unwrap_or_else(|| { - level - .lock() - .map(|current| (*current * 0.8).clamp(0.0, 1.0)) - .unwrap_or(0.0) - }); - if let Ok(mut meter) = level.lock() { - *meter = next.clamp(0.0, 1.0); - } - std::thread::sleep(Duration::from_millis(100)); - } - if let Ok(mut meter) = level.lock() { - *meter = 0.0; - } - running.store(false, Ordering::Release); -} - -fn build_camera_preview_pipeline( - device: &str, - mode: Option, -) -> Result<(gst::Pipeline, gst_app::AppSink)> { - let desc = camera_preview_pipeline_desc(device, mode); - let (width, height, _fps) = camera_preview_mode(mode); - let pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("camera preview pipeline"); - let appsink = pipeline - .by_name("sink") - .context("missing in-launcher camera preview appsink")? - .downcast::() - .expect("camera preview appsink"); - appsink.set_caps(Some( - &gst::Caps::builder("video/x-raw") - .field("format", "RGBA") - .field("width", width) - .field("height", height) - .build(), - )); - Ok((pipeline, appsink)) -} - -fn build_microphone_monitor_pipeline( - source: &str, - sink: Option<&str>, -) -> Result<(gst::Pipeline, gst_app::AppSink)> { - let desc = microphone_monitor_pipeline_desc(source, sink); - let pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("microphone monitor pipeline"); - let appsink = pipeline - .by_name("mic_preview_sink") - .context("missing microphone preview appsink")? - .downcast::() - .expect("microphone preview appsink"); - appsink.set_caps(Some( - &gst::Caps::builder("audio/x-raw") - .field("format", "S16LE") - .field("rate", MIC_MONITOR_RATE) - .field("channels", MIC_MONITOR_CHANNELS) - .build(), - )); - Ok((pipeline, appsink)) -} - -fn camera_preview_pipeline_desc(device: &str, mode: Option) -> String { - let device = gst_quote(device); - let (preview_width, preview_height, preview_fps) = camera_preview_mode(mode); - let source_caps = mode - .map(|mode| { - format!( - "capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ", - mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps - ) - }) - .unwrap_or_default(); - format!( - "v4l2src device=\"{device}\" do-timestamp=true ! \ - {source_caps}videoconvert ! videoscale ! videorate ! \ - video/x-raw,format=RGBA,width={preview_width},height={preview_height},framerate={preview_fps}/1,pixel-aspect-ratio=1/1 ! \ - appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" - ) -} - -fn camera_preview_mode(mode: Option) -> (i32, i32, u32) { - mode.map(|mode| { - ( - i32::try_from(mode.width).unwrap_or(i32::MAX).max(1), - i32::try_from(mode.height).unwrap_or(i32::MAX).max(1), - mode.fps.max(1), - ) - }) - .unwrap_or(( - CAMERA_PREVIEW_DEFAULT_WIDTH, - CAMERA_PREVIEW_DEFAULT_HEIGHT, - CAMERA_PREVIEW_DEFAULT_FPS, - )) -} - -fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String { - let source_element = if looks_like_pulse_source_name(source) - || gst::ElementFactory::find("pipewiresrc").is_none() - { - let source = gst_quote(source); - format!("pulsesrc device=\"{source}\" do-timestamp=true") - } else { - let source = gst_quote(source); - format!("pipewiresrc target-object=\"{source}\" do-timestamp=true") - }; - let sink_prop = sink - .map(gst_quote) - .map(|value| format!(" device=\"{value}\"")) - .unwrap_or_default(); - format!( - "{source_element} ! \ - audioconvert ! audioresample ! \ - audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \ - tee name=t \ - t. ! queue ! pulsesink{sink_prop} \ - t. ! queue ! appsink name=mic_preview_sink emit-signals=false sync=false max-buffers=8 drop=true" - ) -} - -fn looks_like_pulse_source_name(source: &str) -> bool { - let source = source.trim(); - source.starts_with("alsa_input.") - || source.starts_with("bluez_input.") - || source.starts_with("input.") -} - -fn sample_to_frame(sample: &gst::Sample) -> Option { - let caps = sample.caps()?; - let structure = caps.structure(0)?; - let width = structure.get::("width").ok()?; - let height = structure.get::("height").ok()?; - let buffer = sample.buffer()?; - let map = buffer.map_readable().ok()?; - let rgba = map.as_slice().to_vec(); - let stride = rgba.len() / height.max(1) as usize; - Some(PreviewFrame { - width, - height, - stride, - rgba, - }) -} - -fn read_camera_preview_tap(path: &Path) -> Result { - let bytes = fs::read(path).with_context(|| format!("{} is not ready", path.display()))?; - let header_end = bytes - .iter() - .position(|byte| *byte == b'\n') - .ok_or_else(|| anyhow!("preview frame header is incomplete"))?; - let header = - std::str::from_utf8(&bytes[..header_end]).context("preview frame header is not UTF-8")?; - let mut fields = header.split_whitespace(); - if fields.next() != Some("LESAVKA_RGBA") { - return Err(anyhow!("preview frame has an unknown format")); - } - let width = fields - .next() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .ok_or_else(|| anyhow!("preview frame width is invalid"))?; - let height = fields - .next() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .ok_or_else(|| anyhow!("preview frame height is invalid"))?; - let stride = fields - .next() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .ok_or_else(|| anyhow!("preview frame stride is invalid"))?; - let rgba = bytes[header_end + 1..].to_vec(); - let expected_min = stride.saturating_mul(height as usize); - if rgba.len() < expected_min { - return Err(anyhow!("preview frame payload is incomplete")); - } - Ok(PreviewFrame { - width, - height, - stride, - rgba, - }) -} - -fn read_microphone_level_tap(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|raw| raw.split_ascii_whitespace().next()?.parse::().ok()) - .filter(|value| value.is_finite()) - .map(|value| value.clamp(0.0, 1.0)) -} - -fn gst_quote(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn build_speaker_test(sink: Option<&str>) -> Result { - let sink_prop = sink - .filter(|value| !value.trim().is_empty()) - .map(|value| format!("device={}", quote(value))) - .unwrap_or_default(); - Ok(shell_command(format!( - "gst-launch-1.0 -q audiotestsrc is-live=true wave=sine freq=880 volume=0.25 ! audioconvert ! audioresample ! queue ! pulsesink {}", - sink_prop - ))) -} - -fn build_microphone_replay_test(path: &str, sink: Option<&str>) -> Result { - let sink_prop = sink - .filter(|value| !value.trim().is_empty()) - .map(|value| format!("device={}", quote(value))) - .unwrap_or_default(); - Ok(shell_command(format!( - "gst-launch-1.0 -q filesrc location={} ! wavparse ! audioconvert ! audioresample ! queue ! pulsesink {}", - quote(path), - sink_prop - ))) -} - -fn shell_command(command: String) -> Command { - let mut child = Command::new("bash"); - child.args(["-lc", &command]); - child -} - -fn quote(value: impl Into) -> String { - escape(Cow::Owned(value.into())).into_owned() -} - -fn push_recent_audio(buffer: &Arc>>, bytes: &[u8]) { - if let Ok(mut ring) = buffer.lock() { - ring.extend_from_slice(bytes); - if ring.len() > MIC_REPLAY_MAX_BYTES { - let overflow = ring.len() - MIC_REPLAY_MAX_BYTES; - ring.drain(0..overflow); - } - } -} - -fn update_microphone_level(level: &Arc>, bytes: &[u8]) { - let peak = bytes - .chunks_exact(2) - .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64) - .fold(0.0, f64::max) - / i16::MAX as f64; - if let Ok(mut meter) = level.lock() { - *meter = peak.clamp(0.0, 1.0); - } -} - -fn build_wav_bytes(audio: &[u8], sample_rate: u32, channels: u16, bits_per_sample: u16) -> Vec { - let block_align = channels * (bits_per_sample / 8); - let byte_rate = sample_rate * block_align as u32; - let data_len = audio.len() as u32; - let riff_len = 36 + data_len; - - let mut wav = Vec::with_capacity(44 + audio.len()); - wav.extend_from_slice(b"RIFF"); - wav.extend_from_slice(&riff_len.to_le_bytes()); - wav.extend_from_slice(b"WAVE"); - wav.extend_from_slice(b"fmt "); - wav.extend_from_slice(&16u32.to_le_bytes()); - wav.extend_from_slice(&1u16.to_le_bytes()); - wav.extend_from_slice(&channels.to_le_bytes()); - wav.extend_from_slice(&sample_rate.to_le_bytes()); - wav.extend_from_slice(&byte_rate.to_le_bytes()); - wav.extend_from_slice(&block_align.to_le_bytes()); - wav.extend_from_slice(&bits_per_sample.to_le_bytes()); - wav.extend_from_slice(b"data"); - wav.extend_from_slice(&data_len.to_le_bytes()); - wav.extend_from_slice(audio); - wav -} +// Local device staging previews and loopback tests for launcher-selected media devices. +include!("device_test/controller.rs"); +include!("device_test/local_preview.rs"); +include!("device_test/pipeline_helpers.rs"); #[cfg(test)] -mod tests { - use super::{ - MIC_REPLAY_MAX_BYTES, build_wav_bytes, camera_preview_mode, camera_preview_pipeline_desc, - microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio, - read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device, - }; - use crate::launcher::devices::CameraMode; - use std::sync::{Arc, Mutex}; - - #[test] - fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() { - assert_eq!(resolve_camera_device("/dev/video0"), "/dev/video0"); - assert_eq!( - resolve_camera_device("usb-Logitech_C920-video-index0"), - "/dev/v4l/by-id/usb-Logitech_C920-video-index0" - ); - } - - #[test] - fn normalize_camera_selection_drops_auto_and_blank_values() { - assert_eq!(normalize_camera_selection(None), None); - assert_eq!(normalize_camera_selection(Some("")), None); - assert_eq!(normalize_camera_selection(Some("auto")), None); - assert_eq!( - normalize_camera_selection(Some("usb-Logitech_C920-video-index0")), - Some("usb-Logitech_C920-video-index0".to_string()) - ); - } - - #[test] - fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() { - let desc = camera_preview_pipeline_desc("/dev/video0", None); - assert!(desc.contains("v4l2src device=\"/dev/video0\"")); - assert!(desc.contains("videoconvert ! videoscale ! videorate !")); - assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,")); - } - - #[test] - fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() { - let desc = - camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30))); - assert!( - desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1") - ); - assert!( - desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1") - ); - assert!(desc.contains("decodebin ! videoconvert ! videoscale")); - assert!(desc.contains("video/x-raw,format=RGBA,width=1920,height=1080,framerate=30/1")); - assert!(!desc.contains("width=128,height=72")); - } - - #[test] - fn camera_preview_mode_defaults_to_hd_and_tracks_selected_quality() { - assert_eq!(camera_preview_mode(None), (1280, 720, 30)); - assert_eq!( - camera_preview_mode(Some(CameraMode::new(1920, 1080, 30))), - (1920, 1080, 30) - ); - } - - #[test] - fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() { - let desc = microphone_monitor_pipeline_desc( - "alsa_input.usb-Neat_Microphones_Bumblebee_II_USB_Microphone-00.mono-fallback", - None, - ); - assert!(desc.contains("pulsesrc device=\"alsa_input.usb-Neat_Microphones")); - assert!(!desc.contains("pipewiresrc target-object")); - } - - #[test] - fn push_recent_audio_keeps_only_last_three_seconds() { - let buffer = Arc::new(Mutex::new(Vec::new())); - push_recent_audio(&buffer, &vec![1u8; MIC_REPLAY_MAX_BYTES / 2]); - push_recent_audio(&buffer, &vec![2u8; MIC_REPLAY_MAX_BYTES]); - let stored = buffer.lock().expect("buffer").clone(); - assert_eq!(stored.len(), MIC_REPLAY_MAX_BYTES); - assert!(stored.iter().any(|byte| *byte == 2)); - } - - #[test] - fn build_wav_bytes_writes_a_valid_riff_header() { - let audio = vec![0u8; 32]; - let wav = build_wav_bytes(&audio, 16_000, 1, 16); - assert!(wav.starts_with(b"RIFF")); - assert_eq!(&wav[8..12], b"WAVE"); - assert_eq!(&wav[36..40], b"data"); - assert_eq!(wav.len(), 44 + audio.len()); - } - - #[test] - fn relay_camera_preview_tap_round_trips_rgba_frame() { - let path = - std::env::temp_dir().join(format!("lesavka-camera-preview-tap-{}", std::process::id())); - std::fs::write( - &path, - [b"LESAVKA_RGBA 2 2 8\n".as_slice(), &[1_u8; 16]].concat(), - ) - .expect("write tap"); - - let frame = read_camera_preview_tap(&path).expect("read tap"); - assert_eq!(frame.width, 2); - assert_eq!(frame.height, 2); - assert_eq!(frame.stride, 8); - assert_eq!(frame.rgba.len(), 16); - let _ = std::fs::remove_file(path); - } - - #[test] - fn relay_microphone_level_tap_clamps_values() { - let path = - std::env::temp_dir().join(format!("lesavka-mic-level-tap-{}", std::process::id())); - std::fs::write(&path, "1.25\n").expect("write high"); - assert_eq!(read_microphone_level_tap(&path), Some(1.0)); - - std::fs::write(&path, "-0.5\n").expect("write low"); - assert_eq!(read_microphone_level_tap(&path), Some(0.0)); - - std::fs::write(&path, "not-a-number\n").expect("write invalid"); - assert_eq!(read_microphone_level_tap(&path), None); - let _ = std::fs::remove_file(path); - } -} +#[path = "tests/device_test.rs"] +mod tests; diff --git a/client/src/launcher/device_test/controller.rs b/client/src/launcher/device_test/controller.rs new file mode 100644 index 0000000..2ca6baa --- /dev/null +++ b/client/src/launcher/device_test/controller.rs @@ -0,0 +1,398 @@ +use anyhow::{Context, Result, anyhow}; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use gtk::{gdk, glib}; +use shell_escape::escape; +use std::borrow::Cow; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use super::devices::CameraMode; + +const CAMERA_PREVIEW_DEFAULT_WIDTH: i32 = 1280; +const CAMERA_PREVIEW_DEFAULT_HEIGHT: i32 = 720; +const CAMERA_PREVIEW_DEFAULT_FPS: u32 = 30; +const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; +const MIC_MONITOR_RATE: i32 = 16_000; +const MIC_MONITOR_CHANNELS: i32 = 1; +const MIC_MONITOR_SAMPLE_BYTES: usize = 2; +const MIC_REPLAY_SECONDS: usize = 3; +const MIC_REPLAY_PATH: &str = "/tmp/lesavka-mic-replay.wav"; +const MIC_REPLAY_MAX_BYTES: usize = MIC_MONITOR_RATE as usize + * MIC_MONITOR_CHANNELS as usize + * MIC_MONITOR_SAMPLE_BYTES + * MIC_REPLAY_SECONDS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceTestKind { + Camera, + Microphone, + MicrophoneReplay, + Speaker, +} + +pub struct DeviceTestController { + camera: Option, + selected_camera: Option, + selected_camera_mode: Option, + microphone: Option, + microphone_probe: Option, + speaker: Option, + microphone_replay: Option, + microphone_buffer: Arc>>, + microphone_level: Arc>, +} + +impl Default for DeviceTestController { + fn default() -> Self { + Self { + camera: None, + selected_camera: None, + selected_camera_mode: None, + microphone: None, + microphone_probe: None, + speaker: None, + microphone_replay: None, + microphone_buffer: Arc::new(Mutex::new(Vec::new())), + microphone_level: Arc::new(Mutex::new(0.0)), + } + } +} + +impl DeviceTestController { + pub fn new() -> Self { + Self::default() + } + + pub fn bind_camera_preview( + &mut self, + camera_picture: >k::Picture, + camera_status: >k::Label, + ) -> Result<()> { + if let Some(camera) = self.camera.as_mut() { + camera.stop(); + } + let mut preview = LocalCameraPreview::new(camera_picture, camera_status); + preview.set_selected(self.selected_camera.as_deref())?; + preview.set_selected_mode(self.selected_camera_mode)?; + self.camera = Some(preview); + Ok(()) + } + + pub fn is_running(&mut self, kind: DeviceTestKind) -> bool { + self.cleanup_finished(); + match kind { + DeviceTestKind::Camera => self + .camera + .as_ref() + .is_some_and(LocalCameraPreview::is_running), + DeviceTestKind::Microphone => { + self.microphone + .as_ref() + .is_some_and(LocalMicrophoneMonitor::is_running) + || self + .microphone_probe + .as_ref() + .is_some_and(LocalMicrophoneLevelProbe::is_running) + } + DeviceTestKind::MicrophoneReplay => self.microphone_replay.is_some(), + DeviceTestKind::Speaker => self.speaker.is_some(), + } + } + + pub fn set_camera_selection(&mut self, camera: Option<&str>) -> Result<()> { + self.selected_camera = normalize_camera_selection(camera); + if let Some(preview) = self.camera.as_mut() { + preview.set_selected(self.selected_camera.as_deref())?; + } + Ok(()) + } + + pub fn set_camera_quality(&mut self, mode: Option) -> Result<()> { + self.selected_camera_mode = mode; + if let Some(preview) = self.camera.as_mut() { + preview.set_selected_mode(mode)?; + } + Ok(()) + } + + pub fn toggle_camera(&mut self) -> Result { + let preview = self + .camera + .as_mut() + .ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?; + preview.toggle() + } + + pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result { + self.cleanup_finished(); + if self.microphone.is_some() { + self.stop(DeviceTestKind::Microphone); + return Ok(false); + } + + let monitor = LocalMicrophoneMonitor::start( + source, + sink, + Arc::clone(&self.microphone_buffer), + Arc::clone(&self.microphone_level), + )?; + self.microphone = Some(monitor); + Ok(true) + } + + pub fn stop_local_capture_for_relay(&mut self) { + if self + .camera + .as_ref() + .is_some_and(LocalCameraPreview::is_device_preview_running) + && let Some(camera) = self.camera.as_mut() + { + camera.stop(); + } + if let Some(mut monitor) = self.microphone.take() { + monitor.stop(); + } + } + + pub fn sync_relay_uplink_probe( + &mut self, + relay_live: bool, + camera_active: bool, + camera_label: Option<&str>, + camera_preview_path: &Path, + microphone_active: bool, + microphone_level_path: &Path, + ) -> Result<()> { + self.cleanup_finished(); + let camera_should_probe = relay_live && camera_active && camera_label.is_some(); + if camera_should_probe { + let preview = self + .camera + .as_mut() + .ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?; + preview.start_relay_file( + camera_preview_path.to_path_buf(), + camera_label.unwrap_or("selected webcam").to_string(), + )?; + } else if self + .camera + .as_ref() + .is_some_and(LocalCameraPreview::is_relay_file_running) + && let Some(preview) = self.camera.as_mut() + { + preview.stop(); + } + + let microphone_should_probe = relay_live && microphone_active; + if microphone_should_probe { + if self.microphone.is_some() { + self.stop(DeviceTestKind::Microphone); + } + let needs_probe = self + .microphone_probe + .as_ref() + .is_none_or(|probe| !probe.is_running_for(microphone_level_path)); + if needs_probe { + self.stop_microphone_probe(); + self.microphone_probe = Some(LocalMicrophoneLevelProbe::start( + microphone_level_path.to_path_buf(), + Arc::clone(&self.microphone_level), + )); + } + } else { + self.stop_microphone_probe(); + } + Ok(()) + } + + pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result { + self.toggle_child(DeviceTestKind::Speaker, build_speaker_test(sink)) + } + + pub fn toggle_microphone_replay(&mut self, sink: Option<&str>) -> Result { + self.cleanup_finished(); + if self.microphone_replay.is_some() { + self.stop(DeviceTestKind::MicrophoneReplay); + return Ok(false); + } + + let wav_bytes = self.replay_wav_bytes()?; + fs::write(MIC_REPLAY_PATH, wav_bytes).context("writing microphone replay clip")?; + let child = build_microphone_replay_test(MIC_REPLAY_PATH, sink)? + .spawn() + .context("starting microphone replay")?; + self.microphone_replay = Some(child); + Ok(true) + } + + pub fn microphone_level_fraction(&mut self) -> f64 { + self.cleanup_finished(); + self.microphone_level + .lock() + .map(|value| (*value).clamp(0.0, 1.0)) + .unwrap_or(0.0) + } + + pub fn microphone_replay_ready(&mut self) -> bool { + self.cleanup_finished(); + self.microphone_buffer + .lock() + .map(|buffer| !buffer.is_empty()) + .unwrap_or(false) + } + + pub fn stop_all(&mut self) { + if let Some(camera) = self.camera.as_mut() { + camera.stop(); + } + for kind in [ + DeviceTestKind::Microphone, + DeviceTestKind::MicrophoneReplay, + DeviceTestKind::Speaker, + ] { + self.stop(kind); + } + self.stop_microphone_probe(); + } + + fn toggle_child(&mut self, kind: DeviceTestKind, command: Result) -> Result { + self.cleanup_finished(); + if self.slot(kind).is_some() { + self.stop(kind); + return Ok(false); + } + let child = command? + .spawn() + .with_context(|| format!("starting {kind:?} test"))?; + *self.slot_mut(kind) = Some(child); + Ok(true) + } + + fn stop(&mut self, kind: DeviceTestKind) { + match kind { + DeviceTestKind::Camera => panic!("camera preview is not stopped through this path"), + DeviceTestKind::Microphone => { + if let Some(mut monitor) = self.microphone.take() { + monitor.stop(); + } + self.stop_microphone_probe(); + if let Ok(mut level) = self.microphone_level.lock() { + *level = 0.0; + } + } + DeviceTestKind::MicrophoneReplay | DeviceTestKind::Speaker => { + if let Some(mut child) = self.slot_mut(kind).take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } + + fn cleanup_finished(&mut self) { + if self + .microphone + .as_mut() + .is_some_and(|monitor| !monitor.is_running()) + { + self.microphone = None; + } + if self + .microphone_probe + .as_mut() + .is_some_and(|probe| !probe.is_running()) + { + self.microphone_probe = None; + } + for kind in [DeviceTestKind::MicrophoneReplay, DeviceTestKind::Speaker] { + let finished = self + .slot_mut(kind) + .as_mut() + .and_then(|child| child.try_wait().ok()) + .flatten() + .is_some(); + if finished { + let _ = self.slot_mut(kind).take(); + } + } + } + + fn slot(&self, kind: DeviceTestKind) -> &Option { + match kind { + DeviceTestKind::Camera | DeviceTestKind::Microphone => { + panic!("this device test is not an external child process") + } + DeviceTestKind::MicrophoneReplay => &self.microphone_replay, + DeviceTestKind::Speaker => &self.speaker, + } + } + + fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option { + match kind { + DeviceTestKind::Camera | DeviceTestKind::Microphone => { + panic!("this device test is not an external child process") + } + DeviceTestKind::MicrophoneReplay => &mut self.microphone_replay, + DeviceTestKind::Speaker => &mut self.speaker, + } + } + + fn stop_microphone_probe(&mut self) { + if let Some(mut probe) = self.microphone_probe.take() { + probe.stop(); + } + } + + fn replay_wav_bytes(&self) -> Result> { + let audio = self + .microphone_buffer + .lock() + .map_err(|_| anyhow!("microphone replay buffer is unavailable right now"))? + .clone(); + if audio.is_empty() { + return Err(anyhow!( + "Monitor Mic long enough to capture audio before replaying the last 3 seconds." + )); + } + Ok(build_wav_bytes( + &audio, + MIC_MONITOR_RATE as u32, + MIC_MONITOR_CHANNELS as u16, + 16, + )) + } +} + +struct LocalCameraPreview { + latest: Arc>>, + status_text: Arc>, + generation: Arc, + running: Arc, + selected_device: Option, + selected_mode: Option, + relay_preview_path: Option, +} + +struct LocalMicrophoneMonitor { + running: Arc, + generation: Arc, +} + +struct LocalMicrophoneLevelProbe { + path: PathBuf, + running: Arc, + generation: Arc, +} + +struct PreviewFrame { + width: i32, + height: i32, + stride: usize, + rgba: Vec, +} diff --git a/client/src/launcher/device_test/local_preview.rs b/client/src/launcher/device_test/local_preview.rs new file mode 100644 index 0000000..5584af1 --- /dev/null +++ b/client/src/launcher/device_test/local_preview.rs @@ -0,0 +1,320 @@ +impl LocalCameraPreview { + fn new(picture: >k::Picture, status_label: >k::Label) -> Self { + let latest = Arc::new(Mutex::new(None::)); + let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string())); + let generation = Arc::new(AtomicU64::new(0)); + let running = Arc::new(AtomicBool::new(false)); + + picture.set_paintable(Some(&blank_camera_preview_texture())); + + { + let picture = picture.clone(); + let status_label = status_label.clone(); + let latest = Arc::clone(&latest); + let status_text = Arc::clone(&status_text); + glib::timeout_add_local(Duration::from_millis(120), move || { + let next = latest.lock().ok().and_then(|mut slot| slot.take()); + if let Some(frame) = next { + let bytes = glib::Bytes::from_owned(frame.rgba); + let texture = gdk::MemoryTexture::new( + frame.width, + frame.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + frame.stride, + ); + picture.set_paintable(Some(&texture)); + } + if let Ok(text) = status_text.lock() { + status_label.set_text(text.as_str()); + } + glib::ControlFlow::Continue + }); + } + + Self { + latest, + status_text, + generation, + running, + selected_device: None, + selected_mode: None, + relay_preview_path: None, + } + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::Acquire) + } + + fn is_device_preview_running(&self) -> bool { + self.is_running() && self.relay_preview_path.is_none() + } + + fn is_relay_file_running(&self) -> bool { + self.is_running() && self.relay_preview_path.is_some() + } + + fn set_selected(&mut self, camera: Option<&str>) -> Result<()> { + self.selected_device = normalize_camera_selection(camera); + + if self.is_running() { + self.stop(); + self.start()?; + return Ok(()); + } + + self.set_status(match self.selected_device.as_deref() { + Some(camera) => format!( + "Selected {camera}. Start Preview to confirm webcam framing here before you launch the relay." + ), + None => CAMERA_PREVIEW_IDLE.to_string(), + }); + Ok(()) + } + + fn set_selected_mode(&mut self, mode: Option) -> Result<()> { + self.selected_mode = mode; + + if self.is_device_preview_running() { + self.stop(); + self.start()?; + return Ok(()); + } + + if let Some(camera) = self.selected_device.as_deref() { + let quality = self + .selected_mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + self.set_status(format!( + "Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay." + )); + } + Ok(()) + } + + fn toggle(&mut self) -> Result { + if self.is_running() { + self.stop(); + return Ok(false); + } + self.start()?; + Ok(true) + } + + fn start(&mut self) -> Result<()> { + gst::init().context("initialising in-launcher camera preview")?; + let selected = self + .selected_device + .clone() + .ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?; + self.relay_preview_path = None; + let mode = self.selected_mode; + let device = resolve_camera_device(&selected); + let latest = Arc::clone(&self.latest); + let status_text = Arc::clone(&self.status_text); + let generation = Arc::clone(&self.generation); + let running = Arc::clone(&self.running); + let token = generation.fetch_add(1, Ordering::AcqRel) + 1; + running.store(true, Ordering::Release); + self.set_status(format!("Starting local preview for {selected}...")); + + std::thread::spawn(move || { + if let Err(err) = run_camera_preview_feed( + selected, + device, + mode, + token, + latest, + status_text.clone(), + generation.clone(), + running.clone(), + ) && generation.load(Ordering::Acquire) == token + { + running.store(false, Ordering::Release); + if let Ok(mut status) = status_text.lock() { + *status = format!("Camera preview failed: {err}"); + } + } + }); + Ok(()) + } + + fn start_relay_file(&mut self, path: PathBuf, selected: String) -> Result<()> { + if self.is_running() + && self + .relay_preview_path + .as_ref() + .is_some_and(|existing| existing == &path) + { + return Ok(()); + } + if self.is_running() { + self.stop(); + } + + let latest = Arc::clone(&self.latest); + let status_text = Arc::clone(&self.status_text); + let generation = Arc::clone(&self.generation); + let running = Arc::clone(&self.running); + let token = generation.fetch_add(1, Ordering::AcqRel) + 1; + running.store(true, Ordering::Release); + self.relay_preview_path = Some(path.clone()); + self.set_status(format!( + "Waiting for relay webcam frames from {selected}..." + )); + + std::thread::spawn(move || { + run_camera_file_preview_feed( + path, + selected, + token, + latest, + status_text, + generation, + running, + ); + }); + Ok(()) + } + + fn stop(&mut self) { + let was_relay_file = self.relay_preview_path.take().is_some(); + self.running.store(false, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + if let Ok(mut latest) = self.latest.lock() { + *latest = None; + } + let message = if was_relay_file { + "Relay webcam preview stopped.".to_string() + } else { + match self.selected_device.as_deref() { + Some(camera) => { + let quality = self + .selected_mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + format!( + "Local preview stopped. {camera} at {quality} stays selected for the next relay launch." + ) + } + None => CAMERA_PREVIEW_IDLE.to_string(), + } + }; + self.set_status(message); + } + + fn set_status(&self, text: String) { + if let Ok(mut status) = self.status_text.lock() { + *status = text; + } + } +} + +fn blank_camera_preview_texture() -> gdk::MemoryTexture { + let rgba = + vec![12_u8; (CAMERA_PREVIEW_DEFAULT_WIDTH * CAMERA_PREVIEW_DEFAULT_HEIGHT * 4) as usize]; + let bytes = glib::Bytes::from_owned(rgba); + gdk::MemoryTexture::new( + CAMERA_PREVIEW_DEFAULT_WIDTH, + CAMERA_PREVIEW_DEFAULT_HEIGHT, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + (CAMERA_PREVIEW_DEFAULT_WIDTH * 4) as usize, + ) +} + +impl LocalMicrophoneMonitor { + fn start( + source: Option<&str>, + sink: Option<&str>, + recent_audio: Arc>>, + level: Arc>, + ) -> Result { + gst::init().context("initialising microphone preview")?; + let source = source + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("select a microphone before starting Monitor Mic"))? + .to_string(); + let sink = sink + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned); + if let Ok(mut buffer) = recent_audio.lock() { + buffer.clear(); + } + if let Ok(mut meter) = level.lock() { + *meter = 0.0; + } + + let running = Arc::new(AtomicBool::new(true)); + let generation = Arc::new(AtomicU64::new(1)); + let running_handle = Arc::clone(&running); + let generation_handle = Arc::clone(&generation); + let token = generation.load(Ordering::Acquire); + + std::thread::spawn(move || { + let _ = run_microphone_monitor_feed( + &source, + sink.as_deref(), + token, + recent_audio, + level, + generation_handle, + running_handle, + ); + }); + + Ok(Self { + running, + generation, + }) + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::Acquire) + } + + fn stop(&mut self) { + self.running.store(false, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + } +} + +impl LocalMicrophoneLevelProbe { + fn start(path: PathBuf, level: Arc>) -> Self { + let running = Arc::new(AtomicBool::new(true)); + let generation = Arc::new(AtomicU64::new(1)); + let running_handle = Arc::clone(&running); + let generation_handle = Arc::clone(&generation); + let path_handle = path.clone(); + let token = generation.load(Ordering::Acquire); + std::thread::spawn(move || { + run_microphone_level_probe( + path_handle, + token, + level, + generation_handle, + running_handle, + ); + }); + Self { + path, + running, + generation, + } + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::Acquire) + } + + fn is_running_for(&self, path: &Path) -> bool { + self.is_running() && self.path == path + } + + fn stop(&mut self) { + self.running.store(false, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + } +} diff --git a/client/src/launcher/device_test/pipeline_helpers.rs b/client/src/launcher/device_test/pipeline_helpers.rs new file mode 100644 index 0000000..ebd11e5 --- /dev/null +++ b/client/src/launcher/device_test/pipeline_helpers.rs @@ -0,0 +1,425 @@ +fn normalize_camera_selection(camera: Option<&str>) -> Option { + camera + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto")) + .map(ToOwned::to_owned) +} + +fn resolve_camera_device(camera: &str) -> String { + if camera.starts_with("/dev/") { + camera.to_string() + } else { + format!("/dev/v4l/by-id/{camera}") + } +} + +fn run_microphone_monitor_feed( + source: &str, + sink: Option<&str>, + token: u64, + recent_audio: Arc>>, + level: Arc>, + generation: Arc, + running: Arc, +) -> Result<()> { + let (pipeline, appsink) = build_microphone_monitor_pipeline(source, sink)?; + pipeline + .set_state(gst::State::Playing) + .context("starting microphone preview pipeline")?; + + while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + if let Some(buffer) = sample.buffer() + && let Ok(map) = buffer.map_readable() + { + let bytes = map.as_slice(); + push_recent_audio(&recent_audio, bytes); + update_microphone_level(&level, bytes); + } + } else if let Ok(mut meter) = level.lock() { + *meter = (*meter * 0.8).clamp(0.0, 1.0); + } + } + + let _ = pipeline.set_state(gst::State::Null); + if let Ok(mut meter) = level.lock() { + *meter = 0.0; + } + running.store(false, Ordering::Release); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn run_camera_preview_feed( + selected: String, + device: String, + mode: Option, + token: u64, + latest: Arc>>, + status_text: Arc>, + generation: Arc, + running: Arc, +) -> Result<()> { + let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?; + pipeline + .set_state(gst::State::Playing) + .context("starting in-launcher camera preview pipeline")?; + + if let Ok(mut status) = status_text.lock() { + let quality = mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + *status = format!("Local preview live for {selected} at {quality}; waiting for frames..."); + } + + let mut announced_size = None::<(i32, i32)>; + while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) + && let Some(frame) = sample_to_frame(&sample) + { + let size = (frame.width, frame.height); + if announced_size != Some(size) { + announced_size = Some(size); + if let Ok(mut status) = status_text.lock() { + let quality = mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + *status = format!( + "Local preview live for {selected} at {quality}; showing {}x{}.", + size.0, size.1 + ); + } + } + if let Ok(mut slot) = latest.lock() { + *slot = Some(frame); + } + } + } + + let _ = pipeline.set_state(gst::State::Null); + Ok(()) +} + +fn run_camera_file_preview_feed( + path: PathBuf, + selected: String, + token: u64, + latest: Arc>>, + status_text: Arc>, + generation: Arc, + running: Arc, +) { + let mut has_frame = false; + let mut announced_size = None::<(i32, i32)>; + while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { + match read_camera_preview_tap(&path) { + Ok(frame) => { + let size = (frame.width, frame.height); + if let Ok(mut slot) = latest.lock() { + *slot = Some(frame); + } + if !has_frame || announced_size != Some(size) { + has_frame = true; + announced_size = Some(size); + if let Ok(mut status) = status_text.lock() { + *status = format!( + "Relay webcam preview live for {selected}; showing {}x{}.", + size.0, size.1 + ); + } + } + } + Err(err) => { + if !has_frame && let Ok(mut status) = status_text.lock() { + *status = format!("Waiting for relay webcam frames from {selected}: {err}"); + } + } + } + std::thread::sleep(Duration::from_millis(120)); + } + running.store(false, Ordering::Release); +} + +fn run_microphone_level_probe( + path: PathBuf, + token: u64, + level: Arc>, + generation: Arc, + running: Arc, +) { + while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { + let next = read_microphone_level_tap(&path).unwrap_or_else(|| { + level + .lock() + .map(|current| (*current * 0.8).clamp(0.0, 1.0)) + .unwrap_or(0.0) + }); + if let Ok(mut meter) = level.lock() { + *meter = next.clamp(0.0, 1.0); + } + std::thread::sleep(Duration::from_millis(100)); + } + if let Ok(mut meter) = level.lock() { + *meter = 0.0; + } + running.store(false, Ordering::Release); +} + +fn build_camera_preview_pipeline( + device: &str, + mode: Option, +) -> Result<(gst::Pipeline, gst_app::AppSink)> { + let desc = camera_preview_pipeline_desc(device, mode); + let (width, height, _fps) = camera_preview_mode(mode); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("camera preview pipeline"); + let appsink = pipeline + .by_name("sink") + .context("missing in-launcher camera preview appsink")? + .downcast::() + .expect("camera preview appsink"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", "RGBA") + .field("width", width) + .field("height", height) + .build(), + )); + Ok((pipeline, appsink)) +} + +fn build_microphone_monitor_pipeline( + source: &str, + sink: Option<&str>, +) -> Result<(gst::Pipeline, gst_app::AppSink)> { + let desc = microphone_monitor_pipeline_desc(source, sink); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("microphone monitor pipeline"); + let appsink = pipeline + .by_name("mic_preview_sink") + .context("missing microphone preview appsink")? + .downcast::() + .expect("microphone preview appsink"); + appsink.set_caps(Some( + &gst::Caps::builder("audio/x-raw") + .field("format", "S16LE") + .field("rate", MIC_MONITOR_RATE) + .field("channels", MIC_MONITOR_CHANNELS) + .build(), + )); + Ok((pipeline, appsink)) +} + +fn camera_preview_pipeline_desc(device: &str, mode: Option) -> String { + let device = gst_quote(device); + let (preview_width, preview_height, preview_fps) = camera_preview_mode(mode); + let source_caps = mode + .map(|mode| { + format!( + "capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ", + mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps + ) + }) + .unwrap_or_default(); + format!( + "v4l2src device=\"{device}\" do-timestamp=true ! \ + {source_caps}videoconvert ! videoscale ! videorate ! \ + video/x-raw,format=RGBA,width={preview_width},height={preview_height},framerate={preview_fps}/1,pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" + ) +} + +fn camera_preview_mode(mode: Option) -> (i32, i32, u32) { + mode.map(|mode| { + ( + i32::try_from(mode.width).unwrap_or(i32::MAX).max(1), + i32::try_from(mode.height).unwrap_or(i32::MAX).max(1), + mode.fps.max(1), + ) + }) + .unwrap_or(( + CAMERA_PREVIEW_DEFAULT_WIDTH, + CAMERA_PREVIEW_DEFAULT_HEIGHT, + CAMERA_PREVIEW_DEFAULT_FPS, + )) +} + +fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String { + let source_element = if looks_like_pulse_source_name(source) + || gst::ElementFactory::find("pipewiresrc").is_none() + { + let source = gst_quote(source); + format!("pulsesrc device=\"{source}\" do-timestamp=true") + } else { + let source = gst_quote(source); + format!("pipewiresrc target-object=\"{source}\" do-timestamp=true") + }; + let sink_prop = sink + .map(gst_quote) + .map(|value| format!(" device=\"{value}\"")) + .unwrap_or_default(); + format!( + "{source_element} ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \ + tee name=t \ + t. ! queue ! pulsesink{sink_prop} \ + t. ! queue ! appsink name=mic_preview_sink emit-signals=false sync=false max-buffers=8 drop=true" + ) +} + +fn looks_like_pulse_source_name(source: &str) -> bool { + let source = source.trim(); + source.starts_with("alsa_input.") + || source.starts_with("bluez_input.") + || source.starts_with("input.") +} + +fn sample_to_frame(sample: &gst::Sample) -> Option { + let caps = sample.caps()?; + let structure = caps.structure(0)?; + let width = structure.get::("width").ok()?; + let height = structure.get::("height").ok()?; + let buffer = sample.buffer()?; + let map = buffer.map_readable().ok()?; + let rgba = map.as_slice().to_vec(); + let stride = rgba.len() / height.max(1) as usize; + Some(PreviewFrame { + width, + height, + stride, + rgba, + }) +} + +fn read_camera_preview_tap(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("{} is not ready", path.display()))?; + let header_end = bytes + .iter() + .position(|byte| *byte == b'\n') + .ok_or_else(|| anyhow!("preview frame header is incomplete"))?; + let header = + std::str::from_utf8(&bytes[..header_end]).context("preview frame header is not UTF-8")?; + let mut fields = header.split_whitespace(); + if fields.next() != Some("LESAVKA_RGBA") { + return Err(anyhow!("preview frame has an unknown format")); + } + let width = fields + .next() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .ok_or_else(|| anyhow!("preview frame width is invalid"))?; + let height = fields + .next() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .ok_or_else(|| anyhow!("preview frame height is invalid"))?; + let stride = fields + .next() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .ok_or_else(|| anyhow!("preview frame stride is invalid"))?; + let rgba = bytes[header_end + 1..].to_vec(); + let expected_min = stride.saturating_mul(height as usize); + if rgba.len() < expected_min { + return Err(anyhow!("preview frame payload is incomplete")); + } + Ok(PreviewFrame { + width, + height, + stride, + rgba, + }) +} + +fn read_microphone_level_tap(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|raw| raw.split_ascii_whitespace().next()?.parse::().ok()) + .filter(|value| value.is_finite()) + .map(|value| value.clamp(0.0, 1.0)) +} + +fn gst_quote(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn build_speaker_test(sink: Option<&str>) -> Result { + let sink_prop = sink + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("device={}", quote(value))) + .unwrap_or_default(); + Ok(shell_command(format!( + "gst-launch-1.0 -q audiotestsrc is-live=true wave=sine freq=880 volume=0.25 ! audioconvert ! audioresample ! queue ! pulsesink {}", + sink_prop + ))) +} + +fn build_microphone_replay_test(path: &str, sink: Option<&str>) -> Result { + let sink_prop = sink + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("device={}", quote(value))) + .unwrap_or_default(); + Ok(shell_command(format!( + "gst-launch-1.0 -q filesrc location={} ! wavparse ! audioconvert ! audioresample ! queue ! pulsesink {}", + quote(path), + sink_prop + ))) +} + +fn shell_command(command: String) -> Command { + let mut child = Command::new("bash"); + child.args(["-lc", &command]); + child +} + +fn quote(value: impl Into) -> String { + escape(Cow::Owned(value.into())).into_owned() +} + +fn push_recent_audio(buffer: &Arc>>, bytes: &[u8]) { + if let Ok(mut ring) = buffer.lock() { + ring.extend_from_slice(bytes); + if ring.len() > MIC_REPLAY_MAX_BYTES { + let overflow = ring.len() - MIC_REPLAY_MAX_BYTES; + ring.drain(0..overflow); + } + } +} + +fn update_microphone_level(level: &Arc>, bytes: &[u8]) { + let peak = bytes + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64) + .fold(0.0, f64::max) + / i16::MAX as f64; + if let Ok(mut meter) = level.lock() { + *meter = peak.clamp(0.0, 1.0); + } +} + +fn build_wav_bytes(audio: &[u8], sample_rate: u32, channels: u16, bits_per_sample: u16) -> Vec { + let block_align = channels * (bits_per_sample / 8); + let byte_rate = sample_rate * block_align as u32; + let data_len = audio.len() as u32; + let riff_len = 36 + data_len; + + let mut wav = Vec::with_capacity(44 + audio.len()); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&riff_len.to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + wav.extend_from_slice(audio); + wav +} diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index 0bb55c6..75c5710 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -381,184 +381,5 @@ fn classify_input_device(device: &Device) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn mk_temp_dir(prefix: &str) -> PathBuf { - let mut path = std::env::temp_dir(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id())); - std::fs::create_dir_all(&path).expect("create temp dir"); - path - } - - #[test] - fn parse_pactl_short_collects_second_column_and_sorts_unique() { - let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n"; - let parsed = parse_pactl_short(input); - assert_eq!( - parsed, - vec![ - "alsa_input.pci".to_string(), - "alsa_input.usb.test".to_string(), - ] - ); - } - - #[test] - fn parse_pactl_short_ignores_blank_or_short_lines() { - let input = "\nweird\n3\n4 sink.a\tmodule\n"; - let parsed = parse_pactl_short(input); - assert_eq!(parsed, vec!["sink.a".to_string()]); - } - - #[test] - fn camera_discovery_reads_entry_names_from_override_dir() { - let tmp = mk_temp_dir("camera-discovery"); - std::fs::write(tmp.join("usb-cam-a"), "").expect("write"); - std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); - - let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); - assert_eq!( - devices, - vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()] - ); - let _ = std::fs::remove_dir_all(tmp); - } - - #[test] - fn camera_discovery_prefers_one_endpoint_per_physical_webcam() { - let devices = dedupe_camera_devices([ - "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index1".to_string(), - "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), - "usb-Logitech_C920-video-index0".to_string(), - ]); - - assert_eq!( - devices, - vec![ - "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), - "usb-Logitech_C920-video-index0".to_string(), - ] - ); - } - - #[test] - fn camera_discovery_returns_empty_when_directory_missing() { - let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); - assert!(devices.is_empty()); - } - - #[test] - fn camera_discovery_default_path_is_stable_without_overrides() { - let _ = discover_camera_devices(None); - } - - #[test] - fn camera_mode_parser_keeps_only_supported_lesavka_qualities() { - let stdout = r#" -ioctl: VIDIOC_ENUM_FMT - Type: Video Capture - - [0]: 'MJPG' (Motion-JPEG, compressed) - Size: Discrete 1920x1080 - Interval: Discrete 0.033s (30.000 fps) - Interval: Discrete 0.067s (15.000 fps) - Size: Discrete 1280x720 - Interval: Discrete 0.017s (60.000 fps) - Size: Discrete 640x480 - Interval: Discrete 0.033s (30.000 fps) - [1]: 'YUYV' (YUYV 4:2:2) - Size: Discrete 1920x1080 - Interval: Discrete 0.200s (5.000 fps) -"#; - - assert_eq!( - parse_supported_camera_modes(stdout), - vec![ - CameraMode::new(1920, 1080, 30), - CameraMode::new(1280, 720, 30) - ] - ); - } - - #[test] - fn camera_mode_ids_are_short_and_round_trippable() { - let mode = CameraMode::new(1920, 1080, 30); - assert_eq!(mode.id(), "1920x1080@30"); - assert_eq!(mode.short_label(), "1080p@30"); - assert_eq!(CameraMode::from_id("1920x1080@30"), Some(mode)); - assert_eq!(CameraMode::from_id("not-a-mode"), None); - } - - #[test] - fn discover_uses_override_and_tolerates_missing_pactl() { - let tmp = mk_temp_dir("discover-override"); - std::fs::write(tmp.join("cam"), "").expect("write"); - let catalog = - DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); - assert_eq!(catalog.cameras, vec!["cam".to_string()]); - let _ = std::fs::remove_dir_all(tmp); - } - - #[test] - fn discover_is_stable_with_process_environment_defaults() { - let _ = DeviceCatalog::discover(); - } - - #[test] - fn catalog_empty_reflects_collections() { - let mut catalog = DeviceCatalog::default(); - assert!(catalog.is_empty()); - catalog.speakers.push("sink-1".to_string()); - assert!(!catalog.is_empty()); - } - - #[test] - fn parse_pipewire_audio_nodes_collects_named_audio_nodes() { - let sample = br#" -[ - { - "info": { - "props": { - "media.class": "Audio/Source", - "node.name": "alsa_input.usb-TestMic-00.mono-fallback" - } - } - }, - { - "info": { - "props": { - "media.class": "Audio/Source", - "node.name": "bluez_input.80:C3:BA:76:26:AB" - } - } - }, - { - "info": { - "props": { - "media.class": "Audio/Sink", - "node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo" - } - } - } -] -"#; - assert_eq!( - parse_pipewire_audio_nodes(sample, "Audio/Source"), - vec![ - "alsa_input.usb-TestMic-00.mono-fallback".to_string(), - "bluez_input.80:C3:BA:76:26:AB".to_string(), - ] - ); - } - - #[test] - fn parse_pipewire_audio_nodes_ignores_invalid_payloads() { - assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty()); - } -} +#[path = "tests/devices.rs"] +mod tests; diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index f93213f..c185943 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -1,1213 +1,8 @@ -use serde::{Deserialize, Serialize}; -use std::collections::VecDeque; -use std::fmt::Write as _; - -use super::{ - devices::CameraMode, - state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode}, -}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PerformanceSample { - pub rtt_ms: f32, - pub probe_spread_ms: f32, - pub input_latency_ms: f32, - pub probe_loss_pct: f32, - pub client_process_cpu_pct: f32, - pub server_process_cpu_pct: f32, - pub video_loss_pct: f32, - pub left_receive_fps: f32, - pub left_present_fps: f32, - pub left_server_fps: f32, - pub left_stream_spread_ms: f32, - pub left_packet_gap_peak_ms: f32, - pub left_present_gap_peak_ms: f32, - pub left_queue_depth: u32, - pub left_queue_peak: u32, - pub left_server_source_gap_peak_ms: f32, - pub left_server_send_gap_peak_ms: f32, - pub left_server_queue_peak: u32, - pub left_server_encoder_label: String, - pub left_decoder_label: String, - pub left_stream_caps_label: String, - pub left_decoded_caps_label: String, - pub left_rendered_caps_label: String, - pub right_receive_fps: f32, - pub right_present_fps: f32, - pub right_server_fps: f32, - pub right_stream_spread_ms: f32, - pub right_packet_gap_peak_ms: f32, - pub right_present_gap_peak_ms: f32, - pub right_queue_depth: u32, - pub right_queue_peak: u32, - pub right_server_source_gap_peak_ms: f32, - pub right_server_send_gap_peak_ms: f32, - pub right_server_queue_peak: u32, - pub right_server_encoder_label: String, - pub right_decoder_label: String, - pub right_stream_caps_label: String, - pub right_decoded_caps_label: String, - pub right_rendered_caps_label: String, - pub dropped_frames: u64, - pub queue_depth: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiagnosticsLog { - capacity: usize, - history: VecDeque, -} - -impl DiagnosticsLog { - pub fn new(capacity: usize) -> Self { - let capacity = capacity.max(1); - Self { - capacity, - history: VecDeque::with_capacity(capacity), - } - } - - pub fn record(&mut self, sample: PerformanceSample) { - if self.history.len() == self.capacity { - let _ = self.history.pop_front(); - } - self.history.push_back(sample); - } - - pub fn latest(&self) -> Option<&PerformanceSample> { - self.history.back() - } - - pub fn len(&self) -> usize { - self.history.len() - } - - pub fn is_empty(&self) -> bool { - self.history.is_empty() - } - - pub fn iter(&self) -> impl Iterator { - self.history.iter() - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct MediaChannelState { - pub camera: bool, - pub microphone: bool, - pub audio: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SnapshotReport { - pub client_version: String, - pub server_version: Option, - pub server_available: bool, - pub routing: InputRouting, - pub view_mode: ViewMode, - pub remote_active: bool, - pub power_state: String, - pub client_process_cpu_pct: f32, - pub server_process_cpu_pct: f32, - pub preview_source: String, - pub client_display_limit: String, - pub left_surface: String, - pub left_feed_source: String, - pub left_capture_profile: String, - pub left_capture_transport: String, - pub left_breakout_profile: String, - pub left_decoder_label: String, - pub left_stream_spread_ms: f32, - pub left_packet_gap_peak_ms: f32, - pub left_present_gap_peak_ms: f32, - pub left_queue_depth: u32, - pub left_queue_peak: u32, - pub left_server_source_gap_peak_ms: f32, - pub left_server_send_gap_peak_ms: f32, - pub left_server_queue_peak: u32, - pub left_server_encoder_label: String, - pub left_stream_caps_label: String, - pub left_decoded_caps_label: String, - pub left_rendered_caps_label: String, - pub right_surface: String, - pub right_feed_source: String, - pub right_capture_profile: String, - pub right_capture_transport: String, - pub right_breakout_profile: String, - pub right_decoder_label: String, - pub right_stream_spread_ms: f32, - pub right_packet_gap_peak_ms: f32, - pub right_present_gap_peak_ms: f32, - pub right_queue_depth: u32, - pub right_queue_peak: u32, - pub right_server_source_gap_peak_ms: f32, - pub right_server_send_gap_peak_ms: f32, - pub right_server_queue_peak: u32, - pub right_server_encoder_label: String, - pub right_stream_caps_label: String, - pub right_decoded_caps_label: String, - pub right_rendered_caps_label: String, - pub selected_camera: Option, - pub camera_quality_label: String, - pub selected_microphone: Option, - pub selected_speaker: Option, - pub media_channels: MediaChannelState, - pub audio_gain_label: String, - pub mic_gain_label: String, - pub selected_keyboard: Option, - pub selected_mouse: Option, - pub status: String, - pub recent_samples: Vec, - pub notes: Vec, - pub recommendations: Vec, - pub probe_command: String, -} - -impl SnapshotReport { - pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self { - let left_capture = state - .display_capture_size_choice(0) - .unwrap_or_else(|| state.capture_size_choice(0)); - let right_capture = state - .display_capture_size_choice(1) - .unwrap_or_else(|| state.capture_size_choice(1)); - let left_breakout = state.breakout_size_choice(0); - let right_breakout = state.breakout_size_choice(1); - let latest = log.latest(); - let left_stream_caps = latest - .map(|sample| sample.left_stream_caps_label.clone()) - .unwrap_or_default(); - let right_stream_caps = latest - .map(|sample| sample.right_stream_caps_label.clone()) - .unwrap_or_default(); - Self { - client_version: crate::VERSION.to_string(), - server_version: state.server_version.clone(), - server_available: state.server_available, - routing: state.routing, - view_mode: state.view_mode, - remote_active: state.remote_active, - power_state: format!( - "{} | {} | leases {}", - state.capture_power.mode, - state.capture_power.detail, - state.capture_power.active_leases - ), - client_process_cpu_pct: latest - .map(|sample| sample.client_process_cpu_pct) - .unwrap_or(0.0), - server_process_cpu_pct: latest - .map(|sample| sample.server_process_cpu_pct) - .unwrap_or(0.0), - preview_source: format!( - "{}x{} @ {} fps", - state.preview_source.width, state.preview_source.height, state.preview_source.fps - ), - client_display_limit: format!( - "{}x{}", - state.breakout_display.width, state.breakout_display.height - ), - left_surface: state.display_surface(0).label().to_string(), - left_feed_source: match state.feed_source_preset(0) { - super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(), - super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(), - super::state::FeedSourcePreset::Off => "Off".to_string(), - }, - left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps), - left_capture_transport: left_capture.preset.transport_label().to_string(), - left_breakout_profile: format!( - "{} | {}x{}", - left_breakout.preset.label(), - left_breakout.width, - left_breakout.height - ), - left_decoder_label: latest - .map(|sample| { - if sample.left_decoder_label.is_empty() { - "pending".to_string() - } else { - sample.left_decoder_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - left_stream_spread_ms: latest - .map(|sample| sample.left_stream_spread_ms) - .unwrap_or(0.0), - left_packet_gap_peak_ms: latest - .map(|sample| sample.left_packet_gap_peak_ms) - .unwrap_or(0.0), - left_present_gap_peak_ms: latest - .map(|sample| sample.left_present_gap_peak_ms) - .unwrap_or(0.0), - left_queue_depth: latest.map(|sample| sample.left_queue_depth).unwrap_or(0), - left_queue_peak: latest.map(|sample| sample.left_queue_peak).unwrap_or(0), - left_server_source_gap_peak_ms: latest - .map(|sample| sample.left_server_source_gap_peak_ms) - .unwrap_or(0.0), - left_server_send_gap_peak_ms: latest - .map(|sample| sample.left_server_send_gap_peak_ms) - .unwrap_or(0.0), - left_server_queue_peak: latest - .map(|sample| sample.left_server_queue_peak) - .unwrap_or(0), - left_server_encoder_label: latest - .map(|sample| { - if sample.left_server_encoder_label.is_empty() { - "pending".to_string() - } else { - sample.left_server_encoder_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - left_stream_caps_label: latest - .map(|sample| { - if sample.left_stream_caps_label.is_empty() { - "pending".to_string() - } else { - sample.left_stream_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - left_decoded_caps_label: latest - .map(|sample| { - if sample.left_decoded_caps_label.is_empty() { - "pending".to_string() - } else { - sample.left_decoded_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - left_rendered_caps_label: latest - .map(|sample| { - if sample.left_rendered_caps_label.is_empty() { - "pending".to_string() - } else { - sample.left_rendered_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - right_surface: state.display_surface(1).label().to_string(), - right_feed_source: match state.feed_source_preset(1) { - super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(), - super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(), - super::state::FeedSourcePreset::Off => "Off".to_string(), - }, - right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps), - right_capture_transport: right_capture.preset.transport_label().to_string(), - right_breakout_profile: format!( - "{} | {}x{}", - right_breakout.preset.label(), - right_breakout.width, - right_breakout.height - ), - right_decoder_label: latest - .map(|sample| { - if sample.right_decoder_label.is_empty() { - "pending".to_string() - } else { - sample.right_decoder_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - right_stream_spread_ms: latest - .map(|sample| sample.right_stream_spread_ms) - .unwrap_or(0.0), - right_packet_gap_peak_ms: latest - .map(|sample| sample.right_packet_gap_peak_ms) - .unwrap_or(0.0), - right_present_gap_peak_ms: latest - .map(|sample| sample.right_present_gap_peak_ms) - .unwrap_or(0.0), - right_queue_depth: latest.map(|sample| sample.right_queue_depth).unwrap_or(0), - right_queue_peak: latest.map(|sample| sample.right_queue_peak).unwrap_or(0), - right_server_source_gap_peak_ms: latest - .map(|sample| sample.right_server_source_gap_peak_ms) - .unwrap_or(0.0), - right_server_send_gap_peak_ms: latest - .map(|sample| sample.right_server_send_gap_peak_ms) - .unwrap_or(0.0), - right_server_queue_peak: latest - .map(|sample| sample.right_server_queue_peak) - .unwrap_or(0), - right_server_encoder_label: latest - .map(|sample| { - if sample.right_server_encoder_label.is_empty() { - "pending".to_string() - } else { - sample.right_server_encoder_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - right_stream_caps_label: latest - .map(|sample| { - if sample.right_stream_caps_label.is_empty() { - "pending".to_string() - } else { - sample.right_stream_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - right_decoded_caps_label: latest - .map(|sample| { - if sample.right_decoded_caps_label.is_empty() { - "pending".to_string() - } else { - sample.right_decoded_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - right_rendered_caps_label: latest - .map(|sample| { - if sample.right_rendered_caps_label.is_empty() { - "pending".to_string() - } else { - sample.right_rendered_caps_label.clone() - } - }) - .unwrap_or_else(|| "pending".to_string()), - selected_camera: state.devices.camera.clone(), - camera_quality_label: state - .camera_quality - .map(CameraMode::short_label) - .unwrap_or_else(|| "default".to_string()), - selected_microphone: state.devices.microphone.clone(), - selected_speaker: state.devices.speaker.clone(), - media_channels: MediaChannelState { - camera: state.channels.camera, - microphone: state.channels.microphone, - audio: state.channels.audio, - }, - audio_gain_label: state.audio_gain_label(), - mic_gain_label: state.mic_gain_label(), - selected_keyboard: state.devices.keyboard.clone(), - selected_mouse: state.devices.mouse.clone(), - status: state.status_line(), - recent_samples: log.iter().cloned().collect(), - notes: state.notes.clone(), - recommendations: recommendations_for(state, log), - probe_command, - } - } - - pub fn to_pretty_json(&self) -> Result { - serde_json::to_string_pretty(self) - } - - pub fn to_pretty_text(&self) -> String { - let mut text = String::new(); - let server_version = self.server_version.as_deref().unwrap_or("unknown"); - let server_state = if self.server_available { - "reachable" - } else { - "unreachable" - }; - let _ = writeln!(text, "Lesavka Diagnostics"); - let _ = writeln!(text, "client: v{}", self.client_version); - let _ = writeln!(text, "server: {server_version} ({server_state})"); - let _ = writeln!( - text, - "session: routing={:?} view={:?} relay={} capture_power={}", - self.routing, - self.view_mode, - if self.remote_active { "active" } else { "idle" }, - self.power_state - ); - let _ = writeln!( - text, - "runtime: client CPU {:.1}% | server CPU {:.1}%", - self.client_process_cpu_pct, self.server_process_cpu_pct - ); - let _ = writeln!(text, "source feed: {}", self.preview_source); - let _ = writeln!(text, "display limit: {}", self.client_display_limit); - let _ = writeln!(text); - let _ = writeln!(text, "left eye"); - let _ = writeln!(text, " surface: {}", self.left_surface); - let _ = writeln!(text, " source: {}", self.left_feed_source); - let _ = writeln!(text, " capture: {}", self.left_capture_profile); - let _ = writeln!(text, " transport: {}", self.left_capture_transport); - let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); - let _ = writeln!( - text, - " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", - self.left_decoder_label, - self.left_stream_spread_ms, - self.left_packet_gap_peak_ms, - self.left_present_gap_peak_ms, - self.left_queue_depth, - self.left_queue_peak - ); - let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label); - let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); - let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label); - let _ = writeln!( - text, - " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", - self.left_server_encoder_label, - self.server_process_cpu_pct, - self.left_server_source_gap_peak_ms, - self.left_server_send_gap_peak_ms, - self.left_server_queue_peak - ); - let _ = writeln!(text, "right eye"); - let _ = writeln!(text, " surface: {}", self.right_surface); - let _ = writeln!(text, " source: {}", self.right_feed_source); - let _ = writeln!(text, " capture: {}", self.right_capture_profile); - let _ = writeln!(text, " transport: {}", self.right_capture_transport); - let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); - let _ = writeln!( - text, - " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", - self.right_decoder_label, - self.right_stream_spread_ms, - self.right_packet_gap_peak_ms, - self.right_present_gap_peak_ms, - self.right_queue_depth, - self.right_queue_peak - ); - let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label); - let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); - let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label); - let _ = writeln!( - text, - " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", - self.right_server_encoder_label, - self.server_process_cpu_pct, - self.right_server_source_gap_peak_ms, - self.right_server_send_gap_peak_ms, - self.right_server_queue_peak - ); - let _ = writeln!(text); - let _ = writeln!(text, "media staging"); - let _ = writeln!( - text, - " camera: {} | quality={} | enabled={}", - self.selected_camera.as_deref().unwrap_or("auto"), - self.camera_quality_label, - self.media_channels.camera - ); - let _ = writeln!( - text, - " speaker: {} | volume={} | enabled={}", - self.selected_speaker.as_deref().unwrap_or("auto"), - self.audio_gain_label, - self.media_channels.audio - ); - let _ = writeln!( - text, - " microphone: {} | gain={} | enabled={}", - self.selected_microphone.as_deref().unwrap_or("auto"), - self.mic_gain_label, - self.media_channels.microphone - ); - let _ = writeln!( - text, - " keyboard: {}", - self.selected_keyboard.as_deref().unwrap_or("all") - ); - let _ = writeln!( - text, - " mouse: {}", - self.selected_mouse.as_deref().unwrap_or("all") - ); - let _ = writeln!(text); - let _ = writeln!(text, "current UI state"); - let _ = writeln!(text, " {}", self.status); - let _ = writeln!(text); - let _ = writeln!(text, "recent samples"); - if self.recent_samples.is_empty() { - let _ = writeln!( - text, - " no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot." - ); - } else { - for sample in &self.recent_samples { - let _ = writeln!( - text, - " rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}", - sample.rtt_ms, - sample.probe_spread_ms, - sample.input_latency_ms, - sample.client_process_cpu_pct, - sample.server_process_cpu_pct, - sample.probe_loss_pct, - sample.video_loss_pct, - sample.left_receive_fps, - sample.left_present_fps, - sample.left_server_fps, - sample.right_receive_fps, - sample.right_present_fps, - sample.right_server_fps, - sample.dropped_frames, - sample.queue_depth, - sample.left_queue_peak.max(sample.right_queue_peak), - sample.left_packet_gap_peak_ms, - sample.left_present_gap_peak_ms, - sample.right_packet_gap_peak_ms, - sample.right_present_gap_peak_ms, - sample.left_server_encoder_label, - sample.left_server_source_gap_peak_ms, - sample.left_server_send_gap_peak_ms, - sample.left_server_queue_peak, - sample.right_server_encoder_label, - sample.right_server_source_gap_peak_ms, - sample.right_server_send_gap_peak_ms, - sample.right_server_queue_peak - ); - } - } - let _ = writeln!(text); - let _ = writeln!(text, "recommendations"); - for item in &self.recommendations { - let _ = writeln!(text, " - {item}"); - } - if !self.notes.is_empty() { - let _ = writeln!(text); - let _ = writeln!(text, "notes"); - for item in &self.notes { - let _ = writeln!(text, " - {item}"); - } - } - let _ = writeln!(text); - let _ = writeln!(text, "quality probe"); - let _ = writeln!(text, " {}", self.probe_command); - text - } -} - -pub fn quality_probe_command() -> &'static str { - "scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh" -} - -fn capture_profile_label(capture: &CaptureSizeChoice, stream_caps_label: &str) -> String { - if let Some((width, height, fps)) = parse_stream_caps_profile(stream_caps_label) { - return format!( - "{} | observed {}x{} @ {} fps | bitrate est ~{} kbit", - capture.preset.label(), - width, - height, - fps, - capture.max_bitrate_kbit - ); - } - format!( - "{} | {}x{} | {} fps | bitrate est ~{} kbit", - capture.preset.label(), - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit - ) -} - -fn parse_stream_caps_profile(caps: &str) -> Option<(u32, u32, u32)> { - let width = parse_caps_u32(caps, "width=(int)")?; - let height = parse_caps_u32(caps, "height=(int)")?; - let fps = parse_caps_fraction_numerator(caps, "framerate=(fraction)")?; - Some((width, height, fps)) -} - -fn parse_caps_u32(caps: &str, needle: &str) -> Option { - let start = caps.find(needle)? + needle.len(); - let tail = &caps[start..]; - let end = tail.find([',', ';']).unwrap_or(tail.len()); - tail[..end].trim().parse::().ok() -} - -fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option { - let start = caps.find(needle)? + needle.len(); - let tail = &caps[start..]; - let end = tail.find([',', ';']).unwrap_or(tail.len()); - let value = tail[..end].trim(); - let numerator = value.split('/').next()?; - numerator.parse::().ok() -} - -fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec { - let mut items = Vec::new(); - let hardware_decode_active = log.latest().is_some_and(sample_uses_hardware_decode); - let software_decode_active = log.latest().is_some_and(sample_uses_software_decode); - if !state.server_available { - items.push( - "The server is not reachable from this launcher yet, so stream-quality results would not be meaningful." - .to_string(), - ); - } - if log.is_empty() { - items.push( - "Live stream samples will appear here after the launcher collects a few probe windows. Leave the relay up for a few seconds to populate RTT, probe spread, loss, and fps." - .to_string(), - ); - } - if let Some(sample) = log.latest() { - if sample.probe_loss_pct >= 3.0 || sample.probe_spread_ms >= 18.0 { - items.push( - "Control-plane probe spread or loss is elevated. That can come from the network or from server stalls, so compare it against the eye fps before blaming the WAN." - .to_string(), - ); - } - if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 { - items.push( - "Video packets are arriving with gaps or server-side drops. Stay on device H.264 pass-through for now and reduce concurrent load before trying more invasive changes." - .to_string(), - ); - } - if sample.left_present_fps + 1.0 < sample.left_receive_fps - || sample.right_present_fps + 1.0 < sample.right_receive_fps - { - items.push(if hardware_decode_active { - "The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or a cheaper source mode before adding bitrate." - .to_string() - } else { - "The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or hardware decode." - .to_string() - }); - } - if (sample.left_present_gap_peak_ms - sample.left_packet_gap_peak_ms) > 40.0 - || (sample.right_present_gap_peak_ms - sample.right_packet_gap_peak_ms) > 40.0 - { - items.push( - "Present-gap spikes are materially larger than packet-gap spikes. That usually means the client decode/render path is stalling after packets arrive." - .to_string(), - ); - } - if sample.queue_depth > 8 { - items.push( - "The preview queue is backing up. When queue depth climbs, expect laggy mouse feel and delayed visual response even if raw fps still looks okay." - .to_string(), - ); - } - if sample.left_queue_peak >= 4 || sample.right_queue_peak >= 4 { - items.push( - "Queue depth is spiking even if the latest sample looks calm. That points at bursty backpressure rather than steady-state overload." - .to_string(), - ); - } - if (sample.left_packet_gap_peak_ms - sample.left_server_send_gap_peak_ms) > 60.0 - || (sample.right_packet_gap_peak_ms - sample.right_server_send_gap_peak_ms) > 60.0 - { - items.push( - "Client packet-gap spikes are much larger than the server's send-gap peaks. That points away from the server pipeline and toward network burstiness or client-side receive scheduling." - .to_string(), - ); - } - if sample.left_server_source_gap_peak_ms >= 120.0 - || sample.right_server_source_gap_peak_ms >= 120.0 - { - items.push( - "The server is seeing large source-frame gaps before packets even leave the box. That points at capture cadence or server-side pipeline stalls more than WAN loss." - .to_string(), - ); - } - if sample.left_server_queue_peak >= 4 || sample.right_server_queue_peak >= 4 { - items.push( - "The server-side stream queue is peaking above its steady state. That suggests bursty backpressure is already forming before the client sees it." - .to_string(), - ); - } - if sample.client_process_cpu_pct >= 85.0 { - items.push(if hardware_decode_active { - "Client process CPU is high even though hardware decode is active. If motion still looks rough, favor lighter breakout layouts or a cheaper source mode before adding more bitrate." - .to_string() - } else { - "Client process CPU is high. If motion still looks rough, favor lighter breakout layouts or a hardware decoder before adding more bitrate." - .to_string() - }); - } - if sample.server_process_cpu_pct >= 85.0 { - items.push( - "Server process CPU is high. On current hardware that is a strong reason to stay on device H.264 pass-through and avoid any server-side eye transcoding." - .to_string(), - ); - } - } - let source_passthrough = state - .feed_sources - .iter() - .any(|preset| !matches!(preset, FeedSourcePreset::Off)); - if source_passthrough { - items.push( - "Device H.264 pass-through is active. On current GC311 hardware, prefer the real 1080p/720p source modes. The lower SD/VGA modes are intentionally retired because they center-cut widescreen HDMI sources." - .to_string(), - ); - } - if let Some(sample) = log.latest() - && sample.video_loss_pct < 0.5 - && sample.dropped_frames == 0 - && ((sample.left_server_fps - sample.left_receive_fps) > 6.0 - || (sample.right_server_fps - sample.right_receive_fps) > 6.0) - { - items.push( - "Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss." - .to_string(), - ); - } - if let Some(sample) = log.latest() - && sample.video_loss_pct < 0.5 - && sample.dropped_frames == 0 - && (sample.left_packet_gap_peak_ms >= 140.0 || sample.right_packet_gap_peak_ms >= 140.0) - { - items.push( - "Packet-gap spikes are high without packet loss. That means the stream is arriving in bursts, which usually points at source cadence, encoder stalls, or local decoder starvation more than raw WAN loss." - .to_string(), - ); - } - if let Some(sample) = log.latest() - && software_decode_active - && ((sample.left_decoder_label.contains("avdec") - && sample.left_present_fps + 1.0 < sample.left_receive_fps) - || (sample.right_decoder_label.contains("avdec") - && sample.right_present_fps + 1.0 < sample.right_receive_fps)) - { - items.push( - "At least one eye is falling back to `avdec_*` while presentation lags behind receive. A hardware decode path would likely help more than extra bitrate." - .to_string(), - ); - } - if let Some(sample) = log.latest() - && sample.server_process_cpu_pct >= 70.0 - && (sample.left_server_encoder_label.contains("x264") - || sample.right_server_encoder_label.contains("x264")) - { - items.push( - "At least one eye is still leaning on `x264enc`. That is now unexpected on the source-first path, so treat it as a bug or stale install rather than a normal operating mode." - .to_string(), - ); - } - if state.breakout_count() == 2 { - items.push( - "Both eye feeds are broken out right now. If the client starts struggling, compare in-launcher preview smoothness against full-window decode." - .to_string(), - ); - } - if items.is_empty() { - items.push("Session state looks stable. Collect a few real samples before changing capture settings.".to_string()); - } - items -} - -fn sample_uses_hardware_decode(sample: &PerformanceSample) -> bool { - decoder_label_is_hardware(&sample.left_decoder_label) - || decoder_label_is_hardware(&sample.right_decoder_label) -} - -fn sample_uses_software_decode(sample: &PerformanceSample) -> bool { - sample.left_decoder_label.contains("avdec") || sample.right_decoder_label.contains("avdec") -} - -fn decoder_label_is_hardware(label: &str) -> bool { - let lower = label.to_ascii_lowercase(); - lower.contains("nvh264dec") - || lower.contains("nvdec") - || lower.contains("vah264dec") - || lower.contains("vaapih264dec") - || lower.contains("v4l2slh264dec") - || lower.contains("d3d11") - || lower.contains("vtdec") -} +// Launcher diagnostics snapshots, summaries, and operator recommendations. +include!("diagnostics/diagnostics_models.rs"); +include!("diagnostics/snapshot_report.rs"); +include!("diagnostics/recommendations.rs"); #[cfg(test)] -mod tests { - use super::*; - use crate::launcher::state::{ - CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState, - }; - - fn sample(n: u64) -> PerformanceSample { - PerformanceSample { - rtt_ms: 20.0 + n as f32, - probe_spread_ms: 3.0 + n as f32, - input_latency_ms: 10.0 + n as f32, - probe_loss_pct: n as f32, - client_process_cpu_pct: 12.5 + n as f32, - server_process_cpu_pct: 22.5 + n as f32, - video_loss_pct: (n as f32) * 0.5, - left_receive_fps: 30.0, - left_present_fps: 29.0, - left_server_fps: 30.0, - left_stream_spread_ms: 4.0, - left_packet_gap_peak_ms: 55.0, - left_present_gap_peak_ms: 60.0, - left_queue_depth: n as u32, - left_queue_peak: n as u32, - left_server_source_gap_peak_ms: 42.0, - left_server_send_gap_peak_ms: 48.0, - left_server_queue_peak: n as u32 + 1, - left_server_encoder_label: "x264enc".to_string(), - left_decoder_label: "decodebin".to_string(), - left_stream_caps_label: - "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1" - .to_string(), - left_decoded_caps_label: - "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), - left_rendered_caps_label: - "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), - right_receive_fps: 30.0, - right_present_fps: 28.0, - right_server_fps: 30.0, - right_stream_spread_ms: 5.0, - right_packet_gap_peak_ms: 65.0, - right_present_gap_peak_ms: 75.0, - right_queue_depth: n as u32, - right_queue_peak: n as u32, - right_server_source_gap_peak_ms: 51.0, - right_server_send_gap_peak_ms: 58.0, - right_server_queue_peak: n as u32 + 1, - right_server_encoder_label: "source-pass-through".to_string(), - right_decoder_label: "decodebin".to_string(), - right_stream_caps_label: - "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1" - .to_string(), - right_decoded_caps_label: - "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), - right_rendered_caps_label: - "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), - dropped_frames: n, - queue_depth: n as u32, - } - } - - #[test] - fn diagnostics_log_keeps_only_latest_samples_with_capacity() { - let mut log = DiagnosticsLog::new(2); - log.record(sample(1)); - log.record(sample(2)); - log.record(sample(3)); - - let kept: Vec = log.iter().map(|item| item.dropped_frames).collect(); - assert_eq!(kept, vec![2, 3]); - assert_eq!(log.latest().map(|s| s.dropped_frames), Some(3)); - } - - #[test] - fn diagnostics_log_enforces_minimum_capacity() { - let mut log = DiagnosticsLog::new(0); - log.record(sample(1)); - log.record(sample(2)); - assert_eq!(log.len(), 1); - assert_eq!(log.latest().map(|s| s.dropped_frames), Some(2)); - } - - #[test] - fn snapshot_report_contains_state_fields_and_samples() { - let mut state = LauncherState::new(); - state.devices = DeviceSelection { - camera: Some("/dev/video0".to_string()), - microphone: Some("alsa_input.usb".to_string()), - speaker: Some("alsa_output.usb".to_string()), - keyboard: Some("/dev/input/event10".to_string()), - mouse: Some("/dev/input/event11".to_string()), - }; - state.push_note("first note"); - - let mut log = DiagnosticsLog::new(4); - log.record(sample(7)); - - let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); - assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0")); - assert_eq!( - report.selected_microphone.as_deref(), - Some("alsa_input.usb") - ); - assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); - assert_eq!(report.audio_gain_label, "200%"); - assert_eq!( - report.selected_keyboard.as_deref(), - Some("/dev/input/event10") - ); - assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11")); - assert_eq!(report.recent_samples.len(), 1); - assert_eq!(report.notes, vec!["first note".to_string()]); - assert!(report.status.contains("mode=remote")); - assert!(report.client_version.starts_with("0.")); - assert_eq!(report.left_feed_source, "Left Eye"); - assert!( - report - .left_capture_profile - .contains("observed 1920x1080 @ 60 fps") - ); - assert_eq!(report.left_capture_transport, "device H.264 pass-through"); - assert_eq!(report.left_decoder_label, "decodebin"); - assert!(report.left_stream_caps_label.contains("video/x-h264")); - assert!(report.left_decoded_caps_label.contains("video/x-raw")); - assert!(report.left_rendered_caps_label.contains("video/x-raw")); - } - - #[test] - fn snapshot_report_marks_empty_live_labels_pending() { - let mut log = DiagnosticsLog::new(1); - let mut sample = sample(0); - sample.left_decoder_label.clear(); - sample.left_server_encoder_label.clear(); - sample.left_stream_caps_label.clear(); - sample.left_decoded_caps_label.clear(); - sample.left_rendered_caps_label.clear(); - sample.right_decoder_label.clear(); - sample.right_server_encoder_label.clear(); - sample.right_stream_caps_label.clear(); - sample.right_decoded_caps_label.clear(); - sample.right_rendered_caps_label.clear(); - log.record(sample); - - let mut state = LauncherState::new(); - state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); - let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); - - assert_eq!(report.left_decoder_label, "pending"); - assert_eq!(report.left_server_encoder_label, "pending"); - assert_eq!(report.left_stream_caps_label, "pending"); - assert_eq!(report.left_decoded_caps_label, "pending"); - assert_eq!(report.left_rendered_caps_label, "pending"); - assert_eq!(report.right_feed_source, "Left Eye (mirrored)"); - assert_eq!(report.right_decoder_label, "pending"); - assert_eq!(report.right_server_encoder_label, "pending"); - assert_eq!(report.right_stream_caps_label, "pending"); - assert_eq!(report.right_decoded_caps_label, "pending"); - assert_eq!(report.right_rendered_caps_label, "pending"); - } - - #[test] - fn snapshot_json_is_serializable_and_mentions_probe_command() { - let report = SnapshotReport::from_state( - &LauncherState::new(), - &DiagnosticsLog::new(1), - quality_probe_command().to_string(), - ); - let json = report.to_pretty_json().expect("serialize"); - assert!(json.contains("quality_gate.sh")); - assert!(json.contains("routing")); - assert!(json.contains("view_mode")); - } - - #[test] - fn snapshot_text_mentions_versions_profiles_and_recommendations() { - let report = SnapshotReport::from_state( - &LauncherState::new(), - &DiagnosticsLog::new(1), - quality_probe_command().to_string(), - ); - let text = report.to_pretty_text(); - assert!(text.contains("Lesavka Diagnostics")); - assert!(text.contains("client: v")); - assert!(text.contains("left eye")); - assert!(text.contains("source:")); - assert!(text.contains("transport:")); - assert!(text.contains("live: decoder=")); - assert!(text.contains("stream caps:")); - assert!(text.contains("decoded caps:")); - assert!(text.contains("rendered caps:")); - assert!(text.contains("media staging")); - assert!(text.contains("current UI state")); - assert!(text.contains("recommendations")); - } - - #[test] - #[doc = "Verifies diagnostics text follows live media settings."] - fn snapshot_text_reflects_live_media_control_changes() { - let mut state = LauncherState::new(); - state.select_camera(Some("/dev/video9".to_string())); - state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new( - 1920, 1080, 30, - ))); - state.select_microphone(Some("alsa_input.usb".to_string())); - state.select_speaker(Some("alsa_output.usb".to_string())); - state.set_audio_gain_percent(250); - state.set_mic_gain_percent(125); - state.set_camera_channel_enabled(false); - state.set_microphone_channel_enabled(true); - - let report = SnapshotReport::from_state( - &state, - &DiagnosticsLog::new(1), - quality_probe_command().to_string(), - ); - let text = report.to_pretty_text(); - - assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false")); - assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true")); - assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true")); - } - - #[test] - fn snapshot_text_renders_recent_samples_and_notes() { - let mut state = LauncherState::new(); - state.set_server_available(true); - state.push_note("operator changed camera quality during the run"); - let mut log = DiagnosticsLog::new(2); - log.record(sample(3)); - - let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); - let text = report.to_pretty_text(); - - assert!(text.contains("server: unknown (reachable)")); - assert!(text.contains("rtt=23.0ms")); - assert!(text.contains("server=lx264enc:42/48/4")); - assert!(text.contains("notes")); - assert!(text.contains("operator changed camera quality during the run")); - } - - #[test] - fn snapshot_report_uses_effective_mirrored_capture_profile() { - let mut state = LauncherState::new(); - state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); - state.set_capture_size_preset(1, CaptureSizePreset::P720); - - let report = SnapshotReport::from_state( - &state, - &DiagnosticsLog::new(1), - quality_probe_command().to_string(), - ); - - assert_eq!(report.left_feed_source, "Right Eye (mirrored)"); - assert!(report.left_capture_profile.contains("720p")); - assert!(report.left_capture_profile.contains("1280x720")); - } - - #[test] - fn quality_probe_command_mentions_both_gates() { - let cmd = quality_probe_command(); - assert!(cmd.contains("hygiene_gate.sh")); - assert!(cmd.contains("quality_gate.sh")); - } - - #[test] - fn source_capture_profile_prefers_observed_stream_caps_when_available() { - let capture = CaptureSizeChoice { - preset: CaptureSizePreset::P1080, - width: 1920, - height: 1080, - fps: 60, - max_bitrate_kbit: 18_000, - }; - let label = capture_profile_label( - &capture, - "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1", - ); - assert_eq!( - label, - "1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit" - ); - } - - #[test] - fn capture_profile_falls_back_when_stream_caps_are_incomplete() { - let capture = CaptureSizeChoice { - preset: CaptureSizePreset::P1080, - width: 1920, - height: 1080, - fps: 60, - max_bitrate_kbit: 18_000, - }; - let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920"); - assert_eq!( - label, - "1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit" - ); - } - - #[test] - fn recommendations_do_not_suggest_hardware_decode_when_nvdec_is_active() { - let mut log = DiagnosticsLog::new(1); - let mut sample = sample(1); - sample.client_process_cpu_pct = 96.0; - sample.left_receive_fps = 40.0; - sample.left_present_fps = 30.0; - sample.left_decoder_label = "nvh264dec".to_string(); - sample.right_decoder_label = "nvh264dec".to_string(); - log.record(sample); - - let items = recommendations_for(&LauncherState::new(), &log); - let joined = items.join("\n"); - assert!(!joined.contains("hardware decoder before adding more bitrate")); - assert!(!joined.contains("lighter breakout sizes or hardware decode")); - assert!(joined.contains("cheaper source mode")); - } - - #[test] - fn recommendations_cover_video_network_queue_cpu_and_decoder_pressure() { - let mut state = LauncherState::new(); - state.set_server_available(true); - state.set_display_surface(0, DisplaySurface::Window); - state.set_display_surface(1, DisplaySurface::Window); - - let mut sample = sample(12); - sample.probe_loss_pct = 4.0; - sample.probe_spread_ms = 22.0; - sample.video_loss_pct = 3.0; - sample.dropped_frames = 2; - sample.left_receive_fps = 58.0; - sample.left_present_fps = 42.0; - sample.right_receive_fps = 58.0; - sample.right_present_fps = 42.0; - sample.left_packet_gap_peak_ms = 180.0; - sample.right_packet_gap_peak_ms = 181.0; - sample.left_present_gap_peak_ms = 250.0; - sample.right_present_gap_peak_ms = 260.0; - sample.queue_depth = 9; - sample.left_queue_peak = 5; - sample.right_queue_peak = 5; - sample.left_server_send_gap_peak_ms = 40.0; - sample.right_server_send_gap_peak_ms = 40.0; - sample.left_server_source_gap_peak_ms = 130.0; - sample.right_server_source_gap_peak_ms = 131.0; - sample.left_server_queue_peak = 5; - sample.right_server_queue_peak = 5; - sample.client_process_cpu_pct = 90.0; - sample.server_process_cpu_pct = 88.0; - sample.left_decoder_label = "avdec_h264".to_string(); - sample.right_decoder_label = "avdec_h264".to_string(); - sample.left_server_encoder_label = "x264enc".to_string(); - sample.right_server_encoder_label = "x264enc".to_string(); - - let mut log = DiagnosticsLog::new(1); - log.record(sample); - let joined = recommendations_for(&state, &log).join("\n"); - - for needle in [ - "Control-plane probe spread or loss is elevated", - "Video packets are arriving with gaps", - "receiving more frames than it is presenting", - "Present-gap spikes are materially larger", - "preview queue is backing up", - "Queue depth is spiking", - "Client packet-gap spikes are much larger", - "large source-frame gaps", - "server-side stream queue is peaking", - "Client process CPU is high", - "Server process CPU is high", - "Device H.264 pass-through is active", - "At least one eye is falling back", - "At least one eye is still leaning on `x264enc`", - "Both eye feeds are broken out", - ] { - assert!(joined.contains(needle), "{needle} missing from {joined}"); - } - } - - #[test] - fn recommendations_cover_low_receive_fps_and_bursty_gap_without_loss() { - let mut sample = sample(0); - sample.video_loss_pct = 0.0; - sample.dropped_frames = 0; - sample.left_server_fps = 60.0; - sample.left_receive_fps = 48.0; - sample.right_server_fps = 60.0; - sample.right_receive_fps = 48.0; - sample.left_packet_gap_peak_ms = 150.0; - sample.right_packet_gap_peak_ms = 151.0; - - let mut log = DiagnosticsLog::new(1); - log.record(sample); - let joined = recommendations_for(&LauncherState::new(), &log).join("\n"); - - assert!(joined.contains("Receive fps is well below the target without packet loss")); - assert!(joined.contains("Packet-gap spikes are high without packet loss")); - } - - #[test] - fn hardware_decoder_detection_recognizes_nvdec_labels() { - let mut sample = sample(1); - sample.left_decoder_label = "nvh264dec".to_string(); - assert!(sample_uses_hardware_decode(&sample)); - assert!(!sample_uses_software_decode(&sample)); - } -} +#[path = "tests/diagnostics.rs"] +mod tests; diff --git a/client/src/launcher/diagnostics/diagnostics_models.rs b/client/src/launcher/diagnostics/diagnostics_models.rs new file mode 100644 index 0000000..7376c38 --- /dev/null +++ b/client/src/launcher/diagnostics/diagnostics_models.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::fmt::Write as _; + +use super::{ + devices::CameraMode, + state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PerformanceSample { + pub rtt_ms: f32, + pub probe_spread_ms: f32, + pub input_latency_ms: f32, + pub probe_loss_pct: f32, + pub client_process_cpu_pct: f32, + pub server_process_cpu_pct: f32, + pub video_loss_pct: f32, + pub left_receive_fps: f32, + pub left_present_fps: f32, + pub left_server_fps: f32, + pub left_stream_spread_ms: f32, + pub left_packet_gap_peak_ms: f32, + pub left_present_gap_peak_ms: f32, + pub left_queue_depth: u32, + pub left_queue_peak: u32, + pub left_server_source_gap_peak_ms: f32, + pub left_server_send_gap_peak_ms: f32, + pub left_server_queue_peak: u32, + pub left_server_encoder_label: String, + pub left_decoder_label: String, + pub left_stream_caps_label: String, + pub left_decoded_caps_label: String, + pub left_rendered_caps_label: String, + pub right_receive_fps: f32, + pub right_present_fps: f32, + pub right_server_fps: f32, + pub right_stream_spread_ms: f32, + pub right_packet_gap_peak_ms: f32, + pub right_present_gap_peak_ms: f32, + pub right_queue_depth: u32, + pub right_queue_peak: u32, + pub right_server_source_gap_peak_ms: f32, + pub right_server_send_gap_peak_ms: f32, + pub right_server_queue_peak: u32, + pub right_server_encoder_label: String, + pub right_decoder_label: String, + pub right_stream_caps_label: String, + pub right_decoded_caps_label: String, + pub right_rendered_caps_label: String, + pub dropped_frames: u64, + pub queue_depth: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticsLog { + capacity: usize, + history: VecDeque, +} + +impl DiagnosticsLog { + pub fn new(capacity: usize) -> Self { + let capacity = capacity.max(1); + Self { + capacity, + history: VecDeque::with_capacity(capacity), + } + } + + pub fn record(&mut self, sample: PerformanceSample) { + if self.history.len() == self.capacity { + let _ = self.history.pop_front(); + } + self.history.push_back(sample); + } + + pub fn latest(&self) -> Option<&PerformanceSample> { + self.history.back() + } + + pub fn len(&self) -> usize { + self.history.len() + } + + pub fn is_empty(&self) -> bool { + self.history.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.history.iter() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MediaChannelState { + pub camera: bool, + pub microphone: bool, + pub audio: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotReport { + pub client_version: String, + pub server_version: Option, + pub server_available: bool, + pub routing: InputRouting, + pub view_mode: ViewMode, + pub remote_active: bool, + pub power_state: String, + pub client_process_cpu_pct: f32, + pub server_process_cpu_pct: f32, + pub preview_source: String, + pub client_display_limit: String, + pub left_surface: String, + pub left_feed_source: String, + pub left_capture_profile: String, + pub left_capture_transport: String, + pub left_breakout_profile: String, + pub left_decoder_label: String, + pub left_stream_spread_ms: f32, + pub left_packet_gap_peak_ms: f32, + pub left_present_gap_peak_ms: f32, + pub left_queue_depth: u32, + pub left_queue_peak: u32, + pub left_server_source_gap_peak_ms: f32, + pub left_server_send_gap_peak_ms: f32, + pub left_server_queue_peak: u32, + pub left_server_encoder_label: String, + pub left_stream_caps_label: String, + pub left_decoded_caps_label: String, + pub left_rendered_caps_label: String, + pub right_surface: String, + pub right_feed_source: String, + pub right_capture_profile: String, + pub right_capture_transport: String, + pub right_breakout_profile: String, + pub right_decoder_label: String, + pub right_stream_spread_ms: f32, + pub right_packet_gap_peak_ms: f32, + pub right_present_gap_peak_ms: f32, + pub right_queue_depth: u32, + pub right_queue_peak: u32, + pub right_server_source_gap_peak_ms: f32, + pub right_server_send_gap_peak_ms: f32, + pub right_server_queue_peak: u32, + pub right_server_encoder_label: String, + pub right_stream_caps_label: String, + pub right_decoded_caps_label: String, + pub right_rendered_caps_label: String, + pub selected_camera: Option, + pub camera_quality_label: String, + pub selected_microphone: Option, + pub selected_speaker: Option, + pub media_channels: MediaChannelState, + pub audio_gain_label: String, + pub mic_gain_label: String, + pub selected_keyboard: Option, + pub selected_mouse: Option, + pub status: String, + pub recent_samples: Vec, + pub notes: Vec, + pub recommendations: Vec, + pub probe_command: String, +} diff --git a/client/src/launcher/diagnostics/recommendations.rs b/client/src/launcher/diagnostics/recommendations.rs new file mode 100644 index 0000000..ba81e93 --- /dev/null +++ b/client/src/launcher/diagnostics/recommendations.rs @@ -0,0 +1,230 @@ +pub fn quality_probe_command() -> &'static str { + "scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh" +} + +fn capture_profile_label(capture: &CaptureSizeChoice, stream_caps_label: &str) -> String { + if let Some((width, height, fps)) = parse_stream_caps_profile(stream_caps_label) { + return format!( + "{} | observed {}x{} @ {} fps | bitrate est ~{} kbit", + capture.preset.label(), + width, + height, + fps, + capture.max_bitrate_kbit + ); + } + format!( + "{} | {}x{} | {} fps | bitrate est ~{} kbit", + capture.preset.label(), + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit + ) +} + +fn parse_stream_caps_profile(caps: &str) -> Option<(u32, u32, u32)> { + let width = parse_caps_u32(caps, "width=(int)")?; + let height = parse_caps_u32(caps, "height=(int)")?; + let fps = parse_caps_fraction_numerator(caps, "framerate=(fraction)")?; + Some((width, height, fps)) +} + +fn parse_caps_u32(caps: &str, needle: &str) -> Option { + let start = caps.find(needle)? + needle.len(); + let tail = &caps[start..]; + let end = tail.find([',', ';']).unwrap_or(tail.len()); + tail[..end].trim().parse::().ok() +} + +fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option { + let start = caps.find(needle)? + needle.len(); + let tail = &caps[start..]; + let end = tail.find([',', ';']).unwrap_or(tail.len()); + let value = tail[..end].trim(); + let numerator = value.split('/').next()?; + numerator.parse::().ok() +} + +fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec { + let mut items = Vec::new(); + let hardware_decode_active = log.latest().is_some_and(sample_uses_hardware_decode); + let software_decode_active = log.latest().is_some_and(sample_uses_software_decode); + if !state.server_available { + items.push( + "The server is not reachable from this launcher yet, so stream-quality results would not be meaningful." + .to_string(), + ); + } + if log.is_empty() { + items.push( + "Live stream samples will appear here after the launcher collects a few probe windows. Leave the relay up for a few seconds to populate RTT, probe spread, loss, and fps." + .to_string(), + ); + } + if let Some(sample) = log.latest() { + if sample.probe_loss_pct >= 3.0 || sample.probe_spread_ms >= 18.0 { + items.push( + "Control-plane probe spread or loss is elevated. That can come from the network or from server stalls, so compare it against the eye fps before blaming the WAN." + .to_string(), + ); + } + if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 { + items.push( + "Video packets are arriving with gaps or server-side drops. Stay on device H.264 pass-through for now and reduce concurrent load before trying more invasive changes." + .to_string(), + ); + } + if sample.left_present_fps + 1.0 < sample.left_receive_fps + || sample.right_present_fps + 1.0 < sample.right_receive_fps + { + items.push(if hardware_decode_active { + "The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or a cheaper source mode before adding bitrate." + .to_string() + } else { + "The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or hardware decode." + .to_string() + }); + } + if (sample.left_present_gap_peak_ms - sample.left_packet_gap_peak_ms) > 40.0 + || (sample.right_present_gap_peak_ms - sample.right_packet_gap_peak_ms) > 40.0 + { + items.push( + "Present-gap spikes are materially larger than packet-gap spikes. That usually means the client decode/render path is stalling after packets arrive." + .to_string(), + ); + } + if sample.queue_depth > 8 { + items.push( + "The preview queue is backing up. When queue depth climbs, expect laggy mouse feel and delayed visual response even if raw fps still looks okay." + .to_string(), + ); + } + if sample.left_queue_peak >= 4 || sample.right_queue_peak >= 4 { + items.push( + "Queue depth is spiking even if the latest sample looks calm. That points at bursty backpressure rather than steady-state overload." + .to_string(), + ); + } + if (sample.left_packet_gap_peak_ms - sample.left_server_send_gap_peak_ms) > 60.0 + || (sample.right_packet_gap_peak_ms - sample.right_server_send_gap_peak_ms) > 60.0 + { + items.push( + "Client packet-gap spikes are much larger than the server's send-gap peaks. That points away from the server pipeline and toward network burstiness or client-side receive scheduling." + .to_string(), + ); + } + if sample.left_server_source_gap_peak_ms >= 120.0 + || sample.right_server_source_gap_peak_ms >= 120.0 + { + items.push( + "The server is seeing large source-frame gaps before packets even leave the box. That points at capture cadence or server-side pipeline stalls more than WAN loss." + .to_string(), + ); + } + if sample.left_server_queue_peak >= 4 || sample.right_server_queue_peak >= 4 { + items.push( + "The server-side stream queue is peaking above its steady state. That suggests bursty backpressure is already forming before the client sees it." + .to_string(), + ); + } + if sample.client_process_cpu_pct >= 85.0 { + items.push(if hardware_decode_active { + "Client process CPU is high even though hardware decode is active. If motion still looks rough, favor lighter breakout layouts or a cheaper source mode before adding more bitrate." + .to_string() + } else { + "Client process CPU is high. If motion still looks rough, favor lighter breakout layouts or a hardware decoder before adding more bitrate." + .to_string() + }); + } + if sample.server_process_cpu_pct >= 85.0 { + items.push( + "Server process CPU is high. On current hardware that is a strong reason to stay on device H.264 pass-through and avoid any server-side eye transcoding." + .to_string(), + ); + } + } + let source_passthrough = state + .feed_sources + .iter() + .any(|preset| !matches!(preset, FeedSourcePreset::Off)); + if source_passthrough { + items.push( + "Device H.264 pass-through is active. On current GC311 hardware, prefer the real 1080p/720p source modes. The lower SD/VGA modes are intentionally retired because they center-cut widescreen HDMI sources." + .to_string(), + ); + } + if let Some(sample) = log.latest() + && sample.video_loss_pct < 0.5 + && sample.dropped_frames == 0 + && ((sample.left_server_fps - sample.left_receive_fps) > 6.0 + || (sample.right_server_fps - sample.right_receive_fps) > 6.0) + { + items.push( + "Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss." + .to_string(), + ); + } + if let Some(sample) = log.latest() + && sample.video_loss_pct < 0.5 + && sample.dropped_frames == 0 + && (sample.left_packet_gap_peak_ms >= 140.0 || sample.right_packet_gap_peak_ms >= 140.0) + { + items.push( + "Packet-gap spikes are high without packet loss. That means the stream is arriving in bursts, which usually points at source cadence, encoder stalls, or local decoder starvation more than raw WAN loss." + .to_string(), + ); + } + if let Some(sample) = log.latest() + && software_decode_active + && ((sample.left_decoder_label.contains("avdec") + && sample.left_present_fps + 1.0 < sample.left_receive_fps) + || (sample.right_decoder_label.contains("avdec") + && sample.right_present_fps + 1.0 < sample.right_receive_fps)) + { + items.push( + "At least one eye is falling back to `avdec_*` while presentation lags behind receive. A hardware decode path would likely help more than extra bitrate." + .to_string(), + ); + } + if let Some(sample) = log.latest() + && sample.server_process_cpu_pct >= 70.0 + && (sample.left_server_encoder_label.contains("x264") + || sample.right_server_encoder_label.contains("x264")) + { + items.push( + "At least one eye is still leaning on `x264enc`. That is now unexpected on the source-first path, so treat it as a bug or stale install rather than a normal operating mode." + .to_string(), + ); + } + if state.breakout_count() == 2 { + items.push( + "Both eye feeds are broken out right now. If the client starts struggling, compare in-launcher preview smoothness against full-window decode." + .to_string(), + ); + } + if items.is_empty() { + items.push("Session state looks stable. Collect a few real samples before changing capture settings.".to_string()); + } + items +} + +fn sample_uses_hardware_decode(sample: &PerformanceSample) -> bool { + decoder_label_is_hardware(&sample.left_decoder_label) + || decoder_label_is_hardware(&sample.right_decoder_label) +} + +fn sample_uses_software_decode(sample: &PerformanceSample) -> bool { + sample.left_decoder_label.contains("avdec") || sample.right_decoder_label.contains("avdec") +} + +fn decoder_label_is_hardware(label: &str) -> bool { + let lower = label.to_ascii_lowercase(); + lower.contains("nvh264dec") + || lower.contains("nvdec") + || lower.contains("vah264dec") + || lower.contains("vaapih264dec") + || lower.contains("v4l2slh264dec") + || lower.contains("d3d11") + || lower.contains("vtdec") +} diff --git a/client/src/launcher/diagnostics/snapshot_report.rs b/client/src/launcher/diagnostics/snapshot_report.rs new file mode 100644 index 0000000..a87d9ee --- /dev/null +++ b/client/src/launcher/diagnostics/snapshot_report.rs @@ -0,0 +1,410 @@ +impl SnapshotReport { + pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self { + let left_capture = state + .display_capture_size_choice(0) + .unwrap_or_else(|| state.capture_size_choice(0)); + let right_capture = state + .display_capture_size_choice(1) + .unwrap_or_else(|| state.capture_size_choice(1)); + let left_breakout = state.breakout_size_choice(0); + let right_breakout = state.breakout_size_choice(1); + let latest = log.latest(); + let left_stream_caps = latest + .map(|sample| sample.left_stream_caps_label.clone()) + .unwrap_or_default(); + let right_stream_caps = latest + .map(|sample| sample.right_stream_caps_label.clone()) + .unwrap_or_default(); + Self { + client_version: crate::VERSION.to_string(), + server_version: state.server_version.clone(), + server_available: state.server_available, + routing: state.routing, + view_mode: state.view_mode, + remote_active: state.remote_active, + power_state: format!( + "{} | {} | leases {}", + state.capture_power.mode, + state.capture_power.detail, + state.capture_power.active_leases + ), + client_process_cpu_pct: latest + .map(|sample| sample.client_process_cpu_pct) + .unwrap_or(0.0), + server_process_cpu_pct: latest + .map(|sample| sample.server_process_cpu_pct) + .unwrap_or(0.0), + preview_source: format!( + "{}x{} @ {} fps", + state.preview_source.width, state.preview_source.height, state.preview_source.fps + ), + client_display_limit: format!( + "{}x{}", + state.breakout_display.width, state.breakout_display.height + ), + left_surface: state.display_surface(0).label().to_string(), + left_feed_source: match state.feed_source_preset(0) { + super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(), + super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(), + super::state::FeedSourcePreset::Off => "Off".to_string(), + }, + left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps), + left_capture_transport: left_capture.preset.transport_label().to_string(), + left_breakout_profile: format!( + "{} | {}x{}", + left_breakout.preset.label(), + left_breakout.width, + left_breakout.height + ), + left_decoder_label: latest + .map(|sample| { + if sample.left_decoder_label.is_empty() { + "pending".to_string() + } else { + sample.left_decoder_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + left_stream_spread_ms: latest + .map(|sample| sample.left_stream_spread_ms) + .unwrap_or(0.0), + left_packet_gap_peak_ms: latest + .map(|sample| sample.left_packet_gap_peak_ms) + .unwrap_or(0.0), + left_present_gap_peak_ms: latest + .map(|sample| sample.left_present_gap_peak_ms) + .unwrap_or(0.0), + left_queue_depth: latest.map(|sample| sample.left_queue_depth).unwrap_or(0), + left_queue_peak: latest.map(|sample| sample.left_queue_peak).unwrap_or(0), + left_server_source_gap_peak_ms: latest + .map(|sample| sample.left_server_source_gap_peak_ms) + .unwrap_or(0.0), + left_server_send_gap_peak_ms: latest + .map(|sample| sample.left_server_send_gap_peak_ms) + .unwrap_or(0.0), + left_server_queue_peak: latest + .map(|sample| sample.left_server_queue_peak) + .unwrap_or(0), + left_server_encoder_label: latest + .map(|sample| { + if sample.left_server_encoder_label.is_empty() { + "pending".to_string() + } else { + sample.left_server_encoder_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + left_stream_caps_label: latest + .map(|sample| { + if sample.left_stream_caps_label.is_empty() { + "pending".to_string() + } else { + sample.left_stream_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + left_decoded_caps_label: latest + .map(|sample| { + if sample.left_decoded_caps_label.is_empty() { + "pending".to_string() + } else { + sample.left_decoded_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + left_rendered_caps_label: latest + .map(|sample| { + if sample.left_rendered_caps_label.is_empty() { + "pending".to_string() + } else { + sample.left_rendered_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + right_surface: state.display_surface(1).label().to_string(), + right_feed_source: match state.feed_source_preset(1) { + super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(), + super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(), + super::state::FeedSourcePreset::Off => "Off".to_string(), + }, + right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps), + right_capture_transport: right_capture.preset.transport_label().to_string(), + right_breakout_profile: format!( + "{} | {}x{}", + right_breakout.preset.label(), + right_breakout.width, + right_breakout.height + ), + right_decoder_label: latest + .map(|sample| { + if sample.right_decoder_label.is_empty() { + "pending".to_string() + } else { + sample.right_decoder_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + right_stream_spread_ms: latest + .map(|sample| sample.right_stream_spread_ms) + .unwrap_or(0.0), + right_packet_gap_peak_ms: latest + .map(|sample| sample.right_packet_gap_peak_ms) + .unwrap_or(0.0), + right_present_gap_peak_ms: latest + .map(|sample| sample.right_present_gap_peak_ms) + .unwrap_or(0.0), + right_queue_depth: latest.map(|sample| sample.right_queue_depth).unwrap_or(0), + right_queue_peak: latest.map(|sample| sample.right_queue_peak).unwrap_or(0), + right_server_source_gap_peak_ms: latest + .map(|sample| sample.right_server_source_gap_peak_ms) + .unwrap_or(0.0), + right_server_send_gap_peak_ms: latest + .map(|sample| sample.right_server_send_gap_peak_ms) + .unwrap_or(0.0), + right_server_queue_peak: latest + .map(|sample| sample.right_server_queue_peak) + .unwrap_or(0), + right_server_encoder_label: latest + .map(|sample| { + if sample.right_server_encoder_label.is_empty() { + "pending".to_string() + } else { + sample.right_server_encoder_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + right_stream_caps_label: latest + .map(|sample| { + if sample.right_stream_caps_label.is_empty() { + "pending".to_string() + } else { + sample.right_stream_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + right_decoded_caps_label: latest + .map(|sample| { + if sample.right_decoded_caps_label.is_empty() { + "pending".to_string() + } else { + sample.right_decoded_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + right_rendered_caps_label: latest + .map(|sample| { + if sample.right_rendered_caps_label.is_empty() { + "pending".to_string() + } else { + sample.right_rendered_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), + selected_camera: state.devices.camera.clone(), + camera_quality_label: state + .camera_quality + .map(CameraMode::short_label) + .unwrap_or_else(|| "default".to_string()), + selected_microphone: state.devices.microphone.clone(), + selected_speaker: state.devices.speaker.clone(), + media_channels: MediaChannelState { + camera: state.channels.camera, + microphone: state.channels.microphone, + audio: state.channels.audio, + }, + audio_gain_label: state.audio_gain_label(), + mic_gain_label: state.mic_gain_label(), + selected_keyboard: state.devices.keyboard.clone(), + selected_mouse: state.devices.mouse.clone(), + status: state.status_line(), + recent_samples: log.iter().cloned().collect(), + notes: state.notes.clone(), + recommendations: recommendations_for(state, log), + probe_command, + } + } + + pub fn to_pretty_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + pub fn to_pretty_text(&self) -> String { + let mut text = String::new(); + let server_version = self.server_version.as_deref().unwrap_or("unknown"); + let server_state = if self.server_available { + "reachable" + } else { + "unreachable" + }; + let _ = writeln!(text, "Lesavka Diagnostics"); + let _ = writeln!(text, "client: v{}", self.client_version); + let _ = writeln!(text, "server: {server_version} ({server_state})"); + let _ = writeln!( + text, + "session: routing={:?} view={:?} relay={} capture_power={}", + self.routing, + self.view_mode, + if self.remote_active { "active" } else { "idle" }, + self.power_state + ); + let _ = writeln!( + text, + "runtime: client CPU {:.1}% | server CPU {:.1}%", + self.client_process_cpu_pct, self.server_process_cpu_pct + ); + let _ = writeln!(text, "source feed: {}", self.preview_source); + let _ = writeln!(text, "display limit: {}", self.client_display_limit); + let _ = writeln!(text); + let _ = writeln!(text, "left eye"); + let _ = writeln!(text, " surface: {}", self.left_surface); + let _ = writeln!(text, " source: {}", self.left_feed_source); + let _ = writeln!(text, " capture: {}", self.left_capture_profile); + let _ = writeln!(text, " transport: {}", self.left_capture_transport); + let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); + let _ = writeln!( + text, + " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", + self.left_decoder_label, + self.left_stream_spread_ms, + self.left_packet_gap_peak_ms, + self.left_present_gap_peak_ms, + self.left_queue_depth, + self.left_queue_peak + ); + let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label); + let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label); + let _ = writeln!( + text, + " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", + self.left_server_encoder_label, + self.server_process_cpu_pct, + self.left_server_source_gap_peak_ms, + self.left_server_send_gap_peak_ms, + self.left_server_queue_peak + ); + let _ = writeln!(text, "right eye"); + let _ = writeln!(text, " surface: {}", self.right_surface); + let _ = writeln!(text, " source: {}", self.right_feed_source); + let _ = writeln!(text, " capture: {}", self.right_capture_profile); + let _ = writeln!(text, " transport: {}", self.right_capture_transport); + let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); + let _ = writeln!( + text, + " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", + self.right_decoder_label, + self.right_stream_spread_ms, + self.right_packet_gap_peak_ms, + self.right_present_gap_peak_ms, + self.right_queue_depth, + self.right_queue_peak + ); + let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label); + let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label); + let _ = writeln!( + text, + " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", + self.right_server_encoder_label, + self.server_process_cpu_pct, + self.right_server_source_gap_peak_ms, + self.right_server_send_gap_peak_ms, + self.right_server_queue_peak + ); + let _ = writeln!(text); + let _ = writeln!(text, "media staging"); + let _ = writeln!( + text, + " camera: {} | quality={} | enabled={}", + self.selected_camera.as_deref().unwrap_or("auto"), + self.camera_quality_label, + self.media_channels.camera + ); + let _ = writeln!( + text, + " speaker: {} | volume={} | enabled={}", + self.selected_speaker.as_deref().unwrap_or("auto"), + self.audio_gain_label, + self.media_channels.audio + ); + let _ = writeln!( + text, + " microphone: {} | gain={} | enabled={}", + self.selected_microphone.as_deref().unwrap_or("auto"), + self.mic_gain_label, + self.media_channels.microphone + ); + let _ = writeln!( + text, + " keyboard: {}", + self.selected_keyboard.as_deref().unwrap_or("all") + ); + let _ = writeln!( + text, + " mouse: {}", + self.selected_mouse.as_deref().unwrap_or("all") + ); + let _ = writeln!(text); + let _ = writeln!(text, "current UI state"); + let _ = writeln!(text, " {}", self.status); + let _ = writeln!(text); + let _ = writeln!(text, "recent samples"); + if self.recent_samples.is_empty() { + let _ = writeln!( + text, + " no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot." + ); + } else { + for sample in &self.recent_samples { + let _ = writeln!( + text, + " rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}", + sample.rtt_ms, + sample.probe_spread_ms, + sample.input_latency_ms, + sample.client_process_cpu_pct, + sample.server_process_cpu_pct, + sample.probe_loss_pct, + sample.video_loss_pct, + sample.left_receive_fps, + sample.left_present_fps, + sample.left_server_fps, + sample.right_receive_fps, + sample.right_present_fps, + sample.right_server_fps, + sample.dropped_frames, + sample.queue_depth, + sample.left_queue_peak.max(sample.right_queue_peak), + sample.left_packet_gap_peak_ms, + sample.left_present_gap_peak_ms, + sample.right_packet_gap_peak_ms, + sample.right_present_gap_peak_ms, + sample.left_server_encoder_label, + sample.left_server_source_gap_peak_ms, + sample.left_server_send_gap_peak_ms, + sample.left_server_queue_peak, + sample.right_server_encoder_label, + sample.right_server_source_gap_peak_ms, + sample.right_server_send_gap_peak_ms, + sample.right_server_queue_peak + ); + } + } + let _ = writeln!(text); + let _ = writeln!(text, "recommendations"); + for item in &self.recommendations { + let _ = writeln!(text, " - {item}"); + } + if !self.notes.is_empty() { + let _ = writeln!(text); + let _ = writeln!(text, "notes"); + for item in &self.notes { + let _ = writeln!(text, " - {item}"); + } + } + let _ = writeln!(text); + let _ = writeln!(text, "quality probe"); + let _ = writeln!(text, " {}", self.probe_command); + text + } +} diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index c953fc5..a4df413 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -240,398 +240,5 @@ fn resolve_server_addr(args: &[String]) -> String { } #[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - - #[test] - fn resolve_server_addr_prefers_explicit_server_flag() { - let args = vec![ - "--launcher".to_string(), - "--server".to_string(), - "http://example:50051".to_string(), - "http://fallback:50051".to_string(), - ]; - assert_eq!(resolve_server_addr(&args), "http://example:50051"); - } - - #[test] - fn resolve_server_addr_uses_first_non_flag_or_default() { - let args = vec![ - "--launcher".to_string(), - "http://from-arg:50051".to_string(), - ]; - assert_eq!(resolve_server_addr(&args), "http://from-arg:50051"); - - let args = vec!["--launcher".to_string()]; - assert_eq!(resolve_server_addr(&args), DEFAULT_SERVER_ADDR); - } - - #[test] - #[serial] - fn resolve_server_addr_falls_back_to_env_before_default() { - temp_env::with_var("LESAVKA_SERVER_ADDR", Some("http://env:50051"), || { - let args = vec!["--launcher".to_string()]; - assert_eq!(resolve_server_addr(&args), "http://env:50051"); - }); - } - - #[test] - #[serial] - fn launcher_ipc_paths_have_stable_defaults_and_env_overrides() { - temp_env::with_vars( - [ - (LAUNCHER_FOCUS_SIGNAL_ENV, None::<&str>), - (LAUNCHER_CLIPBOARD_CONTROL_ENV, None::<&str>), - ], - || { - assert_eq!( - launcher_focus_signal_path(), - PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH) - ); - assert_eq!( - launcher_clipboard_control_path(), - PathBuf::from(DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH) - ); - }, - ); - - temp_env::with_vars( - [ - (LAUNCHER_FOCUS_SIGNAL_ENV, Some("/tmp/focus-now")), - (LAUNCHER_CLIPBOARD_CONTROL_ENV, Some("/tmp/clip-now")), - ], - || { - assert_eq!( - launcher_focus_signal_path(), - PathBuf::from("/tmp/focus-now") - ); - assert_eq!( - launcher_clipboard_control_path(), - PathBuf::from("/tmp/clip-now") - ); - }, - ); - } - - #[test] - #[serial] - fn launcher_parent_env_parsing_is_strict_and_trims_ticks() { - temp_env::with_vars( - [ - (LAUNCHER_PARENT_PID_ENV, None::<&str>), - (LAUNCHER_PARENT_START_TICKS_ENV, None::<&str>), - ], - || assert!(launcher_parent_process_from_env().is_none()), - ); - - temp_env::with_var(LAUNCHER_PARENT_PID_ENV, Some("not-a-pid"), || { - assert!(launcher_parent_process_from_env().is_none()); - }); - - temp_env::with_vars( - [ - (LAUNCHER_PARENT_PID_ENV, Some("42")), - (LAUNCHER_PARENT_START_TICKS_ENV, Some(" 123456 ")), - ], - || { - let parent = launcher_parent_process_from_env().expect("parent env"); - assert_eq!(parent.pid, 42); - assert_eq!(parent.start_ticks.as_deref(), Some("123456")); - }, - ); - - temp_env::with_vars( - [ - (LAUNCHER_PARENT_PID_ENV, Some("42")), - (LAUNCHER_PARENT_START_TICKS_ENV, Some(" ")), - ], - || { - let parent = launcher_parent_process_from_env().expect("parent env"); - assert_eq!(parent.pid, 42); - assert_eq!(parent.start_ticks, None); - }, - ); - } - - #[test] - #[serial] - #[cfg(coverage)] - fn launcher_parent_watchdog_stub_is_non_exiting_under_coverage() { - temp_env::with_var(LAUNCHER_PARENT_PID_ENV, None::<&str>, || { - start_launcher_child_parent_watchdog_from_env(); - }); - } - - #[test] - #[serial] - fn runtime_env_vars_emit_selected_controls() { - temp_env::with_vars( - [ - ("LESAVKA_PASTE_KEY", None::<&str>), - ("LESAVKA_PASTE_KEY_FILE", None::<&str>), - ("LESAVKA_PASTE_RPC", None::<&str>), - ("LESAVKA_PASTE_MAX", None::<&str>), - ("LESAVKA_PASTE_DELAY_MS", None::<&str>), - ("LESAVKA_CLIPBOARD_CMD", None::<&str>), - ("LESAVKA_CLIPBOARD_TIMEOUT_MS", None::<&str>), - ], - || { - let mut state = LauncherState::new(); - state.set_routing(InputRouting::Local); - state.set_view_mode(ViewMode::Unified); - state.select_camera(Some("/dev/video0".to_string())); - state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30))); - state.select_microphone(Some("alsa_input.test".to_string())); - state.select_speaker(Some("alsa_output.test".to_string())); - state.set_camera_channel_enabled(true); - state.set_microphone_channel_enabled(true); - state.set_audio_channel_enabled(true); - state.select_keyboard(Some("/dev/input/event10".to_string())); - state.select_mouse(Some("/dev/input/event11".to_string())); - - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string())); - assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string())); - assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE")); - assert!(!envs.contains_key("LESAVKA_MIC_DISABLE")); - assert_eq!( - envs.get("LESAVKA_CLIPBOARD_DELAY_MS"), - Some(&"18".to_string()) - ); - assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); - assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); - assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string())); - assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string())); - assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string())); - assert_eq!( - envs.get("LESAVKA_CAM_H264_KBIT"), - Some(&"12000".to_string()) - ); - assert_eq!( - envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), - Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) - ); - assert_eq!( - envs.get("LESAVKA_DISABLE_VIDEO_RENDER"), - Some(&"1".to_string()) - ); - assert_eq!( - envs.get("LESAVKA_CAM_SOURCE"), - Some(&"/dev/video0".to_string()) - ); - assert_eq!( - envs.get("LESAVKA_MIC_SOURCE"), - Some(&"alsa_input.test".to_string()) - ); - assert_eq!( - envs.get("LESAVKA_AUDIO_SINK"), - Some(&"alsa_output.test".to_string()) - ); - assert_eq!( - envs.get("LESAVKA_KEYBOARD_DEVICE"), - Some(&"/dev/input/event10".to_string()) - ); - assert_eq!( - envs.get("LESAVKA_MOUSE_DEVICE"), - Some(&"/dev/input/event11".to_string()) - ); - assert!(!envs.contains_key("LESAVKA_PASTE_KEY_FILE")); - }, - ); - } - - #[test] - #[serial] - fn runtime_env_vars_passes_through_clipboard_transport_env() { - temp_env::with_vars( - [ - ("LESAVKA_PASTE_KEY_FILE", Some("/tmp/paste-key")), - ("LESAVKA_PASTE_RPC", Some("1")), - ("LESAVKA_CLIPBOARD_CMD", Some("cat /tmp/secret")), - ], - || { - let state = LauncherState::new(); - let envs = runtime_env_vars(&state); - assert_eq!( - envs.get("LESAVKA_PASTE_KEY_FILE"), - Some(&"/tmp/paste-key".to_string()) - ); - assert_eq!(envs.get("LESAVKA_PASTE_RPC"), Some(&"1".to_string())); - assert_eq!( - envs.get("LESAVKA_CLIPBOARD_CMD"), - Some(&"cat /tmp/secret".to_string()) - ); - }, - ); - } - - #[test] - #[serial] - fn runtime_env_vars_passes_through_remote_failsafe_launch_option() { - temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("60"), || { - let state = LauncherState::new(); - let envs = runtime_env_vars(&state); - assert_eq!( - envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), - Some(&"60".to_string()) - ); - }); - } - - #[test] - #[serial] - fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() { - temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("later"), || { - let state = LauncherState::new(); - let envs = runtime_env_vars(&state); - assert_eq!( - envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), - Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) - ); - }); - } - - #[test] - fn runtime_env_vars_do_not_disable_breakout_video_windows() { - let mut state = LauncherState::new(); - state.set_view_mode(ViewMode::Breakout); - - let envs = runtime_env_vars(&state); - assert!(!envs.contains_key("LESAVKA_DISABLE_VIDEO_RENDER")); - } - - #[test] - fn runtime_env_vars_disable_enabled_channels_without_real_devices() { - let mut state = LauncherState::new(); - state.select_microphone(Some("auto".to_string())); - state.select_speaker(Some("auto".to_string())); - state.set_microphone_channel_enabled(true); - - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); - assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); - assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); - assert!(!envs.contains_key("LESAVKA_AUDIO_SINK")); - } - - #[test] - fn runtime_env_vars_emit_selected_audio_gain() { - let mut state = LauncherState::new(); - state.set_audio_gain_percent(425); - state.set_mic_gain_percent(275); - - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"4.250".to_string())); - assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"2.750".to_string())); - } - - #[test] - fn runtime_env_vars_use_channel_toggles_for_media_inclusion() { - let mut state = LauncherState::new(); - - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); - assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); - assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); - - state.select_camera(Some("/dev/video0".to_string())); - state.select_microphone(Some("alsa_input.usb".to_string())); - state.select_speaker(Some("alsa_output.usb".to_string())); - state.set_camera_channel_enabled(true); - state.set_microphone_channel_enabled(true); - let envs = runtime_env_vars(&state); - assert!(!envs.contains_key("LESAVKA_CAM_DISABLE")); - assert!(!envs.contains_key("LESAVKA_MIC_DISABLE")); - assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE")); - - state.set_audio_channel_enabled(false); - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); - } - - #[test] - fn runtime_env_vars_disable_uplink_media_when_unstaged() { - let state = LauncherState::new(); - - let envs = runtime_env_vars(&state); - assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); - assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); - assert!(!envs.contains_key("LESAVKA_CAM_SOURCE")); - assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); - } - - #[test] - fn maybe_run_launcher_returns_false_with_explicit_opt_out() { - let args = vec!["--no-launcher".to_string()]; - assert!(!maybe_run_launcher(&args).expect("launcher check")); - } - - #[test] - #[cfg(coverage)] - fn maybe_run_launcher_returns_true_with_launcher_flag() { - let args = vec!["--launcher".to_string()]; - assert!(maybe_run_launcher(&args).expect("launcher should run")); - } - - #[test] - #[cfg(coverage)] - fn maybe_run_launcher_defaults_to_launcher_for_empty_args() { - let args: Vec = vec![]; - assert!(maybe_run_launcher(&args).expect("launcher should run")); - } - - #[test] - fn should_run_launcher_defaults_true_for_empty_args() { - assert!(should_run_launcher(&[])); - } - - #[test] - fn should_run_launcher_honors_explicit_opt_out() { - let args = vec!["--no-launcher".to_string()]; - assert!(!should_run_launcher(&args)); - } - - #[test] - fn should_run_launcher_includes_legacy_direct_server_args() { - let args = vec!["http://server:50051".to_string()]; - assert!(should_run_launcher(&args)); - } - - #[test] - fn should_run_launcher_with_server_flag() { - let args = vec!["--server".to_string(), "http://server:50051".to_string()]; - assert!(should_run_launcher(&args)); - } - - #[test] - fn proc_stat_start_ticks_handles_process_names_with_spaces() { - let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21"; - - assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242")); - assert_eq!(proc_stat_start_ticks("missing-parens"), None); - } - - #[test] - fn launcher_parent_start_ticks_is_available_for_current_process() { - assert!(launcher_parent_start_ticks().is_some()); - - let parent = LauncherParentProcess { - pid: std::process::id(), - start_ticks: launcher_parent_start_ticks(), - }; - assert!(launcher_parent_process_matches(&parent)); - - let mismatched = LauncherParentProcess { - pid: std::process::id(), - start_ticks: Some("definitely-not-current".to_string()), - }; - assert!(!launcher_parent_process_matches(&mismatched)); - - let missing = LauncherParentProcess { - pid: u32::MAX, - start_ticks: None, - }; - assert!(!launcher_parent_process_matches(&missing)); - } -} +#[path = "tests/mod.rs"] +mod tests; diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index c94d3c5..2ec661b 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -1,2242 +1,10 @@ -#[cfg(not(coverage))] -use crate::video_support::pick_h264_decoder; -#[cfg(not(coverage))] -use anyhow::{Context, Result}; -#[cfg(not(coverage))] -use gstreamer as gst; -#[cfg(not(coverage))] -use gstreamer::prelude::{Cast, ElementExt, GstBinExt, GstObjectExt, PadExt}; -#[cfg(not(coverage))] -use gstreamer_app as gst_app; -#[cfg(not(coverage))] -use gtk::prelude::WidgetExt; -#[cfg(not(coverage))] -use gtk::{gdk, glib}; -use lesavka_common::{ - eye_source::eye_source_mode_for_request, - lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}, -}; -#[cfg(not(coverage))] -use std::collections::VecDeque; -#[cfg(not(coverage))] -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -#[cfg(not(coverage))] -use std::sync::{Arc, Mutex}; -#[cfg(not(coverage))] -use std::time::{Duration, Instant}; -#[cfg(not(coverage))] -use tonic::{Request, transport::Channel}; -#[cfg(not(coverage))] -use tracing::{debug, warn}; - -#[cfg(not(coverage))] -const PREVIEW_WIDTH: i32 = 960; -#[cfg(not(coverage))] -const PREVIEW_HEIGHT: i32 = 540; -#[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH; -#[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT; -#[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS; -#[cfg(not(coverage))] -const INLINE_PREVIEW_MAX_KBIT: u32 = DEFAULT_EYE_SOURCE_MAX_KBIT; -#[cfg(not(coverage))] -const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920; -#[cfg(not(coverage))] -const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080; -#[cfg(not(coverage))] -const DEFAULT_EYE_SOURCE_FPS: u32 = 60; -#[cfg(not(coverage))] -const DEFAULT_EYE_SOURCE_MAX_KBIT: u32 = 18_000; -#[cfg(not(coverage))] -const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview."; -#[cfg(not(coverage))] -const TELEMETRY_WINDOW: Duration = Duration::from_secs(5); - -#[cfg(not(coverage))] -pub struct LauncherPreview { - server_addr: Arc>, - log_sink: Arc>>>, - inline_feeds: Arc>, - window_feeds: Arc>, -} - -#[cfg(not(coverage))] -#[derive(Clone)] -pub struct PreviewBinding { - enabled: Arc, - alive: Arc, - active_bindings: Arc, -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy, Debug)] -pub enum PreviewSurface { - Inline, - Window, -} - -#[cfg(not(coverage))] -#[derive(Clone, Debug, Default, PartialEq)] -pub struct PreviewMetricsSnapshot { - pub receive_fps: f32, - pub present_fps: f32, - pub server_fps: f32, - pub server_process_cpu_pct: f32, - pub stream_spread_ms: f32, - pub packet_loss_pct: f32, - pub dropped_frames: u64, - pub queue_depth: u32, - pub queue_depth_peak: u32, - pub packet_gap_peak_ms: f32, - pub present_gap_peak_ms: f32, - pub server_source_gap_peak_ms: f32, - pub server_send_gap_peak_ms: f32, - pub server_queue_peak: u32, - pub server_encoder_label: String, - pub decoder_label: String, - pub stream_caps_label: String, - pub decoded_caps_label: String, - pub rendered_caps_label: String, -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy, Debug)] -struct PreviewProfile { - source_monitor_id: u32, - display_width: i32, - display_height: i32, - requested_width: i32, - requested_height: i32, - requested_fps: u32, - max_bitrate_kbit: u32, -} - -#[cfg(not(coverage))] -impl PreviewSurface { - fn profile(self) -> PreviewProfile { - match self { - Self::Inline => PreviewProfile { - source_monitor_id: 0, - display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH), - display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT), - requested_width: preview_dimension( - "LESAVKA_PREVIEW_REQUEST_WIDTH", - INLINE_PREVIEW_REQUEST_WIDTH, - ), - requested_height: preview_dimension( - "LESAVKA_PREVIEW_REQUEST_HEIGHT", - INLINE_PREVIEW_REQUEST_HEIGHT, - ), - requested_fps: preview_bitrate( - "LESAVKA_PREVIEW_REQUEST_FPS", - INLINE_PREVIEW_REQUEST_FPS, - ), - max_bitrate_kbit: preview_bitrate( - "LESAVKA_PREVIEW_MAX_KBIT", - INLINE_PREVIEW_MAX_KBIT, - ), - }, - Self::Window => PreviewProfile { - source_monitor_id: 0, - display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), - display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720), - requested_width: preview_dimension( - "LESAVKA_BREAKOUT_REQUEST_WIDTH", - DEFAULT_EYE_SOURCE_WIDTH, - ), - requested_height: preview_dimension( - "LESAVKA_BREAKOUT_REQUEST_HEIGHT", - DEFAULT_EYE_SOURCE_HEIGHT, - ), - requested_fps: preview_bitrate( - "LESAVKA_BREAKOUT_REQUEST_FPS", - DEFAULT_EYE_SOURCE_FPS, - ), - max_bitrate_kbit: preview_bitrate( - "LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", - DEFAULT_EYE_SOURCE_MAX_KBIT, - ), - }, - } - } -} - -#[cfg(not(coverage))] -impl LauncherPreview { - pub fn new(server_addr: String) -> Result { - gst::init().context("initialising preview gstreamer")?; - let server_addr = Arc::new(Mutex::new(server_addr)); - let log_sink = Arc::new(Mutex::new(None)); - let inline_feeds = Arc::new(Mutex::new([ - PreviewFeed::spawn( - Arc::clone(&server_addr), - 0, - PreviewSurface::Inline.profile(), - Arc::clone(&log_sink), - )?, - PreviewFeed::spawn( - Arc::clone(&server_addr), - 1, - PreviewSurface::Inline.profile(), - Arc::clone(&log_sink), - )?, - ])); - let window_feeds = Arc::new(Mutex::new([ - PreviewFeed::spawn( - Arc::clone(&server_addr), - 0, - PreviewSurface::Window.profile(), - Arc::clone(&log_sink), - )?, - PreviewFeed::spawn( - Arc::clone(&server_addr), - 1, - PreviewSurface::Window.profile(), - Arc::clone(&log_sink), - )?, - ])); - Ok(Self { - server_addr: Arc::clone(&server_addr), - log_sink: Arc::clone(&log_sink), - inline_feeds, - window_feeds, - }) - } - - pub fn set_log_sink(&self, tx: std::sync::mpsc::Sender) { - if let Ok(mut slot) = self.log_sink.lock() { - *slot = Some(tx); - } - } - - pub fn set_server_addr(&self, server_addr: String) { - if let Ok(mut slot) = self.server_addr.lock() { - *slot = server_addr; - } - } - - pub fn set_session_active(&self, active: bool) { - if let Ok(feeds) = self.inline_feeds.lock() { - for feed in feeds.iter() { - feed.set_active(active); - } - } - if let Ok(feeds) = self.window_feeds.lock() { - for feed in feeds.iter() { - feed.set_active(active); - } - } - } - - pub fn shutdown_all(&self) { - if let Ok(feeds) = self.inline_feeds.lock() { - for feed in feeds.iter() { - feed.shutdown(); - } - } - if let Ok(feeds) = self.window_feeds.lock() { - for feed in feeds.iter() { - feed.shutdown(); - } - } - } - - pub fn install_on_picture( - &self, - monitor_id: usize, - surface: PreviewSurface, - picture: >k::Picture, - status_label: >k::Label, - ) -> Option { - match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.install_on_picture(picture, status_label)), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.install_on_picture(picture, status_label)), - } - } - - pub fn snapshot_metrics( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option { - match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.snapshot_metrics()), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.snapshot_metrics()), - } - } - - pub fn set_capture_profile( - &self, - monitor_id: usize, - source_monitor_id: usize, - requested_width: i32, - requested_height: i32, - requested_fps: u32, - max_bitrate_kbit: u32, - ) { - let ( - inline_requested_width, - inline_requested_height, - inline_requested_fps, - inline_max_bitrate_kbit, - ) = sanitize_preview_request( - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - ); - self.rebuild_feed( - &self.inline_feeds, - monitor_id, - Some(( - source_monitor_id, - inline_requested_width, - inline_requested_height, - inline_requested_fps, - inline_max_bitrate_kbit, - )), - None, - ); - self.rebuild_feed( - &self.window_feeds, - monitor_id, - Some(( - source_monitor_id, - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - )), - None, - ); - } - - pub fn set_breakout_profile(&self, monitor_id: usize, width: i32, height: i32) { - self.rebuild_feed(&self.window_feeds, monitor_id, None, Some((width, height))); - } - - #[cfg(test)] - pub(crate) fn profile_for_test( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> { - let feed = match surface { - PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), - PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), - }?; - let profile = feed.profile(); - Some(( - profile.source_monitor_id, - profile.display_width, - profile.display_height, - profile.requested_width, - profile.requested_height, - profile.requested_fps, - profile.max_bitrate_kbit, - )) - } - - #[cfg(test)] - pub(crate) fn feed_disabled_for_test( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option { - let feed = match surface { - PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), - PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), - }?; - Some(feed.is_disabled()) - } - - #[cfg(test)] - pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) { - let feed = match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()), - }; - if let Some(feed) = feed { - feed.session_active.store(true, Ordering::Relaxed); - feed.active_bindings.fetch_add(1, Ordering::AcqRel); - } - } - - fn rebuild_feed( - &self, - feeds: &Arc>, - monitor_id: usize, - requested: Option<(usize, i32, i32, u32, u32)>, - display: Option<(i32, i32)>, - ) { - let Ok(mut feeds) = feeds.lock() else { - return; - }; - let Some(existing) = feeds.get(monitor_id).cloned() else { - return; - }; - let was_active = existing.is_active(); - let keep_disabled = existing.is_disabled(); - let mut profile = existing.profile(); - if let Some(( - source_monitor_id, - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - )) = requested - { - profile.source_monitor_id = source_monitor_id as u32; - profile.requested_width = requested_width.max(2); - profile.requested_height = requested_height.max(2); - profile.requested_fps = requested_fps.max(1); - profile.max_bitrate_kbit = max_bitrate_kbit.max(800); - } - if let Some((display_width, display_height)) = display { - profile.display_width = display_width.max(2); - profile.display_height = display_height.max(2); - } - let next_feed = if keep_disabled { - Some(PreviewFeed::spawn_disabled(profile)) - } else { - match PreviewFeed::spawn( - Arc::clone(&self.server_addr), - monitor_id as u32, - profile, - Arc::clone(&self.log_sink), - ) { - Ok(feed) => Some(feed), - Err(err) => { - warn!(monitor_id, ?err, "could not rebuild preview feed"); - None - } - } - }; - if let Some(feed) = next_feed { - if was_active { - feed.set_active(true); - } - existing.shutdown(); - feeds[monitor_id] = feed; - } - } - - pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) { - self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled); - self.set_feed_enabled(&self.window_feeds, monitor_id, enabled); - } - - fn set_feed_enabled( - &self, - feeds: &Arc>, - monitor_id: usize, - enabled: bool, - ) { - let Ok(mut feeds) = feeds.lock() else { - return; - }; - let Some(existing) = feeds.get(monitor_id).cloned() else { - return; - }; - if existing.is_disabled() == !enabled { - return; - } - let was_active = existing.is_active(); - let profile = existing.profile(); - let replacement = if enabled { - match PreviewFeed::spawn( - Arc::clone(&self.server_addr), - monitor_id as u32, - profile, - Arc::clone(&self.log_sink), - ) { - Ok(feed) => feed, - Err(err) => { - warn!(monitor_id, ?err, "could not enable preview feed"); - return; - } - } - } else { - PreviewFeed::spawn_disabled(profile) - }; - if was_active { - replacement.set_active(true); - } - existing.shutdown(); - feeds[monitor_id] = replacement; - } -} - -#[cfg(not(coverage))] -impl PreviewBinding { - pub fn set_enabled(&self, enabled: bool) { - let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel); - match (was_enabled, enabled) { - (false, true) => { - self.active_bindings.fetch_add(1, Ordering::AcqRel); - } - (true, false) => { - self.active_bindings.fetch_sub(1, Ordering::AcqRel); - } - _ => {} - } - } - - pub fn close(&self) { - if !self.alive.swap(false, Ordering::AcqRel) { - return; - } - if self.enabled.swap(false, Ordering::AcqRel) { - self.active_bindings.fetch_sub(1, Ordering::AcqRel); - } - } - - #[cfg(test)] - pub(crate) fn test_stub() -> Self { - Self { - enabled: Arc::new(AtomicBool::new(true)), - alive: Arc::new(AtomicBool::new(true)), - active_bindings: Arc::new(AtomicUsize::new(1)), - } - } -} - -#[cfg(not(coverage))] -#[derive(Clone)] -struct PreviewFeed { - shared: Arc>, - session_active: Arc, - active_bindings: Arc, - running: Arc, - profile: PreviewProfile, - disabled: bool, -} - -#[cfg(not(coverage))] -struct SharedPreviewState { - latest: Option, - status: String, - generation: u64, - clear_picture: bool, - last_logged_error: Option, - last_logged_status: Option, - telemetry: PreviewTelemetry, -} - -#[cfg(not(coverage))] -impl SharedPreviewState { - fn new() -> Self { - Self { - latest: None, - status: PREVIEW_IDLE_STATUS.to_string(), - generation: 1, - clear_picture: true, - last_logged_error: None, - last_logged_status: None, - telemetry: PreviewTelemetry::default(), - } - } - - fn set_status(&mut self, status: impl Into, clear_picture: bool) { - let status = status.into(); - let changed = self.status != status || clear_picture; - self.status = status.clone(); - if clear_picture { - self.latest = None; - self.clear_picture = true; - } - if !looks_like_preview_problem(&status) { - self.last_logged_error = None; - } - if changed { - self.generation = self.generation.saturating_add(1); - } - } - - fn push_frame(&mut self, frame: PreviewFrame) { - self.telemetry.record_presented_frame(); - self.latest = Some(frame); - self.clear_picture = false; - self.last_logged_error = None; - if self.status != "Live" { - self.status = "Live".to_string(); - self.generation = self.generation.saturating_add(1); - } - } -} - -#[cfg(not(coverage))] -#[derive(Debug, Default)] -struct PreviewTelemetry { - packet_times: VecDeque, - frame_times: VecDeque, - packet_intervals_ms: VecDeque<(Instant, f32)>, - frame_intervals_ms: VecDeque<(Instant, f32)>, - packet_losses: VecDeque<(Instant, u64)>, - dropped_deltas: VecDeque<(Instant, u64)>, - queue_depth_samples: VecDeque<(Instant, u32)>, - last_packet_at: Option, - last_frame_at: Option, - last_seq: Option, - last_dropped_total: Option, - latest_server_fps: u32, - latest_server_process_cpu_tenths: u32, - latest_queue_depth: u32, - latest_server_source_gap_peak_ms: u32, - latest_server_send_gap_peak_ms: u32, - latest_server_queue_peak: u32, - latest_server_encoder_label: String, - decoder_label: String, - stream_caps_label: String, - decoded_caps_label: String, - rendered_caps_label: String, -} - -#[cfg(not(coverage))] -impl PreviewTelemetry { - fn record_packet( - &mut self, - seq: u64, - server_fps: u32, - dropped_total: u64, - queue_depth: u32, - server_source_gap_peak_ms: u32, - server_send_gap_peak_ms: u32, - server_queue_peak: u32, - server_encoder_label: &str, - server_process_cpu_tenths: u32, - ) { - self.record_packet_at( - Instant::now(), - seq, - server_fps, - dropped_total, - queue_depth, - server_source_gap_peak_ms, - server_send_gap_peak_ms, - server_queue_peak, - server_encoder_label, - server_process_cpu_tenths, - ); - } - - fn record_packet_at( - &mut self, - now: Instant, - seq: u64, - server_fps: u32, - dropped_total: u64, - queue_depth: u32, - server_source_gap_peak_ms: u32, - server_send_gap_peak_ms: u32, - server_queue_peak: u32, - server_encoder_label: &str, - server_process_cpu_tenths: u32, - ) { - self.trim(now); - self.packet_times.push_back(now); - if let Some(previous) = self.last_packet_at.replace(now) { - self.packet_intervals_ms.push_back(( - now, - now.saturating_duration_since(previous).as_secs_f32() * 1000.0, - )); - } - if seq > 0 { - if let Some(previous_seq) = self.last_seq - && seq > previous_seq + 1 - { - self.packet_losses - .push_back((now, seq.saturating_sub(previous_seq + 1))); - } - self.last_seq = Some(seq); - } - if let Some(previous_dropped) = self.last_dropped_total - && dropped_total > previous_dropped - { - self.dropped_deltas - .push_back((now, dropped_total.saturating_sub(previous_dropped))); - } - self.last_dropped_total = Some(dropped_total); - self.latest_server_fps = server_fps.max(1); - self.latest_server_process_cpu_tenths = server_process_cpu_tenths; - self.latest_queue_depth = queue_depth; - self.latest_server_source_gap_peak_ms = server_source_gap_peak_ms; - self.latest_server_send_gap_peak_ms = server_send_gap_peak_ms; - self.latest_server_queue_peak = server_queue_peak.max(queue_depth); - if !server_encoder_label.is_empty() { - self.latest_server_encoder_label = server_encoder_label.to_string(); - } - self.queue_depth_samples.push_back((now, queue_depth)); - self.trim(now); - } - - fn record_presented_frame(&mut self) { - self.record_presented_frame_at(Instant::now()); - } - - fn record_presented_frame_at(&mut self, now: Instant) { - self.trim(now); - if let Some(previous) = self.last_frame_at.replace(now) { - self.frame_intervals_ms.push_back(( - now, - now.saturating_duration_since(previous).as_secs_f32() * 1000.0, - )); - } - self.frame_times.push_back(now); - } - - fn note_decoder(&mut self, decoder_label: &str) { - if !decoder_label.is_empty() { - self.decoder_label = decoder_label.to_string(); - } - } - - fn note_stream_caps(&mut self, caps_label: &str) { - if !caps_label.is_empty() { - self.stream_caps_label = caps_label.to_string(); - } - } - - fn note_decoded_caps(&mut self, caps_label: &str) { - if !caps_label.is_empty() { - self.decoded_caps_label = caps_label.to_string(); - } - } - - fn note_rendered_caps(&mut self, caps_label: &str) { - if !caps_label.is_empty() { - self.rendered_caps_label = caps_label.to_string(); - } - } - - fn snapshot(&mut self) -> PreviewMetricsSnapshot { - self.snapshot_at(Instant::now()) - } - - fn snapshot_at(&mut self, now: Instant) -> PreviewMetricsSnapshot { - self.trim(now); - let receive_fps = events_per_second(&self.packet_times, now); - let present_fps = events_per_second(&self.frame_times, now); - let delivered = self.packet_times.len() as u64; - let packet_losses: u64 = self.packet_losses.iter().map(|(_, loss)| *loss).sum(); - let packet_loss_pct = if delivered + packet_losses == 0 { - 0.0 - } else { - packet_losses as f32 * 100.0 / (delivered + packet_losses) as f32 - }; - let dropped_frames: u64 = self - .dropped_deltas - .iter() - .map(|(_, dropped)| *dropped) - .sum(); - let queue_depth_peak = self - .queue_depth_samples - .iter() - .map(|(_, depth)| *depth) - .max() - .unwrap_or(self.latest_queue_depth); - PreviewMetricsSnapshot { - receive_fps, - present_fps, - server_fps: self.latest_server_fps as f32, - server_process_cpu_pct: self.latest_server_process_cpu_tenths as f32 / 10.0, - stream_spread_ms: compute_jitter_ms(&self.packet_intervals_ms), - packet_loss_pct, - dropped_frames, - queue_depth: self.latest_queue_depth, - queue_depth_peak, - packet_gap_peak_ms: compute_peak_gap_ms(&self.packet_intervals_ms), - present_gap_peak_ms: compute_peak_gap_ms(&self.frame_intervals_ms), - server_source_gap_peak_ms: self.latest_server_source_gap_peak_ms as f32, - server_send_gap_peak_ms: self.latest_server_send_gap_peak_ms as f32, - server_queue_peak: self.latest_server_queue_peak, - server_encoder_label: self.latest_server_encoder_label.clone(), - decoder_label: self.decoder_label.clone(), - stream_caps_label: self.stream_caps_label.clone(), - decoded_caps_label: self.decoded_caps_label.clone(), - rendered_caps_label: self.rendered_caps_label.clone(), - } - } - - fn trim(&mut self, now: Instant) { - trim_instant_queue(&mut self.packet_times, now); - trim_instant_queue(&mut self.frame_times, now); - trim_value_queue(&mut self.packet_intervals_ms, now); - trim_value_queue(&mut self.frame_intervals_ms, now); - trim_value_queue(&mut self.packet_losses, now); - trim_value_queue(&mut self.dropped_deltas, now); - trim_value_queue(&mut self.queue_depth_samples, now); - } -} - -#[cfg(not(coverage))] -impl PreviewFeed { - fn spawn( - server_addr: Arc>, - monitor_id: u32, - profile: PreviewProfile, - log_sink: Arc>>>, - ) -> Result { - let shared = Arc::new(Mutex::new(SharedPreviewState::new())); - let session_active = Arc::new(AtomicBool::new(false)); - let active_bindings = Arc::new(AtomicUsize::new(0)); - let running = Arc::new(AtomicBool::new(true)); - let shared_state = Arc::clone(&shared); - let session_active_flag = Arc::clone(&session_active); - let active_bindings_flag = Arc::clone(&active_bindings); - let running_flag = Arc::clone(&running); - std::thread::spawn(move || { - if let Err(err) = run_preview_feed( - server_addr, - monitor_id, - profile, - session_active_flag, - active_bindings_flag, - running_flag, - Arc::clone(&shared_state), - Arc::clone(&log_sink), - ) { - set_shared_status( - &shared_state, - &log_sink, - monitor_id, - "Preview pipeline setup failed. See session log.", - true, - ); - log_preview_issue( - &shared_state, - &log_sink, - monitor_id, - &format!("Preview feed startup failed: {err:#}"), - ); - warn!(monitor_id, ?err, "launcher preview feed exited"); - } - }); - Ok(Self { - shared, - session_active, - active_bindings, - running, - profile, - disabled: false, - }) - } - - fn spawn_disabled(profile: PreviewProfile) -> Self { - let shared = Arc::new(Mutex::new(SharedPreviewState::new())); - if let Ok(mut slot) = shared.lock() { - slot.set_status("Feed disabled.", true); - } - Self { - shared, - session_active: Arc::new(AtomicBool::new(false)), - active_bindings: Arc::new(AtomicUsize::new(0)), - running: Arc::new(AtomicBool::new(false)), - profile, - disabled: true, - } - } - - fn profile(&self) -> PreviewProfile { - self.profile - } - - fn is_disabled(&self) -> bool { - self.disabled - } - - fn is_active(&self) -> bool { - self.session_active.load(Ordering::Relaxed) - } - - fn set_active(&self, active: bool) { - self.session_active.store(active, Ordering::Relaxed); - if !active && !self.disabled { - self.replace_status(PREVIEW_IDLE_STATUS, true); - } - } - - fn shutdown(&self) { - self.running.store(false, Ordering::Relaxed); - self.replace_status( - if self.disabled { - "Feed disabled." - } else { - PREVIEW_IDLE_STATUS - }, - true, - ); - } - - fn replace_status(&self, status: impl Into, clear_picture: bool) { - if let Ok(mut shared) = self.shared.lock() { - shared.set_status(status, clear_picture); - } - } - - fn install_on_picture( - &self, - picture: >k::Picture, - status_label: >k::Label, - ) -> PreviewBinding { - let picture = picture.clone(); - let status_label = status_label.clone(); - let shared = Arc::clone(&self.shared); - let enabled = Arc::new(AtomicBool::new(true)); - let alive = Arc::new(AtomicBool::new(true)); - let active_bindings = Arc::clone(&self.active_bindings); - let enabled_flag = Arc::clone(&enabled); - let alive_flag = Arc::clone(&alive); - active_bindings.fetch_add(1, Ordering::AcqRel); - let mut last_generation = 0_u64; - glib::timeout_add_local(Duration::from_millis(120), move || { - if !alive_flag.load(Ordering::Relaxed) { - return glib::ControlFlow::Break; - } - if !enabled_flag.load(Ordering::Relaxed) { - return glib::ControlFlow::Continue; - } - - let (frame, status, generation, clear_picture) = match shared.lock() { - Ok(mut slot) => { - let frame = slot.latest.take(); - let status = slot.status.clone(); - let generation = slot.generation; - let clear_picture = slot.clear_picture; - slot.clear_picture = false; - (frame, status, generation, clear_picture) - } - Err(_) => return glib::ControlFlow::Continue, - }; - - if clear_picture { - picture.set_paintable(Option::<&gdk::Paintable>::None); - } - if let Some(frame) = frame { - let bytes = glib::Bytes::from_owned(frame.rgba); - let texture = gdk::MemoryTexture::new( - frame.width, - frame.height, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - frame.stride, - ); - picture.set_paintable(Some(&texture)); - } - if generation != last_generation { - status_label.set_text(&status); - status_label.set_tooltip_text(Some(&status)); - last_generation = generation; - } - glib::ControlFlow::Continue - }); - PreviewBinding { - enabled, - alive, - active_bindings, - } - } - - fn snapshot_metrics(&self) -> PreviewMetricsSnapshot { - self.shared - .lock() - .map(|mut shared| shared.telemetry.snapshot()) - .unwrap_or_default() - } -} - -#[cfg(not(coverage))] -struct PreviewFrame { - width: i32, - height: i32, - stride: usize, - rgba: Vec, -} - -#[cfg(not(coverage))] -fn run_preview_feed( - server_addr: Arc>, - monitor_id: u32, - profile: PreviewProfile, - session_active: Arc, - active_bindings: Arc, - running: Arc, - shared: Arc>, - log_sink: Arc>>>, -) -> Result<()> { - let mut startup_error = None; - let mut selected = None; - for decoder_name in preview_decoder_candidates() { - match build_preview_pipeline(profile, &decoder_name) { - Ok((pipeline, appsrc, appsink, decoder_label)) => { - match pipeline - .set_state(gst::State::Playing) - .context("starting launcher preview pipeline") - { - Ok(_) => { - selected = Some((pipeline, appsrc, appsink, decoder_label)); - break; - } - Err(err) => { - let _ = pipeline.set_state(gst::State::Null); - startup_error = Some(err); - } - } - } - Err(err) => { - startup_error = Some(err); - } - } - } - let (pipeline, appsrc, appsink, decoder_name) = selected.ok_or_else(|| { - startup_error.unwrap_or_else(|| anyhow::anyhow!("no usable H.264 decoder")) - })?; - let parser = pipeline.by_name("preview_parse"); - let decoder = pipeline.by_name("decoder"); - if let Ok(mut slot) = shared.lock() { - slot.telemetry.note_decoder(&decoder_name); - } - { - let shared = Arc::clone(&shared); - pipeline.connect_deep_element_added(move |_, _, element| { - if let Some(decoder_label) = preview_decoder_label(element) - && let Ok(mut slot) = shared.lock() - { - slot.telemetry.note_decoder(&decoder_label); - } - }); - } - { - let shared = Arc::clone(&shared); - let appsink = appsink.clone(); - let parser = parser.clone(); - let decoder = decoder.clone(); - let running = Arc::clone(&running); - std::thread::spawn(move || { - loop { - if !running.load(Ordering::Relaxed) { - break; - } - if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { - if let Some(parser) = parser.as_ref() { - record_preview_caps(&shared, parser, "src", PreviewCapsKind::Stream); - } - if let Some(decoder) = decoder.as_ref() { - record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); - } - if let Some(caps) = sample.caps() { - let caps_label = preview_caps_summary(&caps); - if !caps_label.is_empty() - && let Ok(mut slot) = shared.lock() - { - slot.telemetry.note_rendered_caps(&caps_label); - } - } - if let Some(frame) = sample_to_frame(&sample) { - if let Ok(mut slot) = shared.lock() { - slot.push_frame(frame); - } - } - } - } - }); - } - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("building preview tokio runtime")?; - - let _ = rt.block_on(async move { - let mut was_active = false; - let mut retry_delay = Duration::from_millis(750); - loop { - if !running.load(Ordering::Relaxed) { - break; - } - let active_now = session_active.load(Ordering::Relaxed) - && active_bindings.load(Ordering::Relaxed) > 0; - if !active_now { - was_active = false; - retry_delay = Duration::from_millis(750); - set_shared_status(&shared, &log_sink, monitor_id, PREVIEW_IDLE_STATUS, true); - tokio::time::sleep(Duration::from_millis(150)).await; - continue; - } - - if !was_active { - was_active = true; - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waking relay preview...", - true, - ); - tokio::time::sleep(Duration::from_millis(350)).await; - } - - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Connecting relay preview...", - true, - ); - let current_addr = match server_addr.lock() { - Ok(value) => value.clone(), - Err(_) => { - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview address is unavailable.", - true, - ); - tokio::time::sleep(Duration::from_millis(750)).await; - continue; - } - }; - - let channel = match Channel::from_shared(current_addr.clone()) { - Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { - Ok(channel) => channel, - Err(err) => { - warn!(monitor_id, ?err, "launcher preview connect failed"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview host is unavailable: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview host is unavailable.", - true, - ); - tokio::time::sleep(retry_delay).await; - continue; - } - }, - Err(err) => { - warn!(monitor_id, ?err, "launcher preview endpoint invalid"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview address is invalid: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview address is invalid.", - true, - ); - tokio::time::sleep(retry_delay).await; - continue; - } - }; - - let mut cli = RelayClient::new(channel); - let req = MonitorRequest { - id: monitor_id, - max_bitrate: profile.max_bitrate_kbit, - requested_width: profile.requested_width.max(0) as u32, - requested_height: profile.requested_height.max(0) as u32, - requested_fps: profile.requested_fps, - source_id: Some(profile.source_monitor_id), - }; - match cli.capture_video(Request::new(req)).await { - Ok(mut stream) => { - retry_delay = Duration::from_millis(750); - debug!(monitor_id, "launcher preview connected"); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waiting for stream...", - true, - ); - loop { - if !session_active.load(Ordering::Relaxed) - || !running.load(Ordering::Relaxed) - || active_bindings.load(Ordering::Relaxed) == 0 - { - break; - } - match tokio::time::timeout( - Duration::from_millis(300), - stream.get_mut().message(), - ) - .await - { - Ok(Ok(Some(pkt))) => { - record_preview_packet(&shared, &pkt); - push_preview_packet(&appsrc, pkt); - } - Ok(Ok(None)) => { - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview stream ended.", - true, - ); - retry_delay = Duration::from_millis(1_500); - break; - } - Ok(Err(err)) => { - warn!(monitor_id, ?err, "launcher preview stream error"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview stream error: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview stream error. See session log.", - true, - ); - retry_delay = - preview_retry_delay(retry_delay, Some(&err.to_string())); - break; - } - Err(_) => continue, - } - } - } - Err(err) => { - if preview_startup_condition(&err) { - debug!( - monitor_id, - ?err, - "launcher preview waiting for capture pipeline" - ); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Waiting for capture pipeline: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waiting for capture pipeline...", - true, - ); - retry_delay = preview_retry_delay(retry_delay, Some(err.message())); - } else { - warn!(monitor_id, ?err, "launcher preview rpc failed"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview RPC failed: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview RPC failed. See session log.", - true, - ); - retry_delay = preview_retry_delay(retry_delay, Some(err.message())); - } - } - } - tokio::time::sleep(retry_delay).await; - } - #[allow(unreachable_code)] - Ok::<(), anyhow::Error>(()) - }); - - let _ = pipeline.set_state(gst::State::Null); - - Ok(()) -} - -#[cfg(not(coverage))] -fn preview_startup_condition(err: &tonic::Status) -> bool { - let message = err.message().to_ascii_lowercase(); - err.code() == tonic::Code::Internal - && (message.contains("starting video pipeline") - || message.contains("failed to change its state") - || message.contains("resource busy") - || message.contains("device or resource busy") - || message.contains("no signal") - || message.contains("was not ready") - || message.contains("no such file or directory")) -} - -#[cfg(not(coverage))] -fn preview_retry_delay(current: Duration, message: Option<&str>) -> Duration { - let current_ms = current.as_millis() as u64; - let mut next_ms = if current_ms < 1_500 { - 1_500 - } else { - current_ms.saturating_mul(2) - }; - if let Some(message) = message { - let message = message.to_ascii_lowercase(); - if message.contains("too many open files") - || message.contains("failed to change its state") - || message.contains("resource busy") - || message.contains("device or resource busy") - { - next_ms = next_ms.max(6_000); - } - } - Duration::from_millis(next_ms.min(30_000)) -} - -#[cfg(not(coverage))] -fn set_shared_status( - shared: &Arc>, - log_sink: &Arc>>>, - monitor_id: u32, - status: impl Into, - clear: bool, -) { - let status = status.into(); - let should_log = if let Ok(mut slot) = shared.lock() { - let should_log = slot.last_logged_status.as_deref() != Some(status.as_str()); - if should_log { - slot.last_logged_status = Some(status.clone()); - } - slot.set_status(status.clone(), clear); - should_log - } else { - false - }; - if should_log { - log_preview_status(log_sink, monitor_id, &status); - } -} - -#[cfg(not(coverage))] -fn log_preview_issue( - shared: &Arc>, - log_sink: &Arc>>>, - monitor_id: u32, - message: &str, -) { - let should_log = if let Ok(mut slot) = shared.lock() { - if slot.last_logged_error.as_deref() == Some(message) { - false - } else { - slot.last_logged_error = Some(message.to_string()); - true - } - } else { - false - }; - if !should_log { - return; - } - if let Ok(slot) = log_sink.lock() - && let Some(tx) = slot.as_ref() - { - let _ = tx.send(format!( - "[preview:{}] {message}", - preview_eye_label(monitor_id) - )); - } -} - -#[cfg(not(coverage))] -fn log_preview_status( - log_sink: &Arc>>>, - monitor_id: u32, - status: &str, -) { - if status == PREVIEW_IDLE_STATUS { - return; - } - let eye = preview_eye_label(monitor_id); - let message = match status { - "Waking relay preview..." => { - format!( - "🪄 waking {eye} eye preview - «У лукоморья дуб зелёный; златая цепь на дубе том…»" - ) - } - "Connecting relay preview..." => { - format!( - "🛰️ connecting {eye} eye feed - «Там чудеса: там леший бродит, русалка на ветвях сидит…»" - ) - } - "Waiting for stream..." => { - format!( - "👁️ {eye} eye connected; waiting for first frame - «Подымите мне веки: не вижу!»" - ) - } - "Preview stream ended." => { - format!("🌙 {eye} eye stream ended - «Там лес и дол видений полны…»") - } - "Preview host is unavailable." => { - format!( - "💔 {eye} eye cannot reach preview host - «Там царь Кащей над златом чахнет; там русский дух… там Русью пахнет!»" - ) - } - "Preview address is unavailable." => { - format!( - "🧭 {eye} eye has no preview address - «Идёт направо — песнь заводит, налево — сказку говорит…»" - ) - } - "Preview address is invalid." => { - format!( - "🧭 {eye} eye got an invalid preview address - «Там на неведомых дорожках следы невиданных зверей…»" - ) - } - "Waiting for capture pipeline..." => { - format!( - "⏳ {eye} eye waiting for capture pipeline - «Избушка, избушка! Встань к лесу задом, ко мне передом.»" - ) - } - "Preview stream error. See session log." => { - format!("💥 {eye} eye preview stream error - «Фу-фу! Русским духом пахнет!»") - } - "Preview RPC failed. See session log." => { - format!( - "💥 {eye} eye preview RPC failed - «Дела давно минувших дней, преданья старины глубокой…»" - ) - } - other => format!("🎥 {eye} eye: {other}"), - }; - if let Ok(slot) = log_sink.lock() - && let Some(tx) = slot.as_ref() - { - let _ = tx.send(format!( - "[preview:{}] {message}", - preview_eye_label(monitor_id) - )); - } -} - -#[cfg(not(coverage))] -fn preview_eye_label(monitor_id: u32) -> &'static str { - match monitor_id { - 0 => "left", - 1 => "right", - _ => "eye", - } -} - -#[cfg(not(coverage))] -fn looks_like_preview_problem(status: &str) -> bool { - let lower = status.to_ascii_lowercase(); - lower.contains("unavailable") - || lower.contains("invalid") - || lower.contains("failed") - || lower.contains("waiting for capture pipeline") - || lower.contains("error") -} - -#[cfg(not(coverage))] -fn build_preview_pipeline( - profile: PreviewProfile, - decoder_name: &str, -) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> { - let source_mode = eye_source_mode_for_request( - profile.requested_width.max(2) as u32, - profile.requested_height.max(2) as u32, - ); - let (render_width, render_height) = - preview_render_size(profile, source_mode.width, source_mode.height); - let desc = format!( - "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - h264parse name=preview_parse disable-passthrough=true ! {} name=decoder ! videoconvert ! \ - videoscale add-borders=false ! \ - video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \ - appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", - decoder_name, - ); - let pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("preview pipeline"); - - let appsrc = pipeline - .by_name("src") - .context("missing preview appsrc")? - .downcast::() - .expect("preview appsrc"); - appsrc.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - appsrc.set_format(gst::Format::Time); - - let appsink = pipeline - .by_name("sink") - .context("missing preview appsink")? - .downcast::() - .expect("preview appsink"); - appsink.set_caps(Some( - &gst::Caps::builder("video/x-raw") - .field("format", &"RGBA") - .field("width", &(render_width as i32)) - .field("height", &(render_height as i32)) - .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) - .build(), - )); - - Ok((pipeline, appsrc, appsink, decoder_name.to_string())) -} - -#[cfg(not(coverage))] -fn preview_render_size( - profile: PreviewProfile, - source_width: u32, - source_height: u32, -) -> (i32, i32) { - fn round_down_even(value: i32) -> i32 { - let clamped = value.max(2); - clamped - (clamped % 2) - } - - let source_w = source_width.max(2) as f32; - let source_h = source_height.max(2) as f32; - let max_w = profile.display_width.max(2) as f32; - let max_h = profile.display_height.max(2) as f32; - let scale = (max_w / source_w).min(max_h / source_h).min(1.0).max(0.01); - let render_w = round_down_even((source_w * scale).round() as i32); - let render_h = round_down_even((source_h * scale).round() as i32); - (render_w.max(2), render_h.max(2)) -} - -#[cfg(not(coverage))] -fn preview_decoder_candidates() -> Vec { - let mut candidates = Vec::new(); - let preferred = pick_h264_decoder(); - if !preferred.trim().is_empty() { - candidates.push(preferred); - } - for name in [ - "avdec_h264", - "openh264dec", - "vah264dec", - "vaapih264dec", - "v4l2h264dec", - "v4l2slh264dec", - "nvh264dec", - "nvh264sldec", - "decodebin", - ] { - if name == "decodebin" || gst::ElementFactory::find(name).is_some() { - candidates.push(name.to_string()); - } - } - candidates.sort(); - candidates.dedup(); - if let Some(pos) = candidates - .iter() - .position(|name| name == &pick_h264_decoder()) - { - let preferred = candidates.remove(pos); - candidates.insert(0, preferred); - } - candidates -} - -#[cfg(not(coverage))] -fn push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) { - let mut buf = gst::Buffer::from_slice(pkt.data); - if let Some(buf) = buf.get_mut() { - buf.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); - } - let _ = appsrc.push_buffer(buf); -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy)] -enum PreviewCapsKind { - Stream, - Decoded, -} - -#[cfg(not(coverage))] -fn record_preview_caps( - shared: &Arc>, - element: &gst::Element, - pad_name: &str, - kind: PreviewCapsKind, -) { - let Some(pad) = element.static_pad(pad_name) else { - return; - }; - let Some(caps) = pad.current_caps() else { - return; - }; - let caps_label = preview_caps_summary(&caps); - if caps_label.is_empty() { - return; - } - if let Ok(mut slot) = shared.lock() { - match kind { - PreviewCapsKind::Stream => slot.telemetry.note_stream_caps(&caps_label), - PreviewCapsKind::Decoded => slot.telemetry.note_decoded_caps(&caps_label), - } - } -} - -#[cfg(not(coverage))] -fn preview_caps_summary(caps: &impl std::fmt::Display) -> String { - caps.to_string() -} - -#[cfg(not(coverage))] -fn record_preview_packet(shared: &Arc>, pkt: &VideoPacket) { - if let Ok(mut slot) = shared.lock() { - slot.telemetry.record_packet( - pkt.seq, - pkt.effective_fps, - pkt.dropped_total, - pkt.queue_depth, - pkt.server_source_gap_peak_ms, - pkt.server_send_gap_peak_ms, - pkt.server_queue_peak, - &pkt.server_encoder_label, - pkt.server_process_cpu_tenths, - ); - } -} - -#[cfg(not(coverage))] -fn sample_to_frame(sample: &gst::Sample) -> Option { - let caps = sample.caps()?; - let structure = caps.structure(0)?; - let width = structure.get::("width").ok()?; - let height = structure.get::("height").ok()?; - let buffer = sample.buffer()?; - let map = buffer.map_readable().ok()?; - let rgba = map.as_slice().to_vec(); - let stride = rgba.len() / height.max(1) as usize; - Some(PreviewFrame { - width, - height, - stride, - rgba, - }) -} - -#[cfg(not(coverage))] -fn preview_decoder_label(element: &gst::Element) -> Option { - let factory = element.factory()?; - let klass = factory.klass().to_ascii_lowercase(); - if !klass.contains("decoder") || !klass.contains("video") { - return None; - } - Some(factory.name().to_string()) -} - -#[cfg(not(coverage))] -fn preview_bitrate(var: &str, default: u32) -> u32 { - std::env::var(var) - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(default) -} - -#[cfg(not(coverage))] -fn preview_dimension(var: &str, default: i32) -> i32 { - std::env::var(var) - .ok() - .and_then(|raw| raw.parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(default) -} - -#[cfg(not(coverage))] -fn sanitize_preview_request( - requested_width: i32, - requested_height: i32, - requested_fps: u32, - max_bitrate_kbit: u32, -) -> (i32, i32, u32, u32) { - ( - requested_width.max(2), - requested_height.max(2), - requested_fps.max(1), - max_bitrate_kbit.max(800), - ) -} - -#[cfg(not(coverage))] -#[cfg(not(coverage))] -fn events_per_second(events: &VecDeque, now: Instant) -> f32 { - let Some(oldest) = events.front().copied() else { - return 0.0; - }; - let span = now - .saturating_duration_since(oldest) - .as_secs_f32() - .clamp(0.25, TELEMETRY_WINDOW.as_secs_f32()); - events.len() as f32 / span -} - -#[cfg(not(coverage))] -fn trim_instant_queue(queue: &mut VecDeque, now: Instant) { - while let Some(oldest) = queue.front().copied() { - if now.saturating_duration_since(oldest) > TELEMETRY_WINDOW { - let _ = queue.pop_front(); - } else { - break; - } - } -} - -#[cfg(not(coverage))] -fn trim_value_queue(queue: &mut VecDeque<(Instant, T)>, now: Instant) { - while let Some((oldest, _)) = queue.front() { - if now.saturating_duration_since(*oldest) > TELEMETRY_WINDOW { - let _ = queue.pop_front(); - } else { - break; - } - } -} - -#[cfg(not(coverage))] -fn compute_jitter_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { - if samples.len() < 2 { - return 0.0; - } - let mean = samples.iter().map(|(_, value)| *value).sum::() / samples.len() as f32; - samples - .iter() - .map(|(_, value)| (value - mean).abs()) - .sum::() - / samples.len() as f32 -} - -#[cfg(not(coverage))] -fn compute_peak_gap_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { - samples.iter().map(|(_, value)| *value).fold(0.0, f32::max) -} +// Live eye preview transport, rendering, and telemetry for the launcher. +include!("preview/preview_core.rs"); +include!("preview/feed_state.rs"); +include!("preview/feed_runtime.rs"); +include!("preview/status_pipeline.rs"); +include!("preview/frame_telemetry.rs"); #[cfg(test)] -mod tests { - use super::{ - DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, - INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, - LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, - preview_render_size, sanitize_preview_request, - }; - use crate::launcher::state::{CaptureSizePreset, LauncherState}; - use futures::stream; - use lesavka_common::lesavka::relay_server::{Relay, RelayServer}; - use lesavka_common::lesavka::{MonitorRequest, VideoPacket}; - use serial_test::serial; - use std::pin::Pin; - use std::sync::{Arc, Mutex}; - use std::time::{Duration, Instant}; - use tokio::sync::mpsc; - use tokio_stream::wrappers::ReceiverStream; - use tonic::{Request, Response, Status}; - - #[derive(Clone, Default)] - struct ProbeRelay { - requests: Arc>>, - } - - #[tonic::async_trait] - impl Relay for ProbeRelay { - type StreamKeyboardStream = Pin< - Box< - dyn futures::Stream> - + Send, - >, - >; - type StreamMouseStream = Pin< - Box< - dyn futures::Stream> - + Send, - >, - >; - type CaptureVideoStream = - Pin> + Send>>; - type CaptureAudioStream = Pin< - Box< - dyn futures::Stream> - + Send, - >, - >; - type StreamMicrophoneStream = Pin< - Box> + Send>, - >; - type StreamCameraStream = Pin< - Box> + Send>, - >; - - async fn stream_keyboard( - &self, - _request: Request>, - ) -> Result, Status> { - Ok(Response::new(Box::pin(stream::empty()))) - } - - async fn stream_mouse( - &self, - _request: Request>, - ) -> Result, Status> { - Ok(Response::new(Box::pin(stream::empty()))) - } - - async fn capture_video( - &self, - request: Request, - ) -> Result, Status> { - self.requests.lock().unwrap().push(request.into_inner()); - let (_tx, rx) = mpsc::channel(1); - Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) - } - - async fn capture_audio( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(Box::pin(stream::empty()))) - } - - async fn stream_microphone( - &self, - _request: Request>, - ) -> Result, Status> { - Ok(Response::new(Box::pin(stream::empty()))) - } - - async fn stream_camera( - &self, - _request: Request>, - ) -> Result, Status> { - Ok(Response::new(Box::pin(stream::empty()))) - } - - async fn paste_text( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(lesavka_common::lesavka::PasteReply { - ok: true, - error: String::new(), - })) - } - - async fn reset_usb( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { - ok: true, - })) - } - - async fn get_capture_power( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(lesavka_common::lesavka::CapturePowerState { - available: true, - enabled: true, - unit: "relay.service".to_string(), - detail: "active/running".to_string(), - active_leases: 1, - mode: "auto".to_string(), - detected_devices: 2, - })) - } - - async fn set_capture_power( - &self, - _request: Request, - ) -> Result, Status> { - self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {})) - .await - } - } - - #[test] - fn inline_preview_profile_uses_lightweight_defaults() { - let profile = PreviewSurface::Inline.profile(); - assert_eq!(profile.display_width, PREVIEW_WIDTH); - assert_eq!(profile.display_height, PREVIEW_HEIGHT); - assert_eq!(profile.requested_width, INLINE_PREVIEW_REQUEST_WIDTH); - assert_eq!(profile.requested_height, INLINE_PREVIEW_REQUEST_HEIGHT); - assert_eq!(profile.requested_fps, INLINE_PREVIEW_REQUEST_FPS); - assert_eq!(profile.max_bitrate_kbit, INLINE_PREVIEW_MAX_KBIT); - } - - #[test] - fn breakout_preview_profile_defaults_to_higher_quality() { - let profile = PreviewSurface::Window.profile(); - assert_eq!(profile.display_width, 1280); - assert_eq!(profile.display_height, 720); - assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH); - assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT); - assert_eq!(profile.requested_fps, 60); - assert_eq!(profile.max_bitrate_kbit, 18_000); - } - - #[test] - fn preview_render_size_fits_source_into_display_budget() { - let profile = PreviewSurface::Inline.profile(); - assert_eq!(preview_render_size(profile, 1920, 1080), (960, 540)); - } - - #[test] - fn preview_render_size_never_upscales_beyond_source_geometry() { - let profile = PreviewSurface::Window.profile(); - assert_eq!(preview_render_size(profile, 1280, 720), (1280, 720)); - } - - #[test] - fn preview_request_sanitizer_keeps_requested_source_geometry() { - let adapted = sanitize_preview_request(1920, 1080, 60, 18_000); - assert_eq!(adapted, (1920, 1080, 60, 18_000)); - } - - #[test] - fn preview_request_sanitizer_clamps_invalid_values() { - let adapted = sanitize_preview_request(0, 0, 0, 0); - assert_eq!(adapted, (2, 2, 1, 800)); - } - - #[test] - fn preview_telemetry_reports_fps_jitter_loss_and_drop_metrics() { - let mut telemetry = PreviewTelemetry::default(); - let start = Instant::now(); - telemetry.note_decoder("nvh264dec"); - telemetry.record_packet_at(start, 1, 30, 0, 1, 41, 38, 2, "x264enc", 215); - telemetry.record_presented_frame_at(start + Duration::from_millis(5)); - telemetry.record_packet_at( - start + Duration::from_millis(33), - 2, - 30, - 0, - 1, - 41, - 38, - 2, - "x264enc", - 215, - ); - telemetry.record_presented_frame_at(start + Duration::from_millis(37)); - telemetry.record_packet_at( - start + Duration::from_millis(80), - 4, - 27, - 2, - 3, - 77, - 88, - 4, - "x264enc", - 382, - ); - telemetry.record_presented_frame_at(start + Duration::from_millis(90)); - - let snapshot = telemetry.snapshot_at(start + Duration::from_millis(120)); - assert!(snapshot.receive_fps >= 12.0); - assert!(snapshot.present_fps >= 12.0); - assert_eq!(snapshot.server_fps, 27.0); - assert!(snapshot.stream_spread_ms > 0.0); - assert!(snapshot.packet_loss_pct > 0.0); - assert_eq!(snapshot.dropped_frames, 2); - assert_eq!(snapshot.queue_depth, 3); - assert_eq!(snapshot.queue_depth_peak, 3); - assert!(snapshot.packet_gap_peak_ms >= 47.0); - assert!(snapshot.present_gap_peak_ms >= 53.0); - assert_eq!(snapshot.server_source_gap_peak_ms, 77.0); - assert_eq!(snapshot.server_send_gap_peak_ms, 88.0); - assert_eq!(snapshot.server_queue_peak, 4); - assert_eq!(snapshot.server_process_cpu_pct, 38.2); - assert_eq!(snapshot.server_encoder_label, "x264enc"); - assert_eq!(snapshot.decoder_label, "nvh264dec"); - } - - #[test] - #[serial] - fn inline_preview_requests_selected_source_profile_on_wire() { - let relay = ProbeRelay::default(); - let requests = relay.requests.clone(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let addr = rt.block_on(async move { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(relay)) - .serve(addr) - .await; - }); - addr - }); - - let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); - let state = LauncherState::default(); - let capture = state.capture_size_choice(1); - preview.set_capture_profile( - 1, - 1, - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit, - ); - preview.activate_surface_for_test(1, PreviewSurface::Inline); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if let Some(request) = requests.lock().unwrap().last().cloned() { - assert_eq!(request.id, 1); - assert_eq!(request.source_id, Some(1)); - assert_eq!(request.requested_width, 1920); - assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 60); - assert_eq!(request.max_bitrate, 18_000); - preview.shutdown_all(); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - preview.shutdown_all(); - panic!("preview did not issue a capture request within timeout"); - } - - #[test] - #[serial] - fn inline_preview_requests_honest_source_profile_on_wire() { - let relay = ProbeRelay::default(); - let requests = relay.requests.clone(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let addr = rt.block_on(async move { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(relay)) - .serve(addr) - .await; - }); - addr - }); - - let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); - let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::P1080); - let capture = state.capture_size_choice(1); - preview.set_capture_profile( - 1, - 1, - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit, - ); - preview.activate_surface_for_test(1, PreviewSurface::Inline); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if let Some(request) = requests.lock().unwrap().last().cloned() { - assert_eq!(request.id, 1); - assert_eq!(request.source_id, Some(1)); - assert_eq!(request.requested_width, 1920); - assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 60); - assert_eq!(request.max_bitrate, 18_000); - preview.shutdown_all(); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - preview.shutdown_all(); - panic!("preview did not issue a source capture request within timeout"); - } - - #[test] - #[serial] - fn inline_preview_requests_native_720p_source_mode_on_wire() { - let relay = ProbeRelay::default(); - let requests = relay.requests.clone(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let addr = rt.block_on(async move { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(relay)) - .serve(addr) - .await; - }); - addr - }); - - let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); - let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::P720); - let capture = state.capture_size_choice(1); - preview.set_capture_profile( - 1, - 1, - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit, - ); - preview.activate_surface_for_test(1, PreviewSurface::Inline); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if let Some(request) = requests.lock().unwrap().last().cloned() { - assert_eq!(request.id, 1); - assert_eq!(request.source_id, Some(1)); - assert_eq!(request.requested_width, 1280); - assert_eq!(request.requested_height, 720); - assert_eq!(request.requested_fps, 60); - assert_eq!(request.max_bitrate, 12_000); - preview.shutdown_all(); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - preview.shutdown_all(); - panic!("preview did not issue a 720p source capture request within timeout"); - } - - #[test] - #[serial] - fn inline_preview_legacy_low_modes_fall_forward_to_720p_on_wire() { - let relay = ProbeRelay::default(); - let requests = relay.requests.clone(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let addr = rt.block_on(async move { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(relay)) - .serve(addr) - .await; - }); - addr - }); - - let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); - let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::P480); - let capture = state.capture_size_choice(1); - preview.set_capture_profile( - 1, - 1, - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit, - ); - preview.activate_surface_for_test(1, PreviewSurface::Inline); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if let Some(request) = requests.lock().unwrap().last().cloned() { - assert_eq!(request.id, 1); - assert_eq!(request.source_id, Some(1)); - assert_eq!(request.requested_width, 1280); - assert_eq!(request.requested_height, 720); - assert_eq!(request.requested_fps, 60); - assert_eq!(request.max_bitrate, 12_000); - preview.shutdown_all(); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - preview.shutdown_all(); - panic!("preview did not issue a 720p fallback source capture request within timeout"); - } - - #[test] - #[serial] - fn preview_can_request_other_eye_as_a_distinct_stream() { - let relay = ProbeRelay::default(); - let requests = relay.requests.clone(); - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let addr = rt.block_on(async move { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(relay)) - .serve(addr) - .await; - }); - addr - }); - - let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); - preview.set_capture_profile(0, 1, 1920, 1080, 30, 12_000); - preview.activate_surface_for_test(0, PreviewSurface::Inline); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if let Some(request) = requests.lock().unwrap().last().cloned() { - assert_eq!(request.id, 0); - assert_eq!(request.source_id, Some(1)); - assert_eq!(request.requested_width, 1920); - assert_eq!(request.requested_height, 1080); - preview.shutdown_all(); - return; - } - std::thread::sleep(Duration::from_millis(50)); - } - - preview.shutdown_all(); - panic!("preview did not issue a mirrored capture request within timeout"); - } -} +#[path = "tests/preview.rs"] +mod tests; diff --git a/client/src/launcher/preview/feed_runtime.rs b/client/src/launcher/preview/feed_runtime.rs new file mode 100644 index 0000000..0e5e167 --- /dev/null +++ b/client/src/launcher/preview/feed_runtime.rs @@ -0,0 +1,492 @@ +#[cfg(not(coverage))] +impl PreviewFeed { + fn spawn( + server_addr: Arc>, + monitor_id: u32, + profile: PreviewProfile, + log_sink: Arc>>>, + ) -> Result { + let shared = Arc::new(Mutex::new(SharedPreviewState::new())); + let session_active = Arc::new(AtomicBool::new(false)); + let active_bindings = Arc::new(AtomicUsize::new(0)); + let running = Arc::new(AtomicBool::new(true)); + let shared_state = Arc::clone(&shared); + let session_active_flag = Arc::clone(&session_active); + let active_bindings_flag = Arc::clone(&active_bindings); + let running_flag = Arc::clone(&running); + std::thread::spawn(move || { + if let Err(err) = run_preview_feed( + server_addr, + monitor_id, + profile, + session_active_flag, + active_bindings_flag, + running_flag, + Arc::clone(&shared_state), + Arc::clone(&log_sink), + ) { + set_shared_status( + &shared_state, + &log_sink, + monitor_id, + "Preview pipeline setup failed. See session log.", + true, + ); + log_preview_issue( + &shared_state, + &log_sink, + monitor_id, + &format!("Preview feed startup failed: {err:#}"), + ); + warn!(monitor_id, ?err, "launcher preview feed exited"); + } + }); + Ok(Self { + shared, + session_active, + active_bindings, + running, + profile, + disabled: false, + }) + } + + fn spawn_disabled(profile: PreviewProfile) -> Self { + let shared = Arc::new(Mutex::new(SharedPreviewState::new())); + if let Ok(mut slot) = shared.lock() { + slot.set_status("Feed disabled.", true); + } + Self { + shared, + session_active: Arc::new(AtomicBool::new(false)), + active_bindings: Arc::new(AtomicUsize::new(0)), + running: Arc::new(AtomicBool::new(false)), + profile, + disabled: true, + } + } + + fn profile(&self) -> PreviewProfile { + self.profile + } + + fn is_disabled(&self) -> bool { + self.disabled + } + + fn is_active(&self) -> bool { + self.session_active.load(Ordering::Relaxed) + } + + fn set_active(&self, active: bool) { + self.session_active.store(active, Ordering::Relaxed); + if !active && !self.disabled { + self.replace_status(PREVIEW_IDLE_STATUS, true); + } + } + + fn shutdown(&self) { + self.running.store(false, Ordering::Relaxed); + self.replace_status( + if self.disabled { + "Feed disabled." + } else { + PREVIEW_IDLE_STATUS + }, + true, + ); + } + + fn replace_status(&self, status: impl Into, clear_picture: bool) { + if let Ok(mut shared) = self.shared.lock() { + shared.set_status(status, clear_picture); + } + } + + fn install_on_picture( + &self, + picture: >k::Picture, + status_label: >k::Label, + ) -> PreviewBinding { + let picture = picture.clone(); + let status_label = status_label.clone(); + let shared = Arc::clone(&self.shared); + let enabled = Arc::new(AtomicBool::new(true)); + let alive = Arc::new(AtomicBool::new(true)); + let active_bindings = Arc::clone(&self.active_bindings); + let enabled_flag = Arc::clone(&enabled); + let alive_flag = Arc::clone(&alive); + active_bindings.fetch_add(1, Ordering::AcqRel); + let mut last_generation = 0_u64; + glib::timeout_add_local(Duration::from_millis(120), move || { + if !alive_flag.load(Ordering::Relaxed) { + return glib::ControlFlow::Break; + } + if !enabled_flag.load(Ordering::Relaxed) { + return glib::ControlFlow::Continue; + } + + let (frame, status, generation, clear_picture) = match shared.lock() { + Ok(mut slot) => { + let frame = slot.latest.take(); + let status = slot.status.clone(); + let generation = slot.generation; + let clear_picture = slot.clear_picture; + slot.clear_picture = false; + (frame, status, generation, clear_picture) + } + Err(_) => return glib::ControlFlow::Continue, + }; + + if clear_picture { + picture.set_paintable(Option::<&gdk::Paintable>::None); + } + if let Some(frame) = frame { + let bytes = glib::Bytes::from_owned(frame.rgba); + let texture = gdk::MemoryTexture::new( + frame.width, + frame.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + frame.stride, + ); + picture.set_paintable(Some(&texture)); + } + if generation != last_generation { + status_label.set_text(&status); + status_label.set_tooltip_text(Some(&status)); + last_generation = generation; + } + glib::ControlFlow::Continue + }); + PreviewBinding { + enabled, + alive, + active_bindings, + } + } + + fn snapshot_metrics(&self) -> PreviewMetricsSnapshot { + self.shared + .lock() + .map(|mut shared| shared.telemetry.snapshot()) + .unwrap_or_default() + } +} + +#[cfg(not(coverage))] +struct PreviewFrame { + width: i32, + height: i32, + stride: usize, + rgba: Vec, +} + +#[cfg(not(coverage))] +#[allow(clippy::too_many_arguments)] +fn run_preview_feed( + server_addr: Arc>, + monitor_id: u32, + profile: PreviewProfile, + session_active: Arc, + active_bindings: Arc, + running: Arc, + shared: Arc>, + log_sink: Arc>>>, +) -> Result<()> { + let mut startup_error = None; + let mut selected = None; + for decoder_name in preview_decoder_candidates() { + match build_preview_pipeline(profile, &decoder_name) { + Ok((pipeline, appsrc, appsink, decoder_label)) => { + match pipeline + .set_state(gst::State::Playing) + .context("starting launcher preview pipeline") + { + Ok(_) => { + selected = Some((pipeline, appsrc, appsink, decoder_label)); + break; + } + Err(err) => { + let _ = pipeline.set_state(gst::State::Null); + startup_error = Some(err); + } + } + } + Err(err) => { + startup_error = Some(err); + } + } + } + let (pipeline, appsrc, appsink, decoder_name) = selected.ok_or_else(|| { + startup_error.unwrap_or_else(|| anyhow::anyhow!("no usable H.264 decoder")) + })?; + let parser = pipeline.by_name("preview_parse"); + let decoder = pipeline.by_name("decoder"); + if let Ok(mut slot) = shared.lock() { + slot.telemetry.note_decoder(&decoder_name); + } + { + let shared = Arc::clone(&shared); + pipeline.connect_deep_element_added(move |_, _, element| { + if let Some(decoder_label) = preview_decoder_label(element) + && let Ok(mut slot) = shared.lock() + { + slot.telemetry.note_decoder(&decoder_label); + } + }); + } + { + let shared = Arc::clone(&shared); + let appsink = appsink.clone(); + let parser = parser.clone(); + let decoder = decoder.clone(); + let running = Arc::clone(&running); + std::thread::spawn(move || { + loop { + if !running.load(Ordering::Relaxed) { + break; + } + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + if let Some(parser) = parser.as_ref() { + record_preview_caps(&shared, parser, "src", PreviewCapsKind::Stream); + } + if let Some(decoder) = decoder.as_ref() { + record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); + } + if let Some(caps) = sample.caps() { + let caps_label = preview_caps_summary(&caps); + if !caps_label.is_empty() + && let Ok(mut slot) = shared.lock() + { + slot.telemetry.note_rendered_caps(&caps_label); + } + } + if let Some(frame) = sample_to_frame(&sample) + && let Ok(mut slot) = shared.lock() { + slot.push_frame(frame); + } + } + } + }); + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("building preview tokio runtime")?; + + let _ = rt.block_on(async move { + let mut was_active = false; + let mut retry_delay = Duration::from_millis(750); + loop { + if !running.load(Ordering::Relaxed) { + break; + } + let active_now = session_active.load(Ordering::Relaxed) + && active_bindings.load(Ordering::Relaxed) > 0; + if !active_now { + was_active = false; + retry_delay = Duration::from_millis(750); + set_shared_status(&shared, &log_sink, monitor_id, PREVIEW_IDLE_STATUS, true); + tokio::time::sleep(Duration::from_millis(150)).await; + continue; + } + + if !was_active { + was_active = true; + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waking relay preview...", + true, + ); + tokio::time::sleep(Duration::from_millis(350)).await; + } + + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Connecting relay preview...", + true, + ); + let current_addr = match server_addr.lock() { + Ok(value) => value.clone(), + Err(_) => { + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview address is unavailable.", + true, + ); + tokio::time::sleep(Duration::from_millis(750)).await; + continue; + } + }; + + let channel = match Channel::from_shared(current_addr.clone()) { + Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { + Ok(channel) => channel, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview connect failed"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview host is unavailable: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview host is unavailable.", + true, + ); + tokio::time::sleep(retry_delay).await; + continue; + } + }, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview endpoint invalid"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview address is invalid: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview address is invalid.", + true, + ); + tokio::time::sleep(retry_delay).await; + continue; + } + }; + + let mut cli = RelayClient::new(channel); + let req = MonitorRequest { + id: monitor_id, + max_bitrate: profile.max_bitrate_kbit, + requested_width: profile.requested_width.max(0) as u32, + requested_height: profile.requested_height.max(0) as u32, + requested_fps: profile.requested_fps, + source_id: Some(profile.source_monitor_id), + }; + match cli.capture_video(Request::new(req)).await { + Ok(mut stream) => { + retry_delay = Duration::from_millis(750); + debug!(monitor_id, "launcher preview connected"); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waiting for stream...", + true, + ); + loop { + if !session_active.load(Ordering::Relaxed) + || !running.load(Ordering::Relaxed) + || active_bindings.load(Ordering::Relaxed) == 0 + { + break; + } + match tokio::time::timeout( + Duration::from_millis(300), + stream.get_mut().message(), + ) + .await + { + Ok(Ok(Some(pkt))) => { + record_preview_packet(&shared, &pkt); + push_preview_packet(&appsrc, pkt); + } + Ok(Ok(None)) => { + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview stream ended.", + true, + ); + retry_delay = Duration::from_millis(1_500); + break; + } + Ok(Err(err)) => { + warn!(monitor_id, ?err, "launcher preview stream error"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview stream error: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview stream error. See session log.", + true, + ); + retry_delay = + preview_retry_delay(retry_delay, Some(&err.to_string())); + break; + } + Err(_) => continue, + } + } + } + Err(err) => { + if preview_startup_condition(&err) { + debug!( + monitor_id, + ?err, + "launcher preview waiting for capture pipeline" + ); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Waiting for capture pipeline: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waiting for capture pipeline...", + true, + ); + retry_delay = preview_retry_delay(retry_delay, Some(err.message())); + } else { + warn!(monitor_id, ?err, "launcher preview rpc failed"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview RPC failed: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview RPC failed. See session log.", + true, + ); + retry_delay = preview_retry_delay(retry_delay, Some(err.message())); + } + } + } + tokio::time::sleep(retry_delay).await; + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); + + let _ = pipeline.set_state(gst::State::Null); + + Ok(()) +} diff --git a/client/src/launcher/preview/feed_state.rs b/client/src/launcher/preview/feed_state.rs new file mode 100644 index 0000000..11eb398 --- /dev/null +++ b/client/src/launcher/preview/feed_state.rs @@ -0,0 +1,303 @@ +#[cfg(not(coverage))] +impl PreviewBinding { + pub fn set_enabled(&self, enabled: bool) { + let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel); + match (was_enabled, enabled) { + (false, true) => { + self.active_bindings.fetch_add(1, Ordering::AcqRel); + } + (true, false) => { + self.active_bindings.fetch_sub(1, Ordering::AcqRel); + } + _ => {} + } + } + + pub fn close(&self) { + if !self.alive.swap(false, Ordering::AcqRel) { + return; + } + if self.enabled.swap(false, Ordering::AcqRel) { + self.active_bindings.fetch_sub(1, Ordering::AcqRel); + } + } + + #[cfg(test)] + pub(crate) fn test_stub() -> Self { + Self { + enabled: Arc::new(AtomicBool::new(true)), + alive: Arc::new(AtomicBool::new(true)), + active_bindings: Arc::new(AtomicUsize::new(1)), + } + } +} + +#[cfg(not(coverage))] +#[derive(Clone)] +struct PreviewFeed { + shared: Arc>, + session_active: Arc, + active_bindings: Arc, + running: Arc, + profile: PreviewProfile, + disabled: bool, +} + +#[cfg(not(coverage))] +struct SharedPreviewState { + latest: Option, + status: String, + generation: u64, + clear_picture: bool, + last_logged_error: Option, + last_logged_status: Option, + telemetry: PreviewTelemetry, +} + +#[cfg(not(coverage))] +impl SharedPreviewState { + fn new() -> Self { + Self { + latest: None, + status: PREVIEW_IDLE_STATUS.to_string(), + generation: 1, + clear_picture: true, + last_logged_error: None, + last_logged_status: None, + telemetry: PreviewTelemetry::default(), + } + } + + fn set_status(&mut self, status: impl Into, clear_picture: bool) { + let status = status.into(); + let changed = self.status != status || clear_picture; + self.status = status.clone(); + if clear_picture { + self.latest = None; + self.clear_picture = true; + } + if !looks_like_preview_problem(&status) { + self.last_logged_error = None; + } + if changed { + self.generation = self.generation.saturating_add(1); + } + } + + fn push_frame(&mut self, frame: PreviewFrame) { + self.telemetry.record_presented_frame(); + self.latest = Some(frame); + self.clear_picture = false; + self.last_logged_error = None; + if self.status != "Live" { + self.status = "Live".to_string(); + self.generation = self.generation.saturating_add(1); + } + } +} + +#[cfg(not(coverage))] +#[derive(Debug, Default)] +struct PreviewTelemetry { + packet_times: VecDeque, + frame_times: VecDeque, + packet_intervals_ms: VecDeque<(Instant, f32)>, + frame_intervals_ms: VecDeque<(Instant, f32)>, + packet_losses: VecDeque<(Instant, u64)>, + dropped_deltas: VecDeque<(Instant, u64)>, + queue_depth_samples: VecDeque<(Instant, u32)>, + last_packet_at: Option, + last_frame_at: Option, + last_seq: Option, + last_dropped_total: Option, + latest_server_fps: u32, + latest_server_process_cpu_tenths: u32, + latest_queue_depth: u32, + latest_server_source_gap_peak_ms: u32, + latest_server_send_gap_peak_ms: u32, + latest_server_queue_peak: u32, + latest_server_encoder_label: String, + decoder_label: String, + stream_caps_label: String, + decoded_caps_label: String, + rendered_caps_label: String, +} + +#[cfg(not(coverage))] +impl PreviewTelemetry { + #[allow(clippy::too_many_arguments)] + fn record_packet( + &mut self, + seq: u64, + server_fps: u32, + dropped_total: u64, + queue_depth: u32, + server_source_gap_peak_ms: u32, + server_send_gap_peak_ms: u32, + server_queue_peak: u32, + server_encoder_label: &str, + server_process_cpu_tenths: u32, + ) { + self.record_packet_at( + Instant::now(), + seq, + server_fps, + dropped_total, + queue_depth, + server_source_gap_peak_ms, + server_send_gap_peak_ms, + server_queue_peak, + server_encoder_label, + server_process_cpu_tenths, + ); + } + + #[allow(clippy::too_many_arguments)] + fn record_packet_at( + &mut self, + now: Instant, + seq: u64, + server_fps: u32, + dropped_total: u64, + queue_depth: u32, + server_source_gap_peak_ms: u32, + server_send_gap_peak_ms: u32, + server_queue_peak: u32, + server_encoder_label: &str, + server_process_cpu_tenths: u32, + ) { + self.trim(now); + self.packet_times.push_back(now); + if let Some(previous) = self.last_packet_at.replace(now) { + self.packet_intervals_ms.push_back(( + now, + now.saturating_duration_since(previous).as_secs_f32() * 1000.0, + )); + } + if seq > 0 { + if let Some(previous_seq) = self.last_seq + && seq > previous_seq + 1 + { + self.packet_losses + .push_back((now, seq.saturating_sub(previous_seq + 1))); + } + self.last_seq = Some(seq); + } + if let Some(previous_dropped) = self.last_dropped_total + && dropped_total > previous_dropped + { + self.dropped_deltas + .push_back((now, dropped_total.saturating_sub(previous_dropped))); + } + self.last_dropped_total = Some(dropped_total); + self.latest_server_fps = server_fps.max(1); + self.latest_server_process_cpu_tenths = server_process_cpu_tenths; + self.latest_queue_depth = queue_depth; + self.latest_server_source_gap_peak_ms = server_source_gap_peak_ms; + self.latest_server_send_gap_peak_ms = server_send_gap_peak_ms; + self.latest_server_queue_peak = server_queue_peak.max(queue_depth); + if !server_encoder_label.is_empty() { + self.latest_server_encoder_label = server_encoder_label.to_string(); + } + self.queue_depth_samples.push_back((now, queue_depth)); + self.trim(now); + } + + fn record_presented_frame(&mut self) { + self.record_presented_frame_at(Instant::now()); + } + + fn record_presented_frame_at(&mut self, now: Instant) { + self.trim(now); + if let Some(previous) = self.last_frame_at.replace(now) { + self.frame_intervals_ms.push_back(( + now, + now.saturating_duration_since(previous).as_secs_f32() * 1000.0, + )); + } + self.frame_times.push_back(now); + } + + fn note_decoder(&mut self, decoder_label: &str) { + if !decoder_label.is_empty() { + self.decoder_label = decoder_label.to_string(); + } + } + + fn note_stream_caps(&mut self, caps_label: &str) { + if !caps_label.is_empty() { + self.stream_caps_label = caps_label.to_string(); + } + } + + fn note_decoded_caps(&mut self, caps_label: &str) { + if !caps_label.is_empty() { + self.decoded_caps_label = caps_label.to_string(); + } + } + + fn note_rendered_caps(&mut self, caps_label: &str) { + if !caps_label.is_empty() { + self.rendered_caps_label = caps_label.to_string(); + } + } + + fn snapshot(&mut self) -> PreviewMetricsSnapshot { + self.snapshot_at(Instant::now()) + } + + fn snapshot_at(&mut self, now: Instant) -> PreviewMetricsSnapshot { + self.trim(now); + let receive_fps = events_per_second(&self.packet_times, now); + let present_fps = events_per_second(&self.frame_times, now); + let delivered = self.packet_times.len() as u64; + let packet_losses: u64 = self.packet_losses.iter().map(|(_, loss)| *loss).sum(); + let packet_loss_pct = if delivered + packet_losses == 0 { + 0.0 + } else { + packet_losses as f32 * 100.0 / (delivered + packet_losses) as f32 + }; + let dropped_frames: u64 = self + .dropped_deltas + .iter() + .map(|(_, dropped)| *dropped) + .sum(); + let queue_depth_peak = self + .queue_depth_samples + .iter() + .map(|(_, depth)| *depth) + .max() + .unwrap_or(self.latest_queue_depth); + PreviewMetricsSnapshot { + receive_fps, + present_fps, + server_fps: self.latest_server_fps as f32, + server_process_cpu_pct: self.latest_server_process_cpu_tenths as f32 / 10.0, + stream_spread_ms: compute_jitter_ms(&self.packet_intervals_ms), + packet_loss_pct, + dropped_frames, + queue_depth: self.latest_queue_depth, + queue_depth_peak, + packet_gap_peak_ms: compute_peak_gap_ms(&self.packet_intervals_ms), + present_gap_peak_ms: compute_peak_gap_ms(&self.frame_intervals_ms), + server_source_gap_peak_ms: self.latest_server_source_gap_peak_ms as f32, + server_send_gap_peak_ms: self.latest_server_send_gap_peak_ms as f32, + server_queue_peak: self.latest_server_queue_peak, + server_encoder_label: self.latest_server_encoder_label.clone(), + decoder_label: self.decoder_label.clone(), + stream_caps_label: self.stream_caps_label.clone(), + decoded_caps_label: self.decoded_caps_label.clone(), + rendered_caps_label: self.rendered_caps_label.clone(), + } + } + + fn trim(&mut self, now: Instant) { + trim_instant_queue(&mut self.packet_times, now); + trim_instant_queue(&mut self.frame_times, now); + trim_value_queue(&mut self.packet_intervals_ms, now); + trim_value_queue(&mut self.frame_intervals_ms, now); + trim_value_queue(&mut self.packet_losses, now); + trim_value_queue(&mut self.dropped_deltas, now); + trim_value_queue(&mut self.queue_depth_samples, now); + } +} diff --git a/client/src/launcher/preview/frame_telemetry.rs b/client/src/launcher/preview/frame_telemetry.rs new file mode 100644 index 0000000..7320394 --- /dev/null +++ b/client/src/launcher/preview/frame_telemetry.rs @@ -0,0 +1,175 @@ +#[cfg(not(coverage))] +fn push_preview_packet(appsrc: &gst_app::AppSrc, pkt: VideoPacket) { + let mut buf = gst::Buffer::from_slice(pkt.data); + if let Some(buf) = buf.get_mut() { + buf.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + } + let _ = appsrc.push_buffer(buf); +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy)] +enum PreviewCapsKind { + Stream, + Decoded, +} + +#[cfg(not(coverage))] +fn record_preview_caps( + shared: &Arc>, + element: &gst::Element, + pad_name: &str, + kind: PreviewCapsKind, +) { + let Some(pad) = element.static_pad(pad_name) else { + return; + }; + let Some(caps) = pad.current_caps() else { + return; + }; + let caps_label = preview_caps_summary(&caps); + if caps_label.is_empty() { + return; + } + if let Ok(mut slot) = shared.lock() { + match kind { + PreviewCapsKind::Stream => slot.telemetry.note_stream_caps(&caps_label), + PreviewCapsKind::Decoded => slot.telemetry.note_decoded_caps(&caps_label), + } + } +} + +#[cfg(not(coverage))] +fn preview_caps_summary(caps: &impl std::fmt::Display) -> String { + caps.to_string() +} + +#[cfg(not(coverage))] +fn record_preview_packet(shared: &Arc>, pkt: &VideoPacket) { + if let Ok(mut slot) = shared.lock() { + slot.telemetry.record_packet( + pkt.seq, + pkt.effective_fps, + pkt.dropped_total, + pkt.queue_depth, + pkt.server_source_gap_peak_ms, + pkt.server_send_gap_peak_ms, + pkt.server_queue_peak, + &pkt.server_encoder_label, + pkt.server_process_cpu_tenths, + ); + } +} + +#[cfg(not(coverage))] +fn sample_to_frame(sample: &gst::Sample) -> Option { + let caps = sample.caps()?; + let structure = caps.structure(0)?; + let width = structure.get::("width").ok()?; + let height = structure.get::("height").ok()?; + let buffer = sample.buffer()?; + let map = buffer.map_readable().ok()?; + let rgba = map.as_slice().to_vec(); + let stride = rgba.len() / height.max(1) as usize; + Some(PreviewFrame { + width, + height, + stride, + rgba, + }) +} + +#[cfg(not(coverage))] +fn preview_decoder_label(element: &gst::Element) -> Option { + let factory = element.factory()?; + let klass = factory.klass().to_ascii_lowercase(); + if !klass.contains("decoder") || !klass.contains("video") { + return None; + } + Some(factory.name().to_string()) +} + +#[cfg(not(coverage))] +fn preview_bitrate(var: &str, default: u32) -> u32 { + std::env::var(var) + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(default) +} + +#[cfg(not(coverage))] +fn preview_dimension(var: &str, default: i32) -> i32 { + std::env::var(var) + .ok() + .and_then(|raw| raw.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +#[cfg(not(coverage))] +fn sanitize_preview_request( + requested_width: i32, + requested_height: i32, + requested_fps: u32, + max_bitrate_kbit: u32, +) -> (i32, i32, u32, u32) { + ( + requested_width.max(2), + requested_height.max(2), + requested_fps.max(1), + max_bitrate_kbit.max(800), + ) +} + +#[cfg(not(coverage))] +#[cfg(not(coverage))] +fn events_per_second(events: &VecDeque, now: Instant) -> f32 { + let Some(oldest) = events.front().copied() else { + return 0.0; + }; + let span = now + .saturating_duration_since(oldest) + .as_secs_f32() + .clamp(0.25, TELEMETRY_WINDOW.as_secs_f32()); + events.len() as f32 / span +} + +#[cfg(not(coverage))] +fn trim_instant_queue(queue: &mut VecDeque, now: Instant) { + while let Some(oldest) = queue.front().copied() { + if now.saturating_duration_since(oldest) > TELEMETRY_WINDOW { + let _ = queue.pop_front(); + } else { + break; + } + } +} + +#[cfg(not(coverage))] +fn trim_value_queue(queue: &mut VecDeque<(Instant, T)>, now: Instant) { + while let Some((oldest, _)) = queue.front() { + if now.saturating_duration_since(*oldest) > TELEMETRY_WINDOW { + let _ = queue.pop_front(); + } else { + break; + } + } +} + +#[cfg(not(coverage))] +fn compute_jitter_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { + if samples.len() < 2 { + return 0.0; + } + let mean = samples.iter().map(|(_, value)| *value).sum::() / samples.len() as f32; + samples + .iter() + .map(|(_, value)| (value - mean).abs()) + .sum::() + / samples.len() as f32 +} + +#[cfg(not(coverage))] +fn compute_peak_gap_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { + samples.iter().map(|(_, value)| *value).fold(0.0, f32::max) +} diff --git a/client/src/launcher/preview/preview_core.rs b/client/src/launcher/preview/preview_core.rs new file mode 100644 index 0000000..89f64fc --- /dev/null +++ b/client/src/launcher/preview/preview_core.rs @@ -0,0 +1,498 @@ +#[cfg(not(coverage))] +use crate::video_support::pick_h264_decoder; +#[cfg(not(coverage))] +use anyhow::{Context, Result}; +#[cfg(not(coverage))] +use gstreamer as gst; +#[cfg(not(coverage))] +use gstreamer::prelude::{Cast, ElementExt, GstBinExt, GstObjectExt, PadExt}; +#[cfg(not(coverage))] +use gstreamer_app as gst_app; +#[cfg(not(coverage))] +use gtk::prelude::WidgetExt; +#[cfg(not(coverage))] +use gtk::{gdk, glib}; +use lesavka_common::{ + eye_source::eye_source_mode_for_request, + lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}, +}; +#[cfg(not(coverage))] +use std::collections::VecDeque; +#[cfg(not(coverage))] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +#[cfg(not(coverage))] +use std::sync::{Arc, Mutex}; +#[cfg(not(coverage))] +use std::time::{Duration, Instant}; +#[cfg(not(coverage))] +use tonic::{Request, transport::Channel}; +#[cfg(not(coverage))] +use tracing::{debug, warn}; + +#[cfg(not(coverage))] +const PREVIEW_WIDTH: i32 = 960; +#[cfg(not(coverage))] +const PREVIEW_HEIGHT: i32 = 540; +#[cfg(not(coverage))] +const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH; +#[cfg(not(coverage))] +const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT; +#[cfg(not(coverage))] +const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS; +#[cfg(not(coverage))] +const INLINE_PREVIEW_MAX_KBIT: u32 = DEFAULT_EYE_SOURCE_MAX_KBIT; +#[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920; +#[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080; +#[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_FPS: u32 = 60; +#[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_MAX_KBIT: u32 = 18_000; +#[cfg(not(coverage))] +const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview."; +#[cfg(not(coverage))] +const TELEMETRY_WINDOW: Duration = Duration::from_secs(5); + +#[cfg(not(coverage))] +pub struct LauncherPreview { + server_addr: Arc>, + log_sink: Arc>>>, + inline_feeds: Arc>, + window_feeds: Arc>, +} + +#[cfg(not(coverage))] +#[derive(Clone)] +pub struct PreviewBinding { + enabled: Arc, + alive: Arc, + active_bindings: Arc, +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy, Debug)] +pub enum PreviewSurface { + Inline, + Window, +} + +#[cfg(not(coverage))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PreviewMetricsSnapshot { + pub receive_fps: f32, + pub present_fps: f32, + pub server_fps: f32, + pub server_process_cpu_pct: f32, + pub stream_spread_ms: f32, + pub packet_loss_pct: f32, + pub dropped_frames: u64, + pub queue_depth: u32, + pub queue_depth_peak: u32, + pub packet_gap_peak_ms: f32, + pub present_gap_peak_ms: f32, + pub server_source_gap_peak_ms: f32, + pub server_send_gap_peak_ms: f32, + pub server_queue_peak: u32, + pub server_encoder_label: String, + pub decoder_label: String, + pub stream_caps_label: String, + pub decoded_caps_label: String, + pub rendered_caps_label: String, +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy, Debug)] +struct PreviewProfile { + source_monitor_id: u32, + display_width: i32, + display_height: i32, + requested_width: i32, + requested_height: i32, + requested_fps: u32, + max_bitrate_kbit: u32, +} + +#[cfg(not(coverage))] +impl PreviewSurface { + fn profile(self) -> PreviewProfile { + match self { + Self::Inline => PreviewProfile { + source_monitor_id: 0, + display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH), + display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT), + requested_width: preview_dimension( + "LESAVKA_PREVIEW_REQUEST_WIDTH", + INLINE_PREVIEW_REQUEST_WIDTH, + ), + requested_height: preview_dimension( + "LESAVKA_PREVIEW_REQUEST_HEIGHT", + INLINE_PREVIEW_REQUEST_HEIGHT, + ), + requested_fps: preview_bitrate( + "LESAVKA_PREVIEW_REQUEST_FPS", + INLINE_PREVIEW_REQUEST_FPS, + ), + max_bitrate_kbit: preview_bitrate( + "LESAVKA_PREVIEW_MAX_KBIT", + INLINE_PREVIEW_MAX_KBIT, + ), + }, + Self::Window => PreviewProfile { + source_monitor_id: 0, + display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), + display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720), + requested_width: preview_dimension( + "LESAVKA_BREAKOUT_REQUEST_WIDTH", + DEFAULT_EYE_SOURCE_WIDTH, + ), + requested_height: preview_dimension( + "LESAVKA_BREAKOUT_REQUEST_HEIGHT", + DEFAULT_EYE_SOURCE_HEIGHT, + ), + requested_fps: preview_bitrate( + "LESAVKA_BREAKOUT_REQUEST_FPS", + DEFAULT_EYE_SOURCE_FPS, + ), + max_bitrate_kbit: preview_bitrate( + "LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", + DEFAULT_EYE_SOURCE_MAX_KBIT, + ), + }, + } + } +} + +#[cfg(not(coverage))] +impl LauncherPreview { + pub fn new(server_addr: String) -> Result { + gst::init().context("initialising preview gstreamer")?; + let server_addr = Arc::new(Mutex::new(server_addr)); + let log_sink = Arc::new(Mutex::new(None)); + let inline_feeds = Arc::new(Mutex::new([ + PreviewFeed::spawn( + Arc::clone(&server_addr), + 0, + PreviewSurface::Inline.profile(), + Arc::clone(&log_sink), + )?, + PreviewFeed::spawn( + Arc::clone(&server_addr), + 1, + PreviewSurface::Inline.profile(), + Arc::clone(&log_sink), + )?, + ])); + let window_feeds = Arc::new(Mutex::new([ + PreviewFeed::spawn( + Arc::clone(&server_addr), + 0, + PreviewSurface::Window.profile(), + Arc::clone(&log_sink), + )?, + PreviewFeed::spawn( + Arc::clone(&server_addr), + 1, + PreviewSurface::Window.profile(), + Arc::clone(&log_sink), + )?, + ])); + Ok(Self { + server_addr: Arc::clone(&server_addr), + log_sink: Arc::clone(&log_sink), + inline_feeds, + window_feeds, + }) + } + + pub fn set_log_sink(&self, tx: std::sync::mpsc::Sender) { + if let Ok(mut slot) = self.log_sink.lock() { + *slot = Some(tx); + } + } + + pub fn set_server_addr(&self, server_addr: String) { + if let Ok(mut slot) = self.server_addr.lock() { + *slot = server_addr; + } + } + + pub fn set_session_active(&self, active: bool) { + if let Ok(feeds) = self.inline_feeds.lock() { + for feed in feeds.iter() { + feed.set_active(active); + } + } + if let Ok(feeds) = self.window_feeds.lock() { + for feed in feeds.iter() { + feed.set_active(active); + } + } + } + + pub fn shutdown_all(&self) { + if let Ok(feeds) = self.inline_feeds.lock() { + for feed in feeds.iter() { + feed.shutdown(); + } + } + if let Ok(feeds) = self.window_feeds.lock() { + for feed in feeds.iter() { + feed.shutdown(); + } + } + } + + pub fn install_on_picture( + &self, + monitor_id: usize, + surface: PreviewSurface, + picture: >k::Picture, + status_label: >k::Label, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.install_on_picture(picture, status_label)), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.install_on_picture(picture, status_label)), + } + } + + pub fn snapshot_metrics( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.snapshot_metrics()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.snapshot_metrics()), + } + } + + pub fn set_capture_profile( + &self, + monitor_id: usize, + source_monitor_id: usize, + requested_width: i32, + requested_height: i32, + requested_fps: u32, + max_bitrate_kbit: u32, + ) { + let ( + inline_requested_width, + inline_requested_height, + inline_requested_fps, + inline_max_bitrate_kbit, + ) = sanitize_preview_request( + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + ); + self.rebuild_feed( + &self.inline_feeds, + monitor_id, + Some(( + source_monitor_id, + inline_requested_width, + inline_requested_height, + inline_requested_fps, + inline_max_bitrate_kbit, + )), + None, + ); + self.rebuild_feed( + &self.window_feeds, + monitor_id, + Some(( + source_monitor_id, + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + )), + None, + ); + } + + pub fn set_breakout_profile(&self, monitor_id: usize, width: i32, height: i32) { + self.rebuild_feed(&self.window_feeds, monitor_id, None, Some((width, height))); + } + + #[cfg(test)] + pub(crate) fn profile_for_test( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> { + let feed = match surface { + PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), + PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), + }?; + let profile = feed.profile(); + Some(( + profile.source_monitor_id, + profile.display_width, + profile.display_height, + profile.requested_width, + profile.requested_height, + profile.requested_fps, + profile.max_bitrate_kbit, + )) + } + + #[cfg(test)] + pub(crate) fn feed_disabled_for_test( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + let feed = match surface { + PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), + PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), + }?; + Some(feed.is_disabled()) + } + + #[cfg(test)] + pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) { + let feed = match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()), + }; + if let Some(feed) = feed { + feed.session_active.store(true, Ordering::Relaxed); + feed.active_bindings.fetch_add(1, Ordering::AcqRel); + } + } + + fn rebuild_feed( + &self, + feeds: &Arc>, + monitor_id: usize, + requested: Option<(usize, i32, i32, u32, u32)>, + display: Option<(i32, i32)>, + ) { + let Ok(mut feeds) = feeds.lock() else { + return; + }; + let Some(existing) = feeds.get(monitor_id).cloned() else { + return; + }; + let was_active = existing.is_active(); + let keep_disabled = existing.is_disabled(); + let mut profile = existing.profile(); + if let Some(( + source_monitor_id, + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + )) = requested + { + profile.source_monitor_id = source_monitor_id as u32; + profile.requested_width = requested_width.max(2); + profile.requested_height = requested_height.max(2); + profile.requested_fps = requested_fps.max(1); + profile.max_bitrate_kbit = max_bitrate_kbit.max(800); + } + if let Some((display_width, display_height)) = display { + profile.display_width = display_width.max(2); + profile.display_height = display_height.max(2); + } + let next_feed = if keep_disabled { + Some(PreviewFeed::spawn_disabled(profile)) + } else { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => Some(feed), + Err(err) => { + warn!(monitor_id, ?err, "could not rebuild preview feed"); + None + } + } + }; + if let Some(feed) = next_feed { + if was_active { + feed.set_active(true); + } + existing.shutdown(); + feeds[monitor_id] = feed; + } + } + + pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) { + self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled); + self.set_feed_enabled(&self.window_feeds, monitor_id, enabled); + } + + fn set_feed_enabled( + &self, + feeds: &Arc>, + monitor_id: usize, + enabled: bool, + ) { + let Ok(mut feeds) = feeds.lock() else { + return; + }; + let Some(existing) = feeds.get(monitor_id).cloned() else { + return; + }; + if existing.is_disabled() != enabled { + return; + } + let was_active = existing.is_active(); + let profile = existing.profile(); + let replacement = if enabled { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => feed, + Err(err) => { + warn!(monitor_id, ?err, "could not enable preview feed"); + return; + } + } + } else { + PreviewFeed::spawn_disabled(profile) + }; + if was_active { + replacement.set_active(true); + } + existing.shutdown(); + feeds[monitor_id] = replacement; + } +} diff --git a/client/src/launcher/preview/status_pipeline.rs b/client/src/launcher/preview/status_pipeline.rs new file mode 100644 index 0000000..4dddb74 --- /dev/null +++ b/client/src/launcher/preview/status_pipeline.rs @@ -0,0 +1,284 @@ +#[cfg(not(coverage))] +fn preview_startup_condition(err: &tonic::Status) -> bool { + let message = err.message().to_ascii_lowercase(); + err.code() == tonic::Code::Internal + && (message.contains("starting video pipeline") + || message.contains("failed to change its state") + || message.contains("resource busy") + || message.contains("device or resource busy") + || message.contains("no signal") + || message.contains("was not ready") + || message.contains("no such file or directory")) +} + +#[cfg(not(coverage))] +fn preview_retry_delay(current: Duration, message: Option<&str>) -> Duration { + let current_ms = current.as_millis() as u64; + let mut next_ms = if current_ms < 1_500 { + 1_500 + } else { + current_ms.saturating_mul(2) + }; + if let Some(message) = message { + let message = message.to_ascii_lowercase(); + if message.contains("too many open files") + || message.contains("failed to change its state") + || message.contains("resource busy") + || message.contains("device or resource busy") + { + next_ms = next_ms.max(6_000); + } + } + Duration::from_millis(next_ms.min(30_000)) +} + +#[cfg(not(coverage))] +fn set_shared_status( + shared: &Arc>, + log_sink: &Arc>>>, + monitor_id: u32, + status: impl Into, + clear: bool, +) { + let status = status.into(); + let should_log = if let Ok(mut slot) = shared.lock() { + let should_log = slot.last_logged_status.as_deref() != Some(status.as_str()); + if should_log { + slot.last_logged_status = Some(status.clone()); + } + slot.set_status(status.clone(), clear); + should_log + } else { + false + }; + if should_log { + log_preview_status(log_sink, monitor_id, &status); + } +} + +#[cfg(not(coverage))] +fn log_preview_issue( + shared: &Arc>, + log_sink: &Arc>>>, + monitor_id: u32, + message: &str, +) { + let should_log = if let Ok(mut slot) = shared.lock() { + if slot.last_logged_error.as_deref() == Some(message) { + false + } else { + slot.last_logged_error = Some(message.to_string()); + true + } + } else { + false + }; + if !should_log { + return; + } + if let Ok(slot) = log_sink.lock() + && let Some(tx) = slot.as_ref() + { + let _ = tx.send(format!( + "[preview:{}] {message}", + preview_eye_label(monitor_id) + )); + } +} + +#[cfg(not(coverage))] +fn log_preview_status( + log_sink: &Arc>>>, + monitor_id: u32, + status: &str, +) { + if status == PREVIEW_IDLE_STATUS { + return; + } + let eye = preview_eye_label(monitor_id); + let message = match status { + "Waking relay preview..." => { + format!( + "🪄 waking {eye} eye preview - «У лукоморья дуб зелёный; златая цепь на дубе том…»" + ) + } + "Connecting relay preview..." => { + format!( + "🛰️ connecting {eye} eye feed - «Там чудеса: там леший бродит, русалка на ветвях сидит…»" + ) + } + "Waiting for stream..." => { + format!( + "👁️ {eye} eye connected; waiting for first frame - «Подымите мне веки: не вижу!»" + ) + } + "Preview stream ended." => { + format!("🌙 {eye} eye stream ended - «Там лес и дол видений полны…»") + } + "Preview host is unavailable." => { + format!( + "💔 {eye} eye cannot reach preview host - «Там царь Кащей над златом чахнет; там русский дух… там Русью пахнет!»" + ) + } + "Preview address is unavailable." => { + format!( + "🧭 {eye} eye has no preview address - «Идёт направо — песнь заводит, налево — сказку говорит…»" + ) + } + "Preview address is invalid." => { + format!( + "🧭 {eye} eye got an invalid preview address - «Там на неведомых дорожках следы невиданных зверей…»" + ) + } + "Waiting for capture pipeline..." => { + format!( + "⏳ {eye} eye waiting for capture pipeline - «Избушка, избушка! Встань к лесу задом, ко мне передом.»" + ) + } + "Preview stream error. See session log." => { + format!("💥 {eye} eye preview stream error - «Фу-фу! Русским духом пахнет!»") + } + "Preview RPC failed. See session log." => { + format!( + "💥 {eye} eye preview RPC failed - «Дела давно минувших дней, преданья старины глубокой…»" + ) + } + other => format!("🎥 {eye} eye: {other}"), + }; + if let Ok(slot) = log_sink.lock() + && let Some(tx) = slot.as_ref() + { + let _ = tx.send(format!( + "[preview:{}] {message}", + preview_eye_label(monitor_id) + )); + } +} + +#[cfg(not(coverage))] +fn preview_eye_label(monitor_id: u32) -> &'static str { + match monitor_id { + 0 => "left", + 1 => "right", + _ => "eye", + } +} + +#[cfg(not(coverage))] +fn looks_like_preview_problem(status: &str) -> bool { + let lower = status.to_ascii_lowercase(); + lower.contains("unavailable") + || lower.contains("invalid") + || lower.contains("failed") + || lower.contains("waiting for capture pipeline") + || lower.contains("error") +} + +#[cfg(not(coverage))] +fn build_preview_pipeline( + profile: PreviewProfile, + decoder_name: &str, +) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> { + let source_mode = eye_source_mode_for_request( + profile.requested_width.max(2) as u32, + profile.requested_height.max(2) as u32, + ); + let (render_width, render_height) = + preview_render_size(profile, source_mode.width, source_mode.height); + let desc = format!( + "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ + queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + h264parse name=preview_parse disable-passthrough=true ! {} name=decoder ! videoconvert ! \ + videoscale add-borders=false ! \ + video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", + decoder_name, + ); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("preview pipeline"); + + let appsrc = pipeline + .by_name("src") + .context("missing preview appsrc")? + .downcast::() + .expect("preview appsrc"); + appsrc.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", "byte-stream") + .field("alignment", "au") + .build(), + )); + appsrc.set_format(gst::Format::Time); + + let appsink = pipeline + .by_name("sink") + .context("missing preview appsink")? + .downcast::() + .expect("preview appsink"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", "RGBA") + .field("width", render_width) + .field("height", render_height) + .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) + .build(), + )); + + Ok((pipeline, appsrc, appsink, decoder_name.to_string())) +} + +#[cfg(not(coverage))] +fn preview_render_size( + profile: PreviewProfile, + source_width: u32, + source_height: u32, +) -> (i32, i32) { + fn round_down_even(value: i32) -> i32 { + let clamped = value.max(2); + clamped - (clamped % 2) + } + + let source_w = source_width.max(2) as f32; + let source_h = source_height.max(2) as f32; + let max_w = profile.display_width.max(2) as f32; + let max_h = profile.display_height.max(2) as f32; + let scale = (max_w / source_w).min(max_h / source_h).clamp(0.01, 1.0); + let render_w = round_down_even((source_w * scale).round() as i32); + let render_h = round_down_even((source_h * scale).round() as i32); + (render_w.max(2), render_h.max(2)) +} + +#[cfg(not(coverage))] +fn preview_decoder_candidates() -> Vec { + let mut candidates = Vec::new(); + let preferred = pick_h264_decoder(); + if !preferred.trim().is_empty() { + candidates.push(preferred); + } + for name in [ + "avdec_h264", + "openh264dec", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + "nvh264dec", + "nvh264sldec", + "decodebin", + ] { + if name == "decodebin" || gst::ElementFactory::find(name).is_some() { + candidates.push(name.to_string()); + } + } + candidates.sort(); + candidates.dedup(); + if let Some(pos) = candidates + .iter() + .position(|name| name == &pick_h264_decoder()) + { + let preferred = candidates.remove(pos); + candidates.insert(0, preferred); + } + candidates +} diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 89a7629..5de822b 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,1684 +1,8 @@ -use serde::{Deserialize, Serialize}; - -use super::devices::{CameraMode, DeviceCatalog}; -use lesavka_common::eye_source::{ - EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, -}; - -pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200; -pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800; -pub const DEFAULT_MIC_GAIN_PERCENT: u32 = 100; -pub const MAX_MIC_GAIN_PERCENT: u32 = 400; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum InputRouting { - Local, - Remote, -} - -impl InputRouting { - pub fn as_env(self) -> &'static str { - match self { - Self::Local => "0", - Self::Remote => "1", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ViewMode { - Unified, - Breakout, -} - -impl ViewMode { - pub fn as_env(self) -> &'static str { - match self { - Self::Unified => "unified", - Self::Breakout => "breakout", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DisplaySurface { - Preview, - Window, -} - -impl DisplaySurface { - pub fn label(self) -> &'static str { - match self { - Self::Preview => "preview", - Self::Window => "window", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum FeedSourcePreset { - ThisEye, - OtherEye, - Off, -} - -impl FeedSourcePreset { - pub fn as_id(self) -> &'static str { - match self { - Self::ThisEye => "self", - Self::OtherEye => "other", - Self::Off => "off", - } - } - - pub fn from_id(raw: &str) -> Option { - match raw { - "self" => Some(Self::ThisEye), - "other" => Some(Self::OtherEye), - "off" => Some(Self::Off), - _ => None, - } - } - - pub fn label(self, monitor_id: usize) -> &'static str { - match (monitor_id, self) { - (_, Self::Off) => "Off", - (0, Self::ThisEye) => "Left Eye", - (0, Self::OtherEye) => "Right Eye", - (1, Self::ThisEye) => "Right Eye", - (1, Self::OtherEye) => "Left Eye", - _ => "This Eye", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BreakoutSizePreset { - P360, - P540, - P720, - P900, - P1080, - P1440, - Source, - FillDisplay, -} - -impl BreakoutSizePreset { - pub fn as_id(self) -> &'static str { - match self { - Self::P360 => "360p", - Self::P540 => "540p", - Self::P720 => "720p", - Self::P900 => "900p", - Self::P1080 => "1080p", - Self::P1440 => "1440p", - Self::Source => "source", - Self::FillDisplay => "fill", - } - } - - pub fn from_id(raw: &str) -> Option { - match raw { - "360p" => Some(Self::P360), - "540p" => Some(Self::P540), - "720p" => Some(Self::P720), - "900p" => Some(Self::P900), - "1080p" => Some(Self::P1080), - "1440p" => Some(Self::P1440), - "source" => Some(Self::Source), - "fill" => Some(Self::FillDisplay), - _ => None, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::P360 => "360p", - Self::P540 => "540p", - Self::P720 => "720p", - Self::P900 => "900p", - Self::P1080 => "1080p", - Self::P1440 => "1440p", - Self::Source => "Source", - Self::FillDisplay => "Display", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CaptureSizePreset { - #[serde(alias = "P360")] - Vga, - #[serde(alias = "P540")] - P480, - P576, - P720, - #[serde(alias = "P900", alias = "P1440", alias = "Source")] - P1080, -} - -impl CaptureSizePreset { - pub fn as_id(self) -> &'static str { - match self { - Self::Vga => "vga", - Self::P480 => "480p", - Self::P576 => "576p", - Self::P720 => "720p", - Self::P1080 => "1080p", - } - } - - pub fn from_id(raw: &str) -> Option { - match raw { - "vga" | "360p" => Some(Self::Vga), - "480p" | "540p" => Some(Self::P480), - "576p" => Some(Self::P576), - "720p" => Some(Self::P720), - "900p" | "1080p" | "1440p" | "source" => Some(Self::P1080), - _ => None, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Vga => "VGA", - Self::P480 => "480p", - Self::P576 => "576p", - Self::P720 => "720p", - Self::P1080 => "1080p", - } - } - - pub fn transport_label(self) -> &'static str { - "device H.264 pass-through" - } - - pub fn source_mode(self) -> EyeSourceMode { - match normalize_capture_size_preset(self) { - Self::P720 => native_eye_source_modes()[1], - Self::P1080 => native_eye_source_modes()[0], - Self::Vga | Self::P480 | Self::P576 => native_eye_source_modes()[1], - } - } - - pub fn from_source_mode(mode: EyeSourceMode) -> Self { - match (mode.width, mode.height, mode.fps) { - (1280, 720, 60) => Self::P720, - _ => Self::P1080, - } - } - - pub fn display_size(self) -> (u32, u32) { - display_size_for_source_mode(self.source_mode()) - } - - pub fn display_aspect_ratio(self) -> f32 { - let (width, height) = self.display_size(); - width.max(1) as f32 / height.max(1) as f32 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct PreviewSourceSize { - pub width: u32, - pub height: u32, - pub fps: u32, -} - -impl Default for PreviewSourceSize { - fn default() -> Self { - let mode = default_eye_source_mode(); - Self { - width: mode.width, - height: mode.height, - fps: mode.fps, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BreakoutSizeChoice { - pub preset: BreakoutSizePreset, - pub width: i32, - pub height: i32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CaptureSizeChoice { - pub preset: CaptureSizePreset, - pub width: i32, - pub height: i32, - pub fps: u32, - pub max_bitrate_kbit: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FeedSourceChoice { - pub preset: FeedSourcePreset, - pub label: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CaptureFpsChoice { - pub fps: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CaptureBitrateChoice { - pub max_bitrate_kbit: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapturePowerStatus { - pub available: bool, - pub enabled: bool, - pub unit: String, - pub detail: String, - pub active_leases: u32, - pub mode: String, - pub detected_devices: u32, -} - -impl Default for CapturePowerStatus { - fn default() -> Self { - Self { - available: false, - enabled: false, - unit: "relay.service".to_string(), - detail: "unknown".to_string(), - active_leases: 0, - mode: "auto".to_string(), - detected_devices: 0, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct DeviceSelection { - pub camera: Option, - pub microphone: Option, - pub speaker: Option, - pub keyboard: Option, - pub mouse: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChannelSelection { - pub camera: bool, - pub microphone: bool, - pub audio: bool, -} - -impl Default for ChannelSelection { - fn default() -> Self { - Self { - camera: false, - microphone: false, - audio: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LauncherState { - pub server_available: bool, - pub server_version: Option, - pub routing: InputRouting, - pub view_mode: ViewMode, - pub displays: [DisplaySurface; 2], - pub feed_sources: [FeedSourcePreset; 2], - pub preview_source: PreviewSourceSize, - pub breakout_limit: PreviewSourceSize, - pub breakout_display: PreviewSourceSize, - pub capture_sizes: [CaptureSizePreset; 2], - pub capture_fps: [u32; 2], - pub capture_bitrates_kbit: [u32; 2], - pub breakout_sizes: [BreakoutSizePreset; 2], - pub devices: DeviceSelection, - pub camera_quality: Option, - pub channels: ChannelSelection, - pub audio_gain_percent: u32, - pub mic_gain_percent: u32, - pub swap_key: String, - pub swap_key_binding: bool, - pub swap_key_binding_token: u64, - pub capture_power: CapturePowerStatus, - pub remote_active: bool, - pub notes: Vec, -} - -impl Default for LauncherState { - fn default() -> Self { - Self { - server_available: false, - server_version: None, - routing: InputRouting::Remote, - view_mode: ViewMode::Unified, - displays: [DisplaySurface::Preview, DisplaySurface::Preview], - feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye], - preview_source: PreviewSourceSize::default(), - breakout_limit: PreviewSourceSize::default(), - breakout_display: PreviewSourceSize::default(), - capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080], - capture_fps: [60, 60], - capture_bitrates_kbit: [18_000, 18_000], - breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], - devices: DeviceSelection::default(), - camera_quality: None, - channels: ChannelSelection::default(), - audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, - mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, - swap_key: "pause".to_string(), - swap_key_binding: false, - swap_key_binding_token: 0, - capture_power: CapturePowerStatus::default(), - remote_active: false, - notes: Vec::new(), - } - } -} - -impl LauncherState { - pub fn new() -> Self { - Self::default() - } - - pub fn set_routing(&mut self, routing: InputRouting) { - self.routing = routing; - } - - pub fn set_server_available(&mut self, available: bool) { - self.server_available = available; - } - - pub fn set_server_version(&mut self, version: Option) { - self.server_version = version.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); - } - - pub fn set_view_mode(&mut self, view_mode: ViewMode) { - self.view_mode = view_mode; - self.displays = match view_mode { - ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview], - ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window], - }; - } - - pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface { - self.displays - .get(monitor_id) - .copied() - .unwrap_or(DisplaySurface::Preview) - } - - pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset { - self.feed_sources - .get(monitor_id) - .copied() - .unwrap_or(FeedSourcePreset::ThisEye) - } - - pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) { - if let Some(slot) = self.feed_sources.get_mut(monitor_id) { - *slot = preset; - } - } - - pub fn feed_source_options(&self, monitor_id: usize) -> Vec { - vec![ - FeedSourceChoice { - preset: FeedSourcePreset::ThisEye, - label: FeedSourcePreset::ThisEye.label(monitor_id), - }, - FeedSourceChoice { - preset: FeedSourcePreset::OtherEye, - label: FeedSourcePreset::OtherEye.label(monitor_id), - }, - FeedSourceChoice { - preset: FeedSourcePreset::Off, - label: FeedSourcePreset::Off.label(monitor_id), - }, - ] - } - - pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option { - match self.feed_source_preset(monitor_id) { - FeedSourcePreset::ThisEye => Some(monitor_id.min(1)), - FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))), - FeedSourcePreset::Off => None, - } - } - - pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { - if let Some(slot) = self.displays.get_mut(monitor_id) { - *slot = surface; - self.view_mode = if self - .displays - .iter() - .any(|display| matches!(display, DisplaySurface::Window)) - { - ViewMode::Breakout - } else { - ViewMode::Unified - }; - } - } - - pub fn breakout_count(&self) -> usize { - self.displays - .iter() - .filter(|surface| matches!(surface, DisplaySurface::Window)) - .count() - } - - pub fn preview_source_size(&self) -> PreviewSourceSize { - self.preview_source - } - - pub fn set_preview_source_profile(&mut self, width: u32, height: u32, fps: u32) { - if width == 0 || height == 0 { - return; - } - self.preview_source = PreviewSourceSize { - width, - height, - fps: fps.max(1), - }; - } - - pub fn breakout_limit_size(&self) -> PreviewSourceSize { - self.breakout_limit - } - - pub fn set_breakout_limit_size(&mut self, width: u32, height: u32) { - if width == 0 || height == 0 { - return; - } - self.breakout_limit = PreviewSourceSize { - width, - height, - fps: self.breakout_limit.fps.max(1), - }; - } - - pub fn breakout_display_size(&self) -> PreviewSourceSize { - self.breakout_display - } - - pub fn set_breakout_display_size(&mut self, width: u32, height: u32) { - if width == 0 || height == 0 { - return; - } - self.breakout_display = PreviewSourceSize { - width, - height, - fps: self.breakout_display.fps.max(1), - }; - } - - pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset { - normalize_capture_size_preset( - self.capture_sizes - .get(monitor_id) - .copied() - .unwrap_or(CaptureSizePreset::P1080), - ) - } - - pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option { - self.resolved_feed_monitor_id(monitor_id) - .map(|source_id| self.capture_size_preset(source_id)) - } - - pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) { - let preset = normalize_capture_size_preset(preset); - if let Some(slot) = self.capture_sizes.get_mut(monitor_id) { - *slot = preset; - } - let defaults = default_profile_for_preset(self.preview_source, preset); - self.set_capture_fps(monitor_id, defaults.fps); - self.set_capture_bitrate_kbit(monitor_id, defaults.max_bitrate_kbit); - } - - pub fn capture_fps(&self, monitor_id: usize) -> u32 { - self.capture_fps - .get(monitor_id) - .copied() - .unwrap_or(default_eye_source_mode().fps) - .max(1) - } - - pub fn display_capture_fps(&self, monitor_id: usize) -> Option { - self.resolved_feed_monitor_id(monitor_id) - .map(|source_id| self.capture_fps(source_id)) - } - - pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) { - if let Some(slot) = self.capture_fps.get_mut(monitor_id) { - *slot = fps.max(1); - } - } - - pub fn capture_bitrate_kbit(&self, monitor_id: usize) -> u32 { - self.capture_bitrates_kbit - .get(monitor_id) - .copied() - .unwrap_or(estimate_source_bitrate_kbit( - default_eye_source_mode().width as i32, - default_eye_source_mode().height as i32, - default_eye_source_mode().fps, - )) - .max(800) - } - - pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option { - self.resolved_feed_monitor_id(monitor_id) - .map(|source_id| self.capture_bitrate_kbit(source_id)) - } - - pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) { - if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) { - *slot = max_bitrate_kbit.max(800); - } - } - - pub fn capture_size_choice(&self, monitor_id: usize) -> CaptureSizeChoice { - capture_size_choice( - self.preview_source, - self.capture_size_preset(monitor_id), - self.capture_fps(monitor_id), - self.capture_bitrate_kbit(monitor_id), - ) - } - - pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option { - self.resolved_feed_monitor_id(monitor_id) - .map(|source_id| self.capture_size_choice(source_id)) - } - - pub fn effective_preview_source_size(&self, monitor_id: usize) -> PreviewSourceSize { - let capture = self - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| self.capture_size_choice(monitor_id)); - PreviewSourceSize { - width: capture.width.max(1) as u32, - height: capture.height.max(1) as u32, - fps: capture.fps.max(1), - } - } - - pub fn capture_size_options(&self) -> Vec { - capture_size_options(self.preview_source) - } - - pub fn capture_fps_options(&self) -> Vec { - capture_fps_options(self.preview_source) - } - - pub fn capture_bitrate_options(&self) -> Vec { - capture_bitrate_options(self.preview_source) - } - - pub fn breakout_size_preset(&self, monitor_id: usize) -> BreakoutSizePreset { - self.breakout_sizes - .get(monitor_id) - .copied() - .unwrap_or(BreakoutSizePreset::Source) - } - - pub fn set_breakout_size_preset(&mut self, monitor_id: usize, preset: BreakoutSizePreset) { - if let Some(slot) = self.breakout_sizes.get_mut(monitor_id) { - *slot = preset; - } - } - - pub fn breakout_size_choice(&self, monitor_id: usize) -> BreakoutSizeChoice { - breakout_size_choice( - self.breakout_limit, - self.breakout_display, - self.effective_preview_source_size(monitor_id), - self.breakout_size_preset(monitor_id), - ) - } - - pub fn breakout_size_options(&self, monitor_id: usize) -> Vec { - breakout_size_options( - self.breakout_limit, - self.breakout_display, - self.effective_preview_source_size(monitor_id), - ) - } - - pub fn select_camera(&mut self, camera: Option) { - self.devices.camera = normalize_selection(camera); - } - - pub fn select_camera_quality(&mut self, mode: Option) { - self.camera_quality = mode; - } - - pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec { - self.devices - .camera - .as_ref() - .and_then(|camera| catalog.camera_modes.get(camera)) - .cloned() - .unwrap_or_default() - } - - pub fn selected_camera_quality(&self, catalog: &DeviceCatalog) -> Option { - let options = self.camera_quality_options(catalog); - self.camera_quality - .filter(|selected| options.contains(selected)) - .or_else(|| options.first().copied()) - } - - pub fn normalize_camera_quality(&mut self, catalog: &DeviceCatalog) { - self.camera_quality = self.selected_camera_quality(catalog); - } - - pub fn select_microphone(&mut self, microphone: Option) { - self.devices.microphone = normalize_selection(microphone); - } - - pub fn select_speaker(&mut self, speaker: Option) { - self.devices.speaker = normalize_selection(speaker); - } - - pub fn set_camera_channel_enabled(&mut self, enabled: bool) { - self.channels.camera = enabled; - } - - pub fn set_microphone_channel_enabled(&mut self, enabled: bool) { - self.channels.microphone = enabled; - } - - pub fn set_audio_channel_enabled(&mut self, enabled: bool) { - self.channels.audio = enabled; - } - - pub fn set_audio_gain_percent(&mut self, percent: u32) { - self.audio_gain_percent = normalize_audio_gain_percent(percent); - } - - pub fn audio_gain_multiplier(&self) -> f64 { - self.audio_gain_percent as f64 / 100.0 - } - - pub fn audio_gain_env_value(&self) -> String { - format!("{:.3}", self.audio_gain_multiplier()) - } - - pub fn audio_gain_label(&self) -> String { - format_audio_gain_percent(self.audio_gain_percent) - } - - pub fn set_mic_gain_percent(&mut self, percent: u32) { - self.mic_gain_percent = normalize_mic_gain_percent(percent); - } - - pub fn mic_gain_multiplier(&self) -> f64 { - self.mic_gain_percent as f64 / 100.0 - } - - pub fn mic_gain_env_value(&self) -> String { - format!("{:.3}", self.mic_gain_multiplier()) - } - - pub fn mic_gain_label(&self) -> String { - format_mic_gain_percent(self.mic_gain_percent) - } - - pub fn select_keyboard(&mut self, keyboard: Option) { - self.devices.keyboard = normalize_selection(keyboard); - } - - pub fn select_mouse(&mut self, mouse: Option) { - self.devices.mouse = normalize_selection(mouse); - } - - pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { - keep_or_select_first(&mut self.devices.camera, &catalog.cameras); - self.normalize_camera_quality(catalog); - keep_or_select_first(&mut self.devices.microphone, &catalog.microphones); - keep_or_select_first(&mut self.devices.speaker, &catalog.speakers); - } - - pub fn set_swap_key(&mut self, swap_key: impl Into) { - self.swap_key = normalize_swap_key(swap_key.into()); - } - - pub fn begin_swap_key_binding(&mut self) -> u64 { - self.swap_key_binding = true; - self.swap_key_binding_token = self.swap_key_binding_token.wrapping_add(1); - self.swap_key_binding_token - } - - pub fn finish_swap_key_binding(&mut self) { - self.swap_key_binding = false; - } - - pub fn cancel_swap_key_binding(&mut self, token: u64) -> bool { - if self.swap_key_binding && self.swap_key_binding_token == token { - self.swap_key_binding = false; - true - } else { - false - } - } - - pub fn complete_swap_key_binding(&mut self, swap_key: impl Into) { - self.set_swap_key(swap_key); - self.finish_swap_key_binding(); - } - - pub fn start_remote(&mut self) -> bool { - if self.remote_active { - return false; - } - self.remote_active = true; - true - } - - pub fn stop_remote(&mut self) -> bool { - if !self.remote_active { - return false; - } - self.remote_active = false; - true - } - - pub fn push_note(&mut self, note: impl Into) { - self.notes.push(note.into()); - } - - pub fn set_capture_power(&mut self, power: CapturePowerStatus) { - self.capture_power = power; - } - - pub fn status_line(&self) -> String { - format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", - self.server_available, - match self.routing { - InputRouting::Local => "local", - InputRouting::Remote => "remote", - }, - match self.view_mode { - ViewMode::Unified => "unified", - ViewMode::Breakout => "breakout", - }, - self.remote_active, - if self.capture_power.enabled { - "on" - } else { - "off" - }, - self.preview_source.width, - self.preview_source.height, - self.displays[0].label(), - self.displays[1].label(), - self.feed_source_preset(0).as_id(), - self.feed_source_preset(1).as_id(), - media_status_label(self.channels.camera, self.devices.camera.as_deref()), - self.camera_quality - .map(CameraMode::short_label) - .unwrap_or_else(|| "default".to_string()), - media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), - media_status_label(self.channels.audio, self.devices.speaker.as_deref()), - self.channels.camera, - self.channels.microphone, - self.channels.audio, - self.audio_gain_label(), - self.mic_gain_label(), - self.devices.keyboard.as_deref().unwrap_or("all"), - self.devices.mouse.as_deref().unwrap_or("all"), - self.swap_key, - ) - } -} - -pub fn normalize_audio_gain_percent(percent: u32) -> u32 { - percent.min(MAX_AUDIO_GAIN_PERCENT) -} - -pub fn format_audio_gain_percent(percent: u32) -> String { - format!("{}%", normalize_audio_gain_percent(percent)) -} - -pub fn normalize_mic_gain_percent(percent: u32) -> u32 { - percent.min(MAX_MIC_GAIN_PERCENT) -} - -pub fn format_mic_gain_percent(percent: u32) -> String { - format!("{}%", normalize_mic_gain_percent(percent)) -} - -fn breakout_size_choice( - physical_limit: PreviewSourceSize, - display_fill: PreviewSourceSize, - source: PreviewSourceSize, - preset: BreakoutSizePreset, -) -> BreakoutSizeChoice { - let physical_width = physical_limit.width.max(1) as i32; - let physical_height = physical_limit.height.max(1) as i32; - let display_width = display_fill.width.max(1) as i32; - let display_height = display_fill.height.max(1) as i32; - let (width, height) = match preset { - BreakoutSizePreset::P360 => { - fit_standard_dimensions(physical_width, physical_height, 640, 360) - } - BreakoutSizePreset::P540 => { - fit_standard_dimensions(physical_width, physical_height, 960, 540) - } - BreakoutSizePreset::P720 => { - fit_standard_dimensions(physical_width, physical_height, 1280, 720) - } - BreakoutSizePreset::P900 => { - fit_standard_dimensions(physical_width, physical_height, 1600, 900) - } - BreakoutSizePreset::P1080 => { - fit_standard_dimensions(physical_width, physical_height, 1920, 1080) - } - BreakoutSizePreset::P1440 => { - fit_standard_dimensions(physical_width, physical_height, 2560, 1440) - } - BreakoutSizePreset::Source => fit_standard_dimensions( - physical_width, - physical_height, - source.width.max(1) as i32, - source.height.max(1) as i32, - ), - BreakoutSizePreset::FillDisplay => (display_width, display_height), - }; - BreakoutSizeChoice { - preset, - width, - height, - } -} - -fn breakout_size_options( - physical_limit: PreviewSourceSize, - display_fill: PreviewSourceSize, - source: PreviewSourceSize, -) -> Vec { - let mut options = Vec::new(); - for preset in [ - BreakoutSizePreset::Source, - BreakoutSizePreset::P360, - BreakoutSizePreset::P540, - BreakoutSizePreset::P720, - BreakoutSizePreset::P900, - BreakoutSizePreset::P1080, - BreakoutSizePreset::P1440, - BreakoutSizePreset::FillDisplay, - ] { - let choice = breakout_size_choice(physical_limit, display_fill, source, preset); - let allow_duplicate_label = matches!( - preset, - BreakoutSizePreset::Source | BreakoutSizePreset::FillDisplay - ); - if !allow_duplicate_label - && options.iter().any(|existing: &BreakoutSizeChoice| { - existing.width == choice.width && existing.height == choice.height - }) - { - continue; - } - options.push(choice); - } - options -} - -fn capture_size_choice( - _source: PreviewSourceSize, - preset: CaptureSizePreset, - selected_fps: u32, - selected_bitrate_kbit: u32, -) -> CaptureSizeChoice { - let preset = normalize_capture_size_preset(preset); - let mode = preset.source_mode(); - let _ = (selected_fps, selected_bitrate_kbit); - let (width, height, fps, max_bitrate_kbit) = ( - mode.width as i32, - mode.height as i32, - mode.fps, - estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), - ); - CaptureSizeChoice { - preset, - width, - height, - fps, - max_bitrate_kbit, - } -} - -fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { - let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64; - match pixels_per_second { - p if p >= 1920_u64 * 1080 * 50 => 18_000, - p if p >= 1920_u64 * 1080 * 24 => 12_000, - p if p >= 1280_u64 * 720 * 24 => 6_000, - _ => 2_500, - } -} - -fn capture_size_options(source: PreviewSourceSize) -> Vec { - native_eye_source_modes() - .iter() - .copied() - .filter(|mode| mode.width <= source.width && mode.height <= source.height) - .map(CaptureSizePreset::from_source_mode) - .map(|preset| { - let defaults = default_profile_for_preset(source, preset); - capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit) - }) - .collect() -} - -fn capture_fps_options(source: PreviewSourceSize) -> Vec { - vec![CaptureFpsChoice { - fps: source.fps.max(1), - }] -} - -fn capture_bitrate_options(source: PreviewSourceSize) -> Vec { - vec![CaptureBitrateChoice { - max_bitrate_kbit: estimate_source_bitrate_kbit( - source.width as i32, - source.height as i32, - source.fps, - ), - }] -} - -fn default_profile_for_preset( - _source: PreviewSourceSize, - preset: CaptureSizePreset, -) -> CaptureSizeChoice { - let preset = normalize_capture_size_preset(preset); - let mode = preset.source_mode(); - let (width, height, fps, max_bitrate_kbit) = ( - mode.width as i32, - mode.height as i32, - mode.fps, - estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), - ); - CaptureSizeChoice { - preset, - width, - height, - fps, - max_bitrate_kbit, - } -} - -fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset { - match preset { - CaptureSizePreset::Vga | CaptureSizePreset::P480 | CaptureSizePreset::P576 => { - CaptureSizePreset::P720 - } - other => other, - } -} - -fn fit_standard_dimensions( - limit_width: i32, - limit_height: i32, - wanted_width: i32, - wanted_height: i32, -) -> (i32, i32) { - let width = wanted_width.min(limit_width).max(2); - let height = wanted_height.min(limit_height).max(2); - if width == limit_width && height == limit_height { - return (width, height); - } - let width_from_height = round_down_even((height * 16) / 9); - if width_from_height <= limit_width { - (round_down_even(width_from_height), round_down_even(height)) - } else { - let height_from_width = round_down_even((width * 9) / 16); - (round_down_even(width), round_down_even(height_from_width)) - } -} - -fn round_down_even(value: i32) -> i32 { - let rounded = value.max(2); - rounded - (rounded % 2) -} - -fn normalize_selection(value: Option) -> Option { - value.and_then(|v| { - let trimmed = v.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn keep_or_select_first(selection: &mut Option, values: &[String]) { - if selection.is_none() { - *selection = values.first().cloned(); - } -} - -fn media_status_label(enabled: bool, selected: Option<&str>) -> &str { - if !enabled { - "off" - } else { - selected.unwrap_or("none") - } -} - -fn normalize_swap_key(value: String) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - "off".to_string() - } else { - trimmed.to_ascii_lowercase() - } -} +// Launcher state model, selection normalization, and media profile choices. +include!("state/selection_models.rs"); +include!("state/launcher_state_impl.rs"); +include!("state/profile_helpers.rs"); #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn routing_and_view_env_values_are_stable() { - assert_eq!(InputRouting::Local.as_env(), "0"); - assert_eq!(InputRouting::Remote.as_env(), "1"); - assert_eq!(ViewMode::Unified.as_env(), "unified"); - assert_eq!(ViewMode::Breakout.as_env(), "breakout"); - assert_eq!(DisplaySurface::Preview.label(), "preview"); - assert_eq!(DisplaySurface::Window.label(), "window"); - } - - #[test] - fn preset_ids_labels_and_legacy_aliases_are_stable() { - for (preset, id, label) in [ - (FeedSourcePreset::ThisEye, "self", "Left Eye"), - (FeedSourcePreset::OtherEye, "other", "Right Eye"), - (FeedSourcePreset::Off, "off", "Off"), - ] { - assert_eq!(preset.as_id(), id); - assert_eq!(FeedSourcePreset::from_id(id), Some(preset)); - assert_eq!(preset.label(0), label); - } - assert_eq!(FeedSourcePreset::ThisEye.label(1), "Right Eye"); - assert_eq!(FeedSourcePreset::OtherEye.label(1), "Left Eye"); - assert_eq!(FeedSourcePreset::ThisEye.label(9), "This Eye"); - assert_eq!(FeedSourcePreset::from_id("bogus"), None); - - for (preset, id, label) in [ - (BreakoutSizePreset::P360, "360p", "360p"), - (BreakoutSizePreset::P540, "540p", "540p"), - (BreakoutSizePreset::P720, "720p", "720p"), - (BreakoutSizePreset::P900, "900p", "900p"), - (BreakoutSizePreset::P1080, "1080p", "1080p"), - (BreakoutSizePreset::P1440, "1440p", "1440p"), - (BreakoutSizePreset::Source, "source", "Source"), - (BreakoutSizePreset::FillDisplay, "fill", "Display"), - ] { - assert_eq!(preset.as_id(), id); - assert_eq!(BreakoutSizePreset::from_id(id), Some(preset)); - assert_eq!(preset.label(), label); - } - assert_eq!(BreakoutSizePreset::from_id("giant"), None); - - for (preset, id, label) in [ - (CaptureSizePreset::Vga, "vga", "VGA"), - (CaptureSizePreset::P480, "480p", "480p"), - (CaptureSizePreset::P576, "576p", "576p"), - (CaptureSizePreset::P720, "720p", "720p"), - (CaptureSizePreset::P1080, "1080p", "1080p"), - ] { - assert_eq!(preset.as_id(), id); - assert_eq!(preset.label(), label); - assert_eq!(preset.transport_label(), "device H.264 pass-through"); - } - assert_eq!( - CaptureSizePreset::from_id("360p"), - Some(CaptureSizePreset::Vga) - ); - assert_eq!( - CaptureSizePreset::from_id("540p"), - Some(CaptureSizePreset::P480) - ); - assert_eq!( - CaptureSizePreset::from_id("900p"), - Some(CaptureSizePreset::P1080) - ); - assert_eq!( - CaptureSizePreset::from_id("source"), - Some(CaptureSizePreset::P1080) - ); - assert_eq!(CaptureSizePreset::from_id("unknown"), None); - assert_eq!(CaptureSizePreset::P720.display_size(), (1280, 720)); - assert!(CaptureSizePreset::P1080.display_aspect_ratio() > 1.7); - } - - #[test] - fn defaults_pick_remote_unified_and_inactive_session() { - let state = LauncherState::new(); - assert_eq!(state.routing, InputRouting::Remote); - assert_eq!(state.view_mode, ViewMode::Unified); - assert_eq!(state.display_surface(0), DisplaySurface::Preview); - assert_eq!(state.display_surface(1), DisplaySurface::Preview); - assert_eq!(state.preview_source_size(), PreviewSourceSize::default()); - assert_eq!(state.breakout_limit_size(), PreviewSourceSize::default()); - assert_eq!(state.capture_size_preset(0), CaptureSizePreset::P1080); - assert_eq!(state.breakout_size_preset(0), BreakoutSizePreset::Source); - assert!(!state.server_available); - assert!(!state.remote_active); - assert!(state.devices.camera.is_none()); - assert!(state.devices.microphone.is_none()); - assert!(state.devices.speaker.is_none()); - assert!(state.devices.keyboard.is_none()); - assert!(state.devices.mouse.is_none()); - assert!(!state.channels.camera); - assert!(!state.channels.microphone); - assert!(state.channels.audio); - assert_eq!(state.audio_gain_percent, DEFAULT_AUDIO_GAIN_PERCENT); - assert_eq!(state.audio_gain_env_value(), "2.000"); - assert_eq!(state.audio_gain_label(), "200%"); - assert_eq!(state.mic_gain_percent, DEFAULT_MIC_GAIN_PERCENT); - assert_eq!(state.mic_gain_env_value(), "1.000"); - assert_eq!(state.mic_gain_label(), "100%"); - assert_eq!(state.capture_power.unit, "relay.service"); - assert_eq!(state.capture_power.mode, "auto"); - } - - #[test] - fn display_surface_updates_global_view_summary() { - let mut state = LauncherState::new(); - state.set_display_surface(1, DisplaySurface::Window); - assert_eq!(state.view_mode, ViewMode::Breakout); - assert_eq!(state.breakout_count(), 1); - - state.set_display_surface(1, DisplaySurface::Preview); - assert_eq!(state.view_mode, ViewMode::Unified); - assert_eq!(state.breakout_count(), 0); - - state.set_view_mode(ViewMode::Breakout); - assert_eq!(state.display_surface(0), DisplaySurface::Window); - assert_eq!(state.display_surface(1), DisplaySurface::Window); - - state.set_display_surface(9, DisplaySurface::Window); - assert_eq!(state.display_surface(9), DisplaySurface::Preview); - assert_eq!(state.breakout_count(), 2); - } - - #[test] - fn feed_sources_can_mirror_or_disable_a_pane() { - let mut state = LauncherState::new(); - state.set_capture_size_preset(1, CaptureSizePreset::P1080); - - assert_eq!(state.resolved_feed_monitor_id(0), Some(0)); - assert_eq!(state.resolved_feed_monitor_id(1), Some(1)); - - state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); - assert_eq!(state.resolved_feed_monitor_id(0), Some(1)); - assert_eq!( - state.display_capture_size_choice(0), - Some(state.capture_size_choice(1)) - ); - - state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); - assert_eq!(state.resolved_feed_monitor_id(1), Some(0)); - - state.set_feed_source_preset(0, FeedSourcePreset::Off); - assert_eq!(state.resolved_feed_monitor_id(0), None); - assert!(state.display_capture_size_choice(0).is_none()); - - state.set_feed_source_preset(9, FeedSourcePreset::Off); - assert_eq!(state.feed_source_preset(9), FeedSourcePreset::ThisEye); - let labels: Vec<_> = state - .feed_source_options(1) - .into_iter() - .map(|choice| (choice.preset, choice.label)) - .collect(); - assert_eq!( - labels, - vec![ - (FeedSourcePreset::ThisEye, "Right Eye"), - (FeedSourcePreset::OtherEye, "Left Eye"), - (FeedSourcePreset::Off, "Off"), - ] - ); - } - - #[test] - fn mirrored_panes_use_their_effective_source_size_for_breakout_source_labels() { - let mut state = LauncherState::new(); - state.set_capture_size_preset(1, CaptureSizePreset::P720); - state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); - - let mirrored_source = state.effective_preview_source_size(0); - assert_eq!(mirrored_source.width, 1280); - assert_eq!(mirrored_source.height, 720); - assert_eq!(mirrored_source.fps, 60); - - let mirrored_breakout = state.breakout_size_choice(0); - assert_eq!(mirrored_breakout.preset, BreakoutSizePreset::Source); - assert_eq!(mirrored_breakout.width, 1280); - assert_eq!(mirrored_breakout.height, 720); - - assert_eq!( - state.display_capture_size_preset(0), - Some(CaptureSizePreset::P720) - ); - assert_eq!(state.display_capture_fps(0), Some(60)); - assert_eq!(state.display_capture_bitrate_kbit(0), Some(12_000)); - } - - #[test] - fn zero_and_out_of_range_profile_updates_are_safe_noops() { - let mut state = LauncherState::new(); - let original_source = state.preview_source_size(); - state.set_preview_source_profile(0, 1080, 0); - assert_eq!(state.preview_source_size(), original_source); - - let original_limit = state.breakout_limit_size(); - state.set_breakout_limit_size(1920, 0); - assert_eq!(state.breakout_limit_size(), original_limit); - - let original_display = state.breakout_display_size(); - state.set_breakout_display_size(0, 1080); - assert_eq!(state.breakout_display_size(), original_display); - - state.set_breakout_display_size(3440, 1440); - assert_eq!(state.breakout_display_size().width, 3440); - assert_eq!(state.breakout_display_size().height, 1440); - - state.set_capture_size_preset(9, CaptureSizePreset::P720); - assert_eq!(state.capture_size_preset(9), CaptureSizePreset::P1080); - state.set_capture_fps(9, 0); - assert_eq!(state.capture_fps(9), default_eye_source_mode().fps); - state.set_capture_bitrate_kbit(9, 1); - assert!(state.capture_bitrate_kbit(9) >= 18_000); - state.set_breakout_size_preset(9, BreakoutSizePreset::P360); - assert_eq!(state.breakout_size_preset(9), BreakoutSizePreset::Source); - } - - #[test] - fn selecting_auto_or_blank_clears_explicit_device() { - let mut state = LauncherState::new(); - state.select_camera(Some("/dev/video0".to_string())); - assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0")); - - state.select_camera(Some("auto".to_string())); - assert!(state.devices.camera.is_none()); - - state.select_microphone(Some(" ".to_string())); - assert!(state.devices.microphone.is_none()); - } - - #[test] - fn catalog_defaults_stage_real_media_devices_without_enabling_channels() { - let mut state = LauncherState::new(); - state.select_camera(Some("/dev/video-special".to_string())); - - let catalog = DeviceCatalog { - cameras: vec!["/dev/video0".to_string()], - camera_modes: [( - "/dev/video0".to_string(), - vec![CameraMode::new(1920, 1080, 30)], - )] - .into_iter() - .collect(), - microphones: vec!["alsa_input.usb".to_string()], - speakers: vec!["alsa_output.usb".to_string()], - keyboards: vec!["/dev/input/event10".to_string()], - mice: vec!["/dev/input/event11".to_string()], - }; - - state.apply_catalog_defaults(&catalog); - - assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); - assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb")); - assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb")); - assert!(!state.channels.camera); - assert!(!state.channels.microphone); - assert!(state.channels.audio); - - let mut fresh = LauncherState::new(); - fresh.apply_catalog_defaults(&catalog); - assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0")); - assert_eq!(fresh.camera_quality, Some(CameraMode::new(1920, 1080, 30))); - assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb")); - assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb")); - } - - #[test] - fn camera_quality_tracks_selected_camera_supported_modes() { - let catalog = DeviceCatalog { - cameras: vec!["cam-a".to_string(), "cam-b".to_string()], - camera_modes: [ - ( - "cam-a".to_string(), - vec![ - CameraMode::new(1920, 1080, 30), - CameraMode::new(1280, 720, 30), - ], - ), - ("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]), - ] - .into_iter() - .collect(), - ..DeviceCatalog::default() - }; - - let mut state = LauncherState::new(); - state.apply_catalog_defaults(&catalog); - assert_eq!(state.devices.camera.as_deref(), Some("cam-a")); - assert_eq!( - state.camera_quality_options(&catalog), - vec![ - CameraMode::new(1920, 1080, 30), - CameraMode::new(1280, 720, 30) - ] - ); - - state.select_camera_quality(Some(CameraMode::new(1280, 720, 30))); - assert_eq!( - state.selected_camera_quality(&catalog), - Some(CameraMode::new(1280, 720, 30)) - ); - - state.select_camera(Some("cam-b".to_string())); - state.normalize_camera_quality(&catalog); - assert_eq!(state.camera_quality, Some(CameraMode::new(1280, 720, 30))); - - state.select_camera(None); - state.normalize_camera_quality(&catalog); - assert_eq!(state.camera_quality, None); - } - - #[test] - fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() { - let mut state = LauncherState::new(); - state.set_audio_gain_percent(350); - assert_eq!(state.audio_gain_percent, 350); - assert_eq!(state.audio_gain_label(), "350%"); - assert_eq!(state.audio_gain_env_value(), "3.500"); - - state.set_audio_gain_percent(10_000); - assert_eq!(state.audio_gain_percent, MAX_AUDIO_GAIN_PERCENT); - assert_eq!(state.audio_gain_label(), "800%"); - assert_eq!(state.audio_gain_env_value(), "8.000"); - - state.set_mic_gain_percent(325); - assert_eq!(state.mic_gain_percent, 325); - assert_eq!(state.mic_gain_label(), "325%"); - assert_eq!(state.mic_gain_env_value(), "3.250"); - - state.set_mic_gain_percent(10_000); - assert_eq!(state.mic_gain_percent, MAX_MIC_GAIN_PERCENT); - assert_eq!(state.mic_gain_label(), "400%"); - assert_eq!(state.mic_gain_env_value(), "4.000"); - } - - #[test] - fn start_and_stop_remote_only_report_changes_once() { - let mut state = LauncherState::new(); - assert!(state.start_remote()); - assert!(!state.start_remote()); - assert!(state.remote_active); - - assert!(state.stop_remote()); - assert!(!state.stop_remote()); - assert!(!state.remote_active); - } - - #[test] - fn status_line_mentions_all_user_visible_controls() { - let mut state = LauncherState::new(); - state.set_server_available(true); - state.set_routing(InputRouting::Local); - state.set_view_mode(ViewMode::Unified); - state.select_camera(Some("/dev/video0".to_string())); - state.select_camera_quality(Some(CameraMode::new(1920, 1080, 30))); - state.select_microphone(Some("alsa_input.usb".to_string())); - state.select_speaker(Some("alsa_output.usb".to_string())); - state.set_camera_channel_enabled(true); - state.set_microphone_channel_enabled(true); - state.set_audio_channel_enabled(true); - state.select_keyboard(Some("/dev/input/event-kbd".to_string())); - state.select_mouse(Some("/dev/input/event-mouse".to_string())); - state.set_preview_source_profile(1920, 1080, 30); - state.start_remote(); - - let status = state.status_line(); - assert!(status.contains("mode=local")); - assert!(status.contains("server=true")); - assert!(status.contains("view=unified")); - assert!(status.contains("active=true")); - assert!(status.contains("source=1920x1080")); - assert!(status.contains("d1=preview")); - assert!(status.contains("d2=preview")); - assert!(status.contains("camera=/dev/video0")); - assert!(status.contains("camera_quality=1080p@30")); - assert!(status.contains("mic=alsa_input.usb")); - assert!(status.contains("speaker=alsa_output.usb")); - assert!(status.contains("audio_gain=200%")); - assert!(status.contains("kbd=/dev/input/event-kbd")); - assert!(status.contains("mouse=/dev/input/event-mouse")); - } - - #[test] - fn capture_power_status_updates_snapshot_state() { - let mut state = LauncherState::new(); - state.set_capture_power(CapturePowerStatus { - available: true, - enabled: true, - unit: "relay.service".to_string(), - detail: "active/running".to_string(), - active_leases: 2, - mode: "forced-on".to_string(), - detected_devices: 2, - }); - - assert!(state.capture_power.available); - assert!(state.capture_power.enabled); - assert_eq!(state.capture_power.active_leases, 2); - assert!(state.status_line().contains("power=on")); - } - - #[test] - fn server_availability_tracks_reachability() { - let mut state = LauncherState::new(); - assert!(!state.server_available); - state.set_server_available(true); - assert!(state.server_available); - } - - #[test] - fn breakout_size_choices_track_the_negotiated_source_size() { - let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 60); - state.set_breakout_limit_size(2560, 1440); - - let source = state.capture_size_choice(0); - assert_eq!(source.width, 1920); - assert_eq!(source.height, 1080); - assert_eq!(source.fps, 60); - assert_eq!(source.max_bitrate_kbit, 18_000); - - state.set_capture_size_preset(0, CaptureSizePreset::P480); - let compact_capture = state.capture_size_choice(0); - assert_eq!(compact_capture.preset, CaptureSizePreset::P720); - assert_eq!(compact_capture.width, 1280); - assert_eq!(compact_capture.height, 720); - assert_eq!(compact_capture.fps, 60); - assert_eq!(compact_capture.max_bitrate_kbit, 12_000); - - let effective_source = state.effective_preview_source_size(0); - assert_eq!(effective_source.width, 1280); - assert_eq!(effective_source.height, 720); - assert_eq!(effective_source.fps, 60); - - let display = state.breakout_size_choice(0); - assert_eq!(display.width, 1280); - assert_eq!(display.height, 720); - - state.set_breakout_size_preset(0, BreakoutSizePreset::P360); - let smaller = state.breakout_size_choice(0); - assert_eq!(smaller.width, 640); - assert_eq!(smaller.height, 360); - - state.set_breakout_size_preset(0, BreakoutSizePreset::P540); - let compact = state.breakout_size_choice(0); - assert_eq!(compact.width, 960); - assert_eq!(compact.height, 540); - - let capture_options = state.capture_size_options(); - assert_eq!(capture_options.len(), 2); - assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080); - assert_eq!(capture_options[0].width, 1920); - assert_eq!(capture_options[0].height, 1080); - assert_eq!(capture_options[0].fps, 60); - assert_eq!(capture_options[0].max_bitrate_kbit, 18_000); - - let breakout_options = state.breakout_size_options(0); - assert!(breakout_options.len() >= 5); - assert!(breakout_options.iter().any(|choice| { - choice.preset == BreakoutSizePreset::Source - && choice.width == 1280 - && choice.height == 720 - })); - } - - #[test] - fn swap_key_binding_tracks_selected_key_and_binding_mode() { - let mut state = LauncherState::new(); - assert_eq!(state.swap_key, "pause"); - assert!(!state.swap_key_binding); - - let token = state.begin_swap_key_binding(); - assert!(state.swap_key_binding); - assert_eq!(token, state.swap_key_binding_token); - - state.set_swap_key("F8"); - assert_eq!(state.swap_key, "f8"); - - state.set_swap_key(" "); - assert_eq!(state.swap_key, "off"); - - state.finish_swap_key_binding(); - assert!(!state.swap_key_binding); - } - - #[test] - fn swap_key_binding_timeout_only_cancels_matching_attempt() { - let mut state = LauncherState::new(); - let first = state.begin_swap_key_binding(); - let second = state.begin_swap_key_binding(); - - assert!(!state.cancel_swap_key_binding(first)); - assert!(state.swap_key_binding); - assert!(state.cancel_swap_key_binding(second)); - assert!(!state.swap_key_binding); - } - - #[test] - fn complete_swap_key_binding_updates_value_and_ends_binding() { - let mut state = LauncherState::new(); - state.begin_swap_key_binding(); - - state.complete_swap_key_binding("F12"); - - assert_eq!(state.swap_key, "f12"); - assert!(!state.swap_key_binding); - } - - #[test] - fn push_note_accumulates_operator_context() { - let mut state = LauncherState::new(); - state.push_note("preview warm"); - state.push_note("relay linked"); - - assert_eq!(state.notes, vec!["preview warm", "relay linked"]); - } - - #[test] - fn capture_size_presets_map_to_real_device_modes() { - let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 60); - state.set_capture_size_preset(0, CaptureSizePreset::P1080); - let source = state.capture_size_choice(0); - assert_eq!(source.width, 1920); - assert_eq!(source.height, 1080); - assert_eq!(source.fps, 60); - assert!(source.max_bitrate_kbit >= 18_000); - - state.set_capture_size_preset(0, CaptureSizePreset::P720); - let hd = state.capture_size_choice(0); - assert_eq!(hd.preset, CaptureSizePreset::P720); - assert_eq!(hd.width, 1280); - assert_eq!(hd.height, 720); - assert_eq!(hd.fps, 60); - - state.set_capture_size_preset(0, CaptureSizePreset::P576); - let compact = state.capture_size_choice(0); - assert_eq!(compact.preset, CaptureSizePreset::P720); - assert_eq!(compact.width, 1280); - assert_eq!(compact.height, 720); - assert_eq!(compact.fps, 60); - - state.set_capture_size_preset(0, CaptureSizePreset::Vga); - let small = state.capture_size_choice(0); - assert_eq!(small.preset, CaptureSizePreset::P720); - assert_eq!(small.width, 1280); - assert_eq!(small.height, 720); - assert_eq!(small.fps, 60); - } - - #[test] - fn source_capture_knobs_follow_the_selected_native_mode() { - let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 60); - - state.set_capture_size_preset(1, CaptureSizePreset::P1080); - let defaults = state.capture_size_choice(1); - assert_eq!(defaults.width, 1920); - assert_eq!(defaults.height, 1080); - assert_eq!(defaults.fps, 60); - assert_eq!(defaults.max_bitrate_kbit, 18_000); - - state.set_capture_fps(1, 24); - state.set_capture_bitrate_kbit(1, 8_500); - let tuned = state.capture_size_choice(1); - assert_eq!(tuned.preset, CaptureSizePreset::P1080); - assert_eq!(tuned.width, 1920); - assert_eq!(tuned.height, 1080); - assert_eq!(tuned.fps, 60); - assert_eq!(tuned.max_bitrate_kbit, 18_000); - } - - #[test] - fn source_capture_ignores_manual_fps_and_bitrate_knobs() { - let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 60); - state.set_capture_size_preset(0, CaptureSizePreset::P720); - state.set_capture_fps(0, 60); - state.set_capture_bitrate_kbit(0, 24_000); - - let source = state.capture_size_choice(0); - assert_eq!(source.preset, CaptureSizePreset::P720); - assert_eq!(source.width, 1280); - assert_eq!(source.height, 720); - assert_eq!(source.fps, 60); - assert_eq!(source.max_bitrate_kbit, 12_000); - } -} +#[path = "tests/state.rs"] +mod tests; diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs new file mode 100644 index 0000000..6baa8a2 --- /dev/null +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -0,0 +1,465 @@ +impl LauncherState { + pub fn new() -> Self { + Self::default() + } + + pub fn set_routing(&mut self, routing: InputRouting) { + self.routing = routing; + } + + pub fn set_server_available(&mut self, available: bool) { + self.server_available = available; + } + + pub fn set_server_version(&mut self, version: Option) { + self.server_version = version.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + } + + pub fn set_view_mode(&mut self, view_mode: ViewMode) { + self.view_mode = view_mode; + self.displays = match view_mode { + ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview], + ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window], + }; + } + + pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface { + self.displays + .get(monitor_id) + .copied() + .unwrap_or(DisplaySurface::Preview) + } + + pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset { + self.feed_sources + .get(monitor_id) + .copied() + .unwrap_or(FeedSourcePreset::ThisEye) + } + + pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) { + if let Some(slot) = self.feed_sources.get_mut(monitor_id) { + *slot = preset; + } + } + + pub fn feed_source_options(&self, monitor_id: usize) -> Vec { + vec![ + FeedSourceChoice { + preset: FeedSourcePreset::ThisEye, + label: FeedSourcePreset::ThisEye.label(monitor_id), + }, + FeedSourceChoice { + preset: FeedSourcePreset::OtherEye, + label: FeedSourcePreset::OtherEye.label(monitor_id), + }, + FeedSourceChoice { + preset: FeedSourcePreset::Off, + label: FeedSourcePreset::Off.label(monitor_id), + }, + ] + } + + pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option { + match self.feed_source_preset(monitor_id) { + FeedSourcePreset::ThisEye => Some(monitor_id.min(1)), + FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))), + FeedSourcePreset::Off => None, + } + } + + pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { + if let Some(slot) = self.displays.get_mut(monitor_id) { + *slot = surface; + self.view_mode = if self + .displays + .iter() + .any(|display| matches!(display, DisplaySurface::Window)) + { + ViewMode::Breakout + } else { + ViewMode::Unified + }; + } + } + + pub fn breakout_count(&self) -> usize { + self.displays + .iter() + .filter(|surface| matches!(surface, DisplaySurface::Window)) + .count() + } + + pub fn preview_source_size(&self) -> PreviewSourceSize { + self.preview_source + } + + pub fn set_preview_source_profile(&mut self, width: u32, height: u32, fps: u32) { + if width == 0 || height == 0 { + return; + } + self.preview_source = PreviewSourceSize { + width, + height, + fps: fps.max(1), + }; + } + + pub fn breakout_limit_size(&self) -> PreviewSourceSize { + self.breakout_limit + } + + pub fn set_breakout_limit_size(&mut self, width: u32, height: u32) { + if width == 0 || height == 0 { + return; + } + self.breakout_limit = PreviewSourceSize { + width, + height, + fps: self.breakout_limit.fps.max(1), + }; + } + + pub fn breakout_display_size(&self) -> PreviewSourceSize { + self.breakout_display + } + + pub fn set_breakout_display_size(&mut self, width: u32, height: u32) { + if width == 0 || height == 0 { + return; + } + self.breakout_display = PreviewSourceSize { + width, + height, + fps: self.breakout_display.fps.max(1), + }; + } + + pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset { + normalize_capture_size_preset( + self.capture_sizes + .get(monitor_id) + .copied() + .unwrap_or(CaptureSizePreset::P1080), + ) + } + + pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_size_preset(source_id)) + } + + pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) { + let preset = normalize_capture_size_preset(preset); + if let Some(slot) = self.capture_sizes.get_mut(monitor_id) { + *slot = preset; + } + let defaults = default_profile_for_preset(self.preview_source, preset); + self.set_capture_fps(monitor_id, defaults.fps); + self.set_capture_bitrate_kbit(monitor_id, defaults.max_bitrate_kbit); + } + + pub fn capture_fps(&self, monitor_id: usize) -> u32 { + self.capture_fps + .get(monitor_id) + .copied() + .unwrap_or(default_eye_source_mode().fps) + .max(1) + } + + pub fn display_capture_fps(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_fps(source_id)) + } + + pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) { + if let Some(slot) = self.capture_fps.get_mut(monitor_id) { + *slot = fps.max(1); + } + } + + pub fn capture_bitrate_kbit(&self, monitor_id: usize) -> u32 { + self.capture_bitrates_kbit + .get(monitor_id) + .copied() + .unwrap_or(estimate_source_bitrate_kbit( + default_eye_source_mode().width as i32, + default_eye_source_mode().height as i32, + default_eye_source_mode().fps, + )) + .max(800) + } + + pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_bitrate_kbit(source_id)) + } + + pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) { + if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) { + *slot = max_bitrate_kbit.max(800); + } + } + + pub fn capture_size_choice(&self, monitor_id: usize) -> CaptureSizeChoice { + capture_size_choice( + self.preview_source, + self.capture_size_preset(monitor_id), + self.capture_fps(monitor_id), + self.capture_bitrate_kbit(monitor_id), + ) + } + + pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_size_choice(source_id)) + } + + pub fn effective_preview_source_size(&self, monitor_id: usize) -> PreviewSourceSize { + let capture = self + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| self.capture_size_choice(monitor_id)); + PreviewSourceSize { + width: capture.width.max(1) as u32, + height: capture.height.max(1) as u32, + fps: capture.fps.max(1), + } + } + + pub fn capture_size_options(&self) -> Vec { + capture_size_options(self.preview_source) + } + + pub fn capture_fps_options(&self) -> Vec { + capture_fps_options(self.preview_source) + } + + pub fn capture_bitrate_options(&self) -> Vec { + capture_bitrate_options(self.preview_source) + } + + pub fn breakout_size_preset(&self, monitor_id: usize) -> BreakoutSizePreset { + self.breakout_sizes + .get(monitor_id) + .copied() + .unwrap_or(BreakoutSizePreset::Source) + } + + pub fn set_breakout_size_preset(&mut self, monitor_id: usize, preset: BreakoutSizePreset) { + if let Some(slot) = self.breakout_sizes.get_mut(monitor_id) { + *slot = preset; + } + } + + pub fn breakout_size_choice(&self, monitor_id: usize) -> BreakoutSizeChoice { + breakout_size_choice( + self.breakout_limit, + self.breakout_display, + self.effective_preview_source_size(monitor_id), + self.breakout_size_preset(monitor_id), + ) + } + + pub fn breakout_size_options(&self, monitor_id: usize) -> Vec { + breakout_size_options( + self.breakout_limit, + self.breakout_display, + self.effective_preview_source_size(monitor_id), + ) + } + + pub fn select_camera(&mut self, camera: Option) { + self.devices.camera = normalize_selection(camera); + } + + pub fn select_camera_quality(&mut self, mode: Option) { + self.camera_quality = mode; + } + + pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec { + self.devices + .camera + .as_ref() + .and_then(|camera| catalog.camera_modes.get(camera)) + .cloned() + .unwrap_or_default() + } + + pub fn selected_camera_quality(&self, catalog: &DeviceCatalog) -> Option { + let options = self.camera_quality_options(catalog); + self.camera_quality + .filter(|selected| options.contains(selected)) + .or_else(|| options.first().copied()) + } + + pub fn normalize_camera_quality(&mut self, catalog: &DeviceCatalog) { + self.camera_quality = self.selected_camera_quality(catalog); + } + + pub fn select_microphone(&mut self, microphone: Option) { + self.devices.microphone = normalize_selection(microphone); + } + + pub fn select_speaker(&mut self, speaker: Option) { + self.devices.speaker = normalize_selection(speaker); + } + + pub fn set_camera_channel_enabled(&mut self, enabled: bool) { + self.channels.camera = enabled; + } + + pub fn set_microphone_channel_enabled(&mut self, enabled: bool) { + self.channels.microphone = enabled; + } + + pub fn set_audio_channel_enabled(&mut self, enabled: bool) { + self.channels.audio = enabled; + } + + pub fn set_audio_gain_percent(&mut self, percent: u32) { + self.audio_gain_percent = normalize_audio_gain_percent(percent); + } + + pub fn audio_gain_multiplier(&self) -> f64 { + self.audio_gain_percent as f64 / 100.0 + } + + pub fn audio_gain_env_value(&self) -> String { + format!("{:.3}", self.audio_gain_multiplier()) + } + + pub fn audio_gain_label(&self) -> String { + format_audio_gain_percent(self.audio_gain_percent) + } + + pub fn set_mic_gain_percent(&mut self, percent: u32) { + self.mic_gain_percent = normalize_mic_gain_percent(percent); + } + + pub fn mic_gain_multiplier(&self) -> f64 { + self.mic_gain_percent as f64 / 100.0 + } + + pub fn mic_gain_env_value(&self) -> String { + format!("{:.3}", self.mic_gain_multiplier()) + } + + pub fn mic_gain_label(&self) -> String { + format_mic_gain_percent(self.mic_gain_percent) + } + + pub fn select_keyboard(&mut self, keyboard: Option) { + self.devices.keyboard = normalize_selection(keyboard); + } + + pub fn select_mouse(&mut self, mouse: Option) { + self.devices.mouse = normalize_selection(mouse); + } + + pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { + keep_or_select_first(&mut self.devices.camera, &catalog.cameras); + self.normalize_camera_quality(catalog); + keep_or_select_first(&mut self.devices.microphone, &catalog.microphones); + keep_or_select_first(&mut self.devices.speaker, &catalog.speakers); + } + + pub fn set_swap_key(&mut self, swap_key: impl Into) { + self.swap_key = normalize_swap_key(swap_key.into()); + } + + pub fn begin_swap_key_binding(&mut self) -> u64 { + self.swap_key_binding = true; + self.swap_key_binding_token = self.swap_key_binding_token.wrapping_add(1); + self.swap_key_binding_token + } + + pub fn finish_swap_key_binding(&mut self) { + self.swap_key_binding = false; + } + + pub fn cancel_swap_key_binding(&mut self, token: u64) -> bool { + if self.swap_key_binding && self.swap_key_binding_token == token { + self.swap_key_binding = false; + true + } else { + false + } + } + + pub fn complete_swap_key_binding(&mut self, swap_key: impl Into) { + self.set_swap_key(swap_key); + self.finish_swap_key_binding(); + } + + pub fn start_remote(&mut self) -> bool { + if self.remote_active { + return false; + } + self.remote_active = true; + true + } + + pub fn stop_remote(&mut self) -> bool { + if !self.remote_active { + return false; + } + self.remote_active = false; + true + } + + pub fn push_note(&mut self, note: impl Into) { + self.notes.push(note.into()); + } + + pub fn set_capture_power(&mut self, power: CapturePowerStatus) { + self.capture_power = power; + } + + pub fn status_line(&self) -> String { + format!( + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", + self.server_available, + match self.routing { + InputRouting::Local => "local", + InputRouting::Remote => "remote", + }, + match self.view_mode { + ViewMode::Unified => "unified", + ViewMode::Breakout => "breakout", + }, + self.remote_active, + if self.capture_power.enabled { + "on" + } else { + "off" + }, + self.preview_source.width, + self.preview_source.height, + self.displays[0].label(), + self.displays[1].label(), + self.feed_source_preset(0).as_id(), + self.feed_source_preset(1).as_id(), + media_status_label(self.channels.camera, self.devices.camera.as_deref()), + self.camera_quality + .map(CameraMode::short_label) + .unwrap_or_else(|| "default".to_string()), + media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), + media_status_label(self.channels.audio, self.devices.speaker.as_deref()), + self.channels.camera, + self.channels.microphone, + self.channels.audio, + self.audio_gain_label(), + self.mic_gain_label(), + self.devices.keyboard.as_deref().unwrap_or("all"), + self.devices.mouse.as_deref().unwrap_or("all"), + self.swap_key, + ) + } +} diff --git a/client/src/launcher/state/profile_helpers.rs b/client/src/launcher/state/profile_helpers.rs new file mode 100644 index 0000000..9aeb456 --- /dev/null +++ b/client/src/launcher/state/profile_helpers.rs @@ -0,0 +1,244 @@ +pub fn normalize_audio_gain_percent(percent: u32) -> u32 { + percent.min(MAX_AUDIO_GAIN_PERCENT) +} + +pub fn format_audio_gain_percent(percent: u32) -> String { + format!("{}%", normalize_audio_gain_percent(percent)) +} + +pub fn normalize_mic_gain_percent(percent: u32) -> u32 { + percent.min(MAX_MIC_GAIN_PERCENT) +} + +pub fn format_mic_gain_percent(percent: u32) -> String { + format!("{}%", normalize_mic_gain_percent(percent)) +} + +fn breakout_size_choice( + physical_limit: PreviewSourceSize, + display_fill: PreviewSourceSize, + source: PreviewSourceSize, + preset: BreakoutSizePreset, +) -> BreakoutSizeChoice { + let physical_width = physical_limit.width.max(1) as i32; + let physical_height = physical_limit.height.max(1) as i32; + let display_width = display_fill.width.max(1) as i32; + let display_height = display_fill.height.max(1) as i32; + let (width, height) = match preset { + BreakoutSizePreset::P360 => { + fit_standard_dimensions(physical_width, physical_height, 640, 360) + } + BreakoutSizePreset::P540 => { + fit_standard_dimensions(physical_width, physical_height, 960, 540) + } + BreakoutSizePreset::P720 => { + fit_standard_dimensions(physical_width, physical_height, 1280, 720) + } + BreakoutSizePreset::P900 => { + fit_standard_dimensions(physical_width, physical_height, 1600, 900) + } + BreakoutSizePreset::P1080 => { + fit_standard_dimensions(physical_width, physical_height, 1920, 1080) + } + BreakoutSizePreset::P1440 => { + fit_standard_dimensions(physical_width, physical_height, 2560, 1440) + } + BreakoutSizePreset::Source => fit_standard_dimensions( + physical_width, + physical_height, + source.width.max(1) as i32, + source.height.max(1) as i32, + ), + BreakoutSizePreset::FillDisplay => (display_width, display_height), + }; + BreakoutSizeChoice { + preset, + width, + height, + } +} + +fn breakout_size_options( + physical_limit: PreviewSourceSize, + display_fill: PreviewSourceSize, + source: PreviewSourceSize, +) -> Vec { + let mut options = Vec::new(); + for preset in [ + BreakoutSizePreset::Source, + BreakoutSizePreset::P360, + BreakoutSizePreset::P540, + BreakoutSizePreset::P720, + BreakoutSizePreset::P900, + BreakoutSizePreset::P1080, + BreakoutSizePreset::P1440, + BreakoutSizePreset::FillDisplay, + ] { + let choice = breakout_size_choice(physical_limit, display_fill, source, preset); + let allow_duplicate_label = matches!( + preset, + BreakoutSizePreset::Source | BreakoutSizePreset::FillDisplay + ); + if !allow_duplicate_label + && options.iter().any(|existing: &BreakoutSizeChoice| { + existing.width == choice.width && existing.height == choice.height + }) + { + continue; + } + options.push(choice); + } + options +} + +fn capture_size_choice( + _source: PreviewSourceSize, + preset: CaptureSizePreset, + selected_fps: u32, + selected_bitrate_kbit: u32, +) -> CaptureSizeChoice { + let preset = normalize_capture_size_preset(preset); + let mode = preset.source_mode(); + let _ = (selected_fps, selected_bitrate_kbit); + let (width, height, fps, max_bitrate_kbit) = ( + mode.width as i32, + mode.height as i32, + mode.fps, + estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), + ); + CaptureSizeChoice { + preset, + width, + height, + fps, + max_bitrate_kbit, + } +} + +fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { + let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64; + match pixels_per_second { + p if p >= 1920_u64 * 1080 * 50 => 18_000, + p if p >= 1920_u64 * 1080 * 24 => 12_000, + p if p >= 1280_u64 * 720 * 24 => 6_000, + _ => 2_500, + } +} + +fn capture_size_options(source: PreviewSourceSize) -> Vec { + native_eye_source_modes() + .iter() + .copied() + .filter(|mode| mode.width <= source.width && mode.height <= source.height) + .map(CaptureSizePreset::from_source_mode) + .map(|preset| { + let defaults = default_profile_for_preset(source, preset); + capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit) + }) + .collect() +} + +fn capture_fps_options(source: PreviewSourceSize) -> Vec { + vec![CaptureFpsChoice { + fps: source.fps.max(1), + }] +} + +fn capture_bitrate_options(source: PreviewSourceSize) -> Vec { + vec![CaptureBitrateChoice { + max_bitrate_kbit: estimate_source_bitrate_kbit( + source.width as i32, + source.height as i32, + source.fps, + ), + }] +} + +fn default_profile_for_preset( + _source: PreviewSourceSize, + preset: CaptureSizePreset, +) -> CaptureSizeChoice { + let preset = normalize_capture_size_preset(preset); + let mode = preset.source_mode(); + let (width, height, fps, max_bitrate_kbit) = ( + mode.width as i32, + mode.height as i32, + mode.fps, + estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), + ); + CaptureSizeChoice { + preset, + width, + height, + fps, + max_bitrate_kbit, + } +} + +fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset { + match preset { + CaptureSizePreset::Vga | CaptureSizePreset::P480 | CaptureSizePreset::P576 => { + CaptureSizePreset::P720 + } + other => other, + } +} + +fn fit_standard_dimensions( + limit_width: i32, + limit_height: i32, + wanted_width: i32, + wanted_height: i32, +) -> (i32, i32) { + let width = wanted_width.min(limit_width).max(2); + let height = wanted_height.min(limit_height).max(2); + if width == limit_width && height == limit_height { + return (width, height); + } + let width_from_height = round_down_even((height * 16) / 9); + if width_from_height <= limit_width { + (round_down_even(width_from_height), round_down_even(height)) + } else { + let height_from_width = round_down_even((width * 9) / 16); + (round_down_even(width), round_down_even(height_from_width)) + } +} + +fn round_down_even(value: i32) -> i32 { + let rounded = value.max(2); + rounded - (rounded % 2) +} + +fn normalize_selection(value: Option) -> Option { + value.and_then(|v| { + let trimmed = v.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn keep_or_select_first(selection: &mut Option, values: &[String]) { + if selection.is_none() { + *selection = values.first().cloned(); + } +} + +fn media_status_label(enabled: bool, selected: Option<&str>) -> &str { + if !enabled { + "off" + } else { + selected.unwrap_or("none") + } +} + +fn normalize_swap_key(value: String) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "off".to_string() + } else { + trimmed.to_ascii_lowercase() + } +} diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs new file mode 100644 index 0000000..26cd6bf --- /dev/null +++ b/client/src/launcher/state/selection_models.rs @@ -0,0 +1,380 @@ +use serde::{Deserialize, Serialize}; + +use super::devices::{CameraMode, DeviceCatalog}; +use lesavka_common::eye_source::{ + EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, +}; + +pub const DEFAULT_AUDIO_GAIN_PERCENT: u32 = 200; +pub const MAX_AUDIO_GAIN_PERCENT: u32 = 800; +pub const DEFAULT_MIC_GAIN_PERCENT: u32 = 100; +pub const MAX_MIC_GAIN_PERCENT: u32 = 400; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputRouting { + Local, + Remote, +} + +impl InputRouting { + pub fn as_env(self) -> &'static str { + match self { + Self::Local => "0", + Self::Remote => "1", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViewMode { + Unified, + Breakout, +} + +impl ViewMode { + pub fn as_env(self) -> &'static str { + match self { + Self::Unified => "unified", + Self::Breakout => "breakout", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DisplaySurface { + Preview, + Window, +} + +impl DisplaySurface { + pub fn label(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Window => "window", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FeedSourcePreset { + ThisEye, + OtherEye, + Off, +} + +impl FeedSourcePreset { + pub fn as_id(self) -> &'static str { + match self { + Self::ThisEye => "self", + Self::OtherEye => "other", + Self::Off => "off", + } + } + + pub fn from_id(raw: &str) -> Option { + match raw { + "self" => Some(Self::ThisEye), + "other" => Some(Self::OtherEye), + "off" => Some(Self::Off), + _ => None, + } + } + + pub fn label(self, monitor_id: usize) -> &'static str { + match (monitor_id, self) { + (_, Self::Off) => "Off", + (0, Self::ThisEye) => "Left Eye", + (0, Self::OtherEye) => "Right Eye", + (1, Self::ThisEye) => "Right Eye", + (1, Self::OtherEye) => "Left Eye", + _ => "This Eye", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BreakoutSizePreset { + P360, + P540, + P720, + P900, + P1080, + P1440, + Source, + FillDisplay, +} + +impl BreakoutSizePreset { + pub fn as_id(self) -> &'static str { + match self { + Self::P360 => "360p", + Self::P540 => "540p", + Self::P720 => "720p", + Self::P900 => "900p", + Self::P1080 => "1080p", + Self::P1440 => "1440p", + Self::Source => "source", + Self::FillDisplay => "fill", + } + } + + pub fn from_id(raw: &str) -> Option { + match raw { + "360p" => Some(Self::P360), + "540p" => Some(Self::P540), + "720p" => Some(Self::P720), + "900p" => Some(Self::P900), + "1080p" => Some(Self::P1080), + "1440p" => Some(Self::P1440), + "source" => Some(Self::Source), + "fill" => Some(Self::FillDisplay), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::P360 => "360p", + Self::P540 => "540p", + Self::P720 => "720p", + Self::P900 => "900p", + Self::P1080 => "1080p", + Self::P1440 => "1440p", + Self::Source => "Source", + Self::FillDisplay => "Display", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CaptureSizePreset { + #[serde(alias = "P360")] + Vga, + #[serde(alias = "P540")] + P480, + P576, + P720, + #[serde(alias = "P900", alias = "P1440", alias = "Source")] + P1080, +} + +impl CaptureSizePreset { + pub fn as_id(self) -> &'static str { + match self { + Self::Vga => "vga", + Self::P480 => "480p", + Self::P576 => "576p", + Self::P720 => "720p", + Self::P1080 => "1080p", + } + } + + pub fn from_id(raw: &str) -> Option { + match raw { + "vga" | "360p" => Some(Self::Vga), + "480p" | "540p" => Some(Self::P480), + "576p" => Some(Self::P576), + "720p" => Some(Self::P720), + "900p" | "1080p" | "1440p" | "source" => Some(Self::P1080), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Vga => "VGA", + Self::P480 => "480p", + Self::P576 => "576p", + Self::P720 => "720p", + Self::P1080 => "1080p", + } + } + + pub fn transport_label(self) -> &'static str { + "device H.264 pass-through" + } + + pub fn source_mode(self) -> EyeSourceMode { + match normalize_capture_size_preset(self) { + Self::P720 => native_eye_source_modes()[1], + Self::P1080 => native_eye_source_modes()[0], + Self::Vga | Self::P480 | Self::P576 => native_eye_source_modes()[1], + } + } + + pub fn from_source_mode(mode: EyeSourceMode) -> Self { + match (mode.width, mode.height, mode.fps) { + (1280, 720, 60) => Self::P720, + _ => Self::P1080, + } + } + + pub fn display_size(self) -> (u32, u32) { + display_size_for_source_mode(self.source_mode()) + } + + pub fn display_aspect_ratio(self) -> f32 { + let (width, height) = self.display_size(); + width.max(1) as f32 / height.max(1) as f32 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreviewSourceSize { + pub width: u32, + pub height: u32, + pub fps: u32, +} + +impl Default for PreviewSourceSize { + fn default() -> Self { + let mode = default_eye_source_mode(); + Self { + width: mode.width, + height: mode.height, + fps: mode.fps, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BreakoutSizeChoice { + pub preset: BreakoutSizePreset, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureSizeChoice { + pub preset: CaptureSizePreset, + pub width: i32, + pub height: i32, + pub fps: u32, + pub max_bitrate_kbit: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FeedSourceChoice { + pub preset: FeedSourcePreset, + pub label: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureFpsChoice { + pub fps: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureBitrateChoice { + pub max_bitrate_kbit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturePowerStatus { + pub available: bool, + pub enabled: bool, + pub unit: String, + pub detail: String, + pub active_leases: u32, + pub mode: String, + pub detected_devices: u32, +} + +impl Default for CapturePowerStatus { + fn default() -> Self { + Self { + available: false, + enabled: false, + unit: "relay.service".to_string(), + detail: "unknown".to_string(), + active_leases: 0, + mode: "auto".to_string(), + detected_devices: 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DeviceSelection { + pub camera: Option, + pub microphone: Option, + pub speaker: Option, + pub keyboard: Option, + pub mouse: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChannelSelection { + pub camera: bool, + pub microphone: bool, + pub audio: bool, +} + +impl Default for ChannelSelection { + fn default() -> Self { + Self { + camera: false, + microphone: false, + audio: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LauncherState { + pub server_available: bool, + pub server_version: Option, + pub routing: InputRouting, + pub view_mode: ViewMode, + pub displays: [DisplaySurface; 2], + pub feed_sources: [FeedSourcePreset; 2], + pub preview_source: PreviewSourceSize, + pub breakout_limit: PreviewSourceSize, + pub breakout_display: PreviewSourceSize, + pub capture_sizes: [CaptureSizePreset; 2], + pub capture_fps: [u32; 2], + pub capture_bitrates_kbit: [u32; 2], + pub breakout_sizes: [BreakoutSizePreset; 2], + pub devices: DeviceSelection, + pub camera_quality: Option, + pub channels: ChannelSelection, + pub audio_gain_percent: u32, + pub mic_gain_percent: u32, + pub swap_key: String, + pub swap_key_binding: bool, + pub swap_key_binding_token: u64, + pub capture_power: CapturePowerStatus, + pub remote_active: bool, + pub notes: Vec, +} + +impl Default for LauncherState { + fn default() -> Self { + Self { + server_available: false, + server_version: None, + routing: InputRouting::Remote, + view_mode: ViewMode::Unified, + displays: [DisplaySurface::Preview, DisplaySurface::Preview], + feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye], + preview_source: PreviewSourceSize::default(), + breakout_limit: PreviewSourceSize::default(), + breakout_display: PreviewSourceSize::default(), + capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080], + capture_fps: [60, 60], + capture_bitrates_kbit: [18_000, 18_000], + breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], + devices: DeviceSelection::default(), + camera_quality: None, + channels: ChannelSelection::default(), + audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, + mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, + swap_key: "pause".to_string(), + swap_key_binding: false, + swap_key_binding_token: 0, + capture_power: CapturePowerStatus::default(), + remote_active: false, + notes: Vec::new(), + } + } +} diff --git a/client/src/launcher/tests/device_test.rs b/client/src/launcher/tests/device_test.rs new file mode 100644 index 0000000..872f1eb --- /dev/null +++ b/client/src/launcher/tests/device_test.rs @@ -0,0 +1,116 @@ +use super::{ + MIC_REPLAY_MAX_BYTES, build_wav_bytes, camera_preview_mode, camera_preview_pipeline_desc, + microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio, + read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device, +}; +use crate::launcher::devices::CameraMode; +use std::sync::{Arc, Mutex}; + +#[test] +fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() { + assert_eq!(resolve_camera_device("/dev/video0"), "/dev/video0"); + assert_eq!( + resolve_camera_device("usb-Logitech_C920-video-index0"), + "/dev/v4l/by-id/usb-Logitech_C920-video-index0" + ); +} + +#[test] +fn normalize_camera_selection_drops_auto_and_blank_values() { + assert_eq!(normalize_camera_selection(None), None); + assert_eq!(normalize_camera_selection(Some("")), None); + assert_eq!(normalize_camera_selection(Some("auto")), None); + assert_eq!( + normalize_camera_selection(Some("usb-Logitech_C920-video-index0")), + Some("usb-Logitech_C920-video-index0".to_string()) + ); +} + +#[test] +fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() { + let desc = camera_preview_pipeline_desc("/dev/video0", None); + assert!(desc.contains("v4l2src device=\"/dev/video0\"")); + assert!(desc.contains("videoconvert ! videoscale ! videorate !")); + assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,")); +} + +#[test] +fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() { + let desc = camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30))); + assert!(desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")); + assert!(desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")); + assert!(desc.contains("decodebin ! videoconvert ! videoscale")); + assert!(desc.contains("video/x-raw,format=RGBA,width=1920,height=1080,framerate=30/1")); + assert!(!desc.contains("width=128,height=72")); +} + +#[test] +fn camera_preview_mode_defaults_to_hd_and_tracks_selected_quality() { + assert_eq!(camera_preview_mode(None), (1280, 720, 30)); + assert_eq!( + camera_preview_mode(Some(CameraMode::new(1920, 1080, 30))), + (1920, 1080, 30) + ); +} + +#[test] +fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() { + let desc = microphone_monitor_pipeline_desc( + "alsa_input.usb-Neat_Microphones_Bumblebee_II_USB_Microphone-00.mono-fallback", + None, + ); + assert!(desc.contains("pulsesrc device=\"alsa_input.usb-Neat_Microphones")); + assert!(!desc.contains("pipewiresrc target-object")); +} + +#[test] +fn push_recent_audio_keeps_only_last_three_seconds() { + let buffer = Arc::new(Mutex::new(Vec::new())); + push_recent_audio(&buffer, &vec![1u8; MIC_REPLAY_MAX_BYTES / 2]); + push_recent_audio(&buffer, &vec![2u8; MIC_REPLAY_MAX_BYTES]); + let stored = buffer.lock().expect("buffer").clone(); + assert_eq!(stored.len(), MIC_REPLAY_MAX_BYTES); + assert!(stored.contains(&2)); +} + +#[test] +fn build_wav_bytes_writes_a_valid_riff_header() { + let audio = vec![0u8; 32]; + let wav = build_wav_bytes(&audio, 16_000, 1, 16); + assert!(wav.starts_with(b"RIFF")); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(&wav[36..40], b"data"); + assert_eq!(wav.len(), 44 + audio.len()); +} + +#[test] +fn relay_camera_preview_tap_round_trips_rgba_frame() { + let path = + std::env::temp_dir().join(format!("lesavka-camera-preview-tap-{}", std::process::id())); + std::fs::write( + &path, + [b"LESAVKA_RGBA 2 2 8\n".as_slice(), &[1_u8; 16]].concat(), + ) + .expect("write tap"); + + let frame = read_camera_preview_tap(&path).expect("read tap"); + assert_eq!(frame.width, 2); + assert_eq!(frame.height, 2); + assert_eq!(frame.stride, 8); + assert_eq!(frame.rgba.len(), 16); + let _ = std::fs::remove_file(path); +} + +#[test] +fn relay_microphone_level_tap_clamps_values() { + let path = std::env::temp_dir().join(format!("lesavka-mic-level-tap-{}", std::process::id())); + std::fs::write(&path, "1.25\n").expect("write high"); + assert_eq!(read_microphone_level_tap(&path), Some(1.0)); + + std::fs::write(&path, "-0.5\n").expect("write low"); + assert_eq!(read_microphone_level_tap(&path), Some(0.0)); + + std::fs::write(&path, "not-a-number\n").expect("write invalid"); + assert_eq!(read_microphone_level_tap(&path), None); + let _ = std::fs::remove_file(path); +} diff --git a/client/src/launcher/tests/devices.rs b/client/src/launcher/tests/devices.rs new file mode 100644 index 0000000..600f29c --- /dev/null +++ b/client/src/launcher/tests/devices.rs @@ -0,0 +1,214 @@ +use super::*; +use std::path::PathBuf; + +fn mk_temp_dir(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&path).expect("create temp dir"); + path +} + +#[test] +fn parse_pactl_short_collects_second_column_and_sorts_unique() { + let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n"; + let parsed = parse_pactl_short(input); + assert_eq!( + parsed, + vec![ + "alsa_input.pci".to_string(), + "alsa_input.usb.test".to_string(), + ] + ); +} + +#[test] +fn parse_pactl_short_ignores_blank_or_short_lines() { + let input = "\nweird\n3\n4 sink.a\tmodule\n"; + let parsed = parse_pactl_short(input); + assert_eq!(parsed, vec!["sink.a".to_string()]); +} + +#[test] +fn camera_discovery_reads_entry_names_from_override_dir() { + let tmp = mk_temp_dir("camera-discovery"); + std::fs::write(tmp.join("usb-cam-a"), "").expect("write"); + std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); + + let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); + assert_eq!( + devices, + vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()] + ); + let _ = std::fs::remove_dir_all(tmp); +} + +#[test] +fn camera_discovery_prefers_one_endpoint_per_physical_webcam() { + let devices = dedupe_camera_devices([ + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index1".to_string(), + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), + "usb-Logitech_C920-video-index0".to_string(), + ]); + + assert_eq!( + devices, + vec![ + "usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(), + "usb-Logitech_C920-video-index0".to_string(), + ] + ); +} + +#[test] +fn camera_discovery_returns_empty_when_directory_missing() { + let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); + assert!(devices.is_empty()); +} + +#[test] +fn camera_discovery_default_path_is_stable_without_overrides() { + let _ = discover_camera_devices(None); +} + +#[test] +fn camera_mode_parser_keeps_only_supported_lesavka_qualities() { + let stdout = r#" +ioctl: VIDIOC_ENUM_FMT + Type: Video Capture + + [0]: 'MJPG' (Motion-JPEG, compressed) + Size: Discrete 1920x1080 + Interval: Discrete 0.033s (30.000 fps) + Interval: Discrete 0.067s (15.000 fps) + Size: Discrete 1280x720 + Interval: Discrete 0.017s (60.000 fps) + Size: Discrete 640x480 + Interval: Discrete 0.033s (30.000 fps) + [1]: 'YUYV' (YUYV 4:2:2) + Size: Discrete 1920x1080 + Interval: Discrete 0.200s (5.000 fps) +"#; + + assert_eq!( + parse_supported_camera_modes(stdout), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30) + ] + ); +} + +#[test] +fn camera_mode_ids_are_short_and_round_trippable() { + let mode = CameraMode::new(1920, 1080, 30); + assert_eq!(mode.id(), "1920x1080@30"); + assert_eq!(mode.short_label(), "1080p@30"); + assert_eq!(mode.h264_bitrate_kbit(), 12_000); + assert_eq!(CameraMode::new(1280, 720, 30).h264_bitrate_kbit(), 6_000); + assert_eq!(CameraMode::new(640, 480, 30).h264_bitrate_kbit(), 3_000); + assert_eq!(CameraMode::from_id("1920x1080@30"), Some(mode)); + assert_eq!(CameraMode::from_id("not-a-mode"), None); + assert_eq!(CameraMode::from_id("1920x1080@0"), None); +} + +#[test] +fn camera_device_rank_prefers_primary_video_endpoint() { + assert_eq!(camera_device_rank("usb-Cam-video-index0"), 0); + assert_eq!(camera_device_rank("usb-Cam-video-index1"), 1); + assert_eq!(camera_device_rank("usb-Cam-without-index"), 2); +} + +#[test] +fn direct_camera_mode_probe_tolerates_missing_or_bad_device() { + assert!(discover_camera_modes_for("/dev/lesavka-definitely-missing").is_empty()); +} + +#[test] +fn discover_uses_override_and_tolerates_missing_pactl() { + let tmp = mk_temp_dir("discover-override"); + std::fs::write(tmp.join("cam"), "").expect("write"); + let catalog = + DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); + assert_eq!(catalog.cameras, vec!["cam".to_string()]); + let _ = std::fs::remove_dir_all(tmp); +} + +#[test] +fn discover_is_stable_with_process_environment_defaults() { + let _ = DeviceCatalog::discover(); +} + +#[test] +fn catalog_empty_reflects_collections() { + let mut catalog = DeviceCatalog::default(); + assert!(catalog.is_empty()); + catalog.speakers.push("sink-1".to_string()); + assert!(!catalog.is_empty()); +} + +#[test] +fn parse_pipewire_audio_nodes_collects_named_audio_nodes() { + let sample = br#" +[ + { +"info": { + "props": { + "media.class": "Audio/Source", + "node.name": "alsa_input.usb-TestMic-00.mono-fallback" + } +} + }, + { +"info": { + "props": { + "media.class": "Audio/Source", + "node.name": "bluez_input.80:C3:BA:76:26:AB" + } +} + }, + { +"info": { + "props": { + "media.class": "Audio/Sink", + "node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo" + } +} + } +] +"#; + assert_eq!( + parse_pipewire_audio_nodes(sample, "Audio/Source"), + vec![ + "alsa_input.usb-TestMic-00.mono-fallback".to_string(), + "bluez_input.80:C3:BA:76:26:AB".to_string(), + ] + ); +} + +#[test] +fn parse_pipewire_audio_nodes_ignores_invalid_payloads() { + assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty()); +} + +#[test] +fn parse_pipewire_audio_nodes_ignores_objects_without_names() { + let sample = br#" +[ + {"info":{"props":{"media.class":"Audio/Source"}}}, + {"info":{"props":{"media.class":"Audio/Source","node.nick":"Nick Source"}}}, + {"info":{"props":{"media.class":"Audio/Sink","device.name":"Sink Name"}}} +] +"#; + assert_eq!( + parse_pipewire_audio_nodes(sample, "Audio/Source"), + vec!["Nick Source".to_string()] + ); + assert_eq!( + parse_pipewire_audio_nodes(sample, "Audio/Sink"), + vec!["Sink Name".to_string()] + ); +} diff --git a/client/src/launcher/tests/diagnostics.rs b/client/src/launcher/tests/diagnostics.rs new file mode 100644 index 0000000..2604bd7 --- /dev/null +++ b/client/src/launcher/tests/diagnostics.rs @@ -0,0 +1,401 @@ +use super::*; +use crate::launcher::state::{ + CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState, +}; + +fn sample(n: u64) -> PerformanceSample { + PerformanceSample { + rtt_ms: 20.0 + n as f32, + probe_spread_ms: 3.0 + n as f32, + input_latency_ms: 10.0 + n as f32, + probe_loss_pct: n as f32, + client_process_cpu_pct: 12.5 + n as f32, + server_process_cpu_pct: 22.5 + n as f32, + video_loss_pct: (n as f32) * 0.5, + left_receive_fps: 30.0, + left_present_fps: 29.0, + left_server_fps: 30.0, + left_stream_spread_ms: 4.0, + left_packet_gap_peak_ms: 55.0, + left_present_gap_peak_ms: 60.0, + left_queue_depth: n as u32, + left_queue_peak: n as u32, + left_server_source_gap_peak_ms: 42.0, + left_server_send_gap_peak_ms: 48.0, + left_server_queue_peak: n as u32 + 1, + left_server_encoder_label: "x264enc".to_string(), + left_decoder_label: "decodebin".to_string(), + left_stream_caps_label: + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1".to_string(), + left_decoded_caps_label: + "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), + left_rendered_caps_label: + "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), + right_receive_fps: 30.0, + right_present_fps: 28.0, + right_server_fps: 30.0, + right_stream_spread_ms: 5.0, + right_packet_gap_peak_ms: 65.0, + right_present_gap_peak_ms: 75.0, + right_queue_depth: n as u32, + right_queue_peak: n as u32, + right_server_source_gap_peak_ms: 51.0, + right_server_send_gap_peak_ms: 58.0, + right_server_queue_peak: n as u32 + 1, + right_server_encoder_label: "source-pass-through".to_string(), + right_decoder_label: "decodebin".to_string(), + right_stream_caps_label: + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1".to_string(), + right_decoded_caps_label: + "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), + right_rendered_caps_label: + "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), + dropped_frames: n, + queue_depth: n as u32, + } +} + +#[test] +fn diagnostics_log_keeps_only_latest_samples_with_capacity() { + let mut log = DiagnosticsLog::new(2); + log.record(sample(1)); + log.record(sample(2)); + log.record(sample(3)); + + let kept: Vec = log.iter().map(|item| item.dropped_frames).collect(); + assert_eq!(kept, vec![2, 3]); + assert_eq!(log.latest().map(|s| s.dropped_frames), Some(3)); +} + +#[test] +fn diagnostics_log_enforces_minimum_capacity() { + let mut log = DiagnosticsLog::new(0); + log.record(sample(1)); + log.record(sample(2)); + assert_eq!(log.len(), 1); + assert_eq!(log.latest().map(|s| s.dropped_frames), Some(2)); +} + +#[test] +fn snapshot_report_contains_state_fields_and_samples() { + let mut state = LauncherState::new(); + state.devices = DeviceSelection { + camera: Some("/dev/video0".to_string()), + microphone: Some("alsa_input.usb".to_string()), + speaker: Some("alsa_output.usb".to_string()), + keyboard: Some("/dev/input/event10".to_string()), + mouse: Some("/dev/input/event11".to_string()), + }; + state.push_note("first note"); + + let mut log = DiagnosticsLog::new(4); + log.record(sample(7)); + + let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); + assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0")); + assert_eq!( + report.selected_microphone.as_deref(), + Some("alsa_input.usb") + ); + assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); + assert_eq!(report.audio_gain_label, "200%"); + assert_eq!( + report.selected_keyboard.as_deref(), + Some("/dev/input/event10") + ); + assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11")); + assert_eq!(report.recent_samples.len(), 1); + assert_eq!(report.notes, vec!["first note".to_string()]); + assert!(report.status.contains("mode=remote")); + assert!(report.client_version.starts_with("0.")); + assert_eq!(report.left_feed_source, "Left Eye"); + assert!( + report + .left_capture_profile + .contains("observed 1920x1080 @ 60 fps") + ); + assert_eq!(report.left_capture_transport, "device H.264 pass-through"); + assert_eq!(report.left_decoder_label, "decodebin"); + assert!(report.left_stream_caps_label.contains("video/x-h264")); + assert!(report.left_decoded_caps_label.contains("video/x-raw")); + assert!(report.left_rendered_caps_label.contains("video/x-raw")); +} + +#[test] +fn snapshot_report_marks_empty_live_labels_pending() { + let mut log = DiagnosticsLog::new(1); + let mut sample = sample(0); + sample.left_decoder_label.clear(); + sample.left_server_encoder_label.clear(); + sample.left_stream_caps_label.clear(); + sample.left_decoded_caps_label.clear(); + sample.left_rendered_caps_label.clear(); + sample.right_decoder_label.clear(); + sample.right_server_encoder_label.clear(); + sample.right_stream_caps_label.clear(); + sample.right_decoded_caps_label.clear(); + sample.right_rendered_caps_label.clear(); + log.record(sample); + + let mut state = LauncherState::new(); + state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); + let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); + + assert_eq!(report.left_decoder_label, "pending"); + assert_eq!(report.left_server_encoder_label, "pending"); + assert_eq!(report.left_stream_caps_label, "pending"); + assert_eq!(report.left_decoded_caps_label, "pending"); + assert_eq!(report.left_rendered_caps_label, "pending"); + assert_eq!(report.right_feed_source, "Left Eye (mirrored)"); + assert_eq!(report.right_decoder_label, "pending"); + assert_eq!(report.right_server_encoder_label, "pending"); + assert_eq!(report.right_stream_caps_label, "pending"); + assert_eq!(report.right_decoded_caps_label, "pending"); + assert_eq!(report.right_rendered_caps_label, "pending"); +} + +#[test] +fn snapshot_json_is_serializable_and_mentions_probe_command() { + let report = SnapshotReport::from_state( + &LauncherState::new(), + &DiagnosticsLog::new(1), + quality_probe_command().to_string(), + ); + let json = report.to_pretty_json().expect("serialize"); + assert!(json.contains("quality_gate.sh")); + assert!(json.contains("routing")); + assert!(json.contains("view_mode")); +} + +#[test] +fn snapshot_text_mentions_versions_profiles_and_recommendations() { + let report = SnapshotReport::from_state( + &LauncherState::new(), + &DiagnosticsLog::new(1), + quality_probe_command().to_string(), + ); + let text = report.to_pretty_text(); + assert!(text.contains("Lesavka Diagnostics")); + assert!(text.contains("client: v")); + assert!(text.contains("left eye")); + assert!(text.contains("source:")); + assert!(text.contains("transport:")); + assert!(text.contains("live: decoder=")); + assert!(text.contains("stream caps:")); + assert!(text.contains("decoded caps:")); + assert!(text.contains("rendered caps:")); + assert!(text.contains("media staging")); + assert!(text.contains("current UI state")); + assert!(text.contains("recommendations")); +} + +#[test] +#[doc = "Verifies diagnostics text follows live media settings."] +fn snapshot_text_reflects_live_media_control_changes() { + let mut state = LauncherState::new(); + state.select_camera(Some("/dev/video9".to_string())); + state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new( + 1920, 1080, 30, + ))); + state.select_microphone(Some("alsa_input.usb".to_string())); + state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_audio_gain_percent(250); + state.set_mic_gain_percent(125); + state.set_camera_channel_enabled(false); + state.set_microphone_channel_enabled(true); + + let report = SnapshotReport::from_state( + &state, + &DiagnosticsLog::new(1), + quality_probe_command().to_string(), + ); + let text = report.to_pretty_text(); + + assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false")); + assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true")); + assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true")); +} + +#[test] +fn snapshot_text_renders_recent_samples_and_notes() { + let mut state = LauncherState::new(); + state.set_server_available(true); + state.push_note("operator changed camera quality during the run"); + let mut log = DiagnosticsLog::new(2); + log.record(sample(3)); + + let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); + let text = report.to_pretty_text(); + + assert!(text.contains("server: unknown (reachable)")); + assert!(text.contains("rtt=23.0ms")); + assert!(text.contains("server=lx264enc:42/48/4")); + assert!(text.contains("notes")); + assert!(text.contains("operator changed camera quality during the run")); +} + +#[test] +fn snapshot_report_uses_effective_mirrored_capture_profile() { + let mut state = LauncherState::new(); + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + state.set_capture_size_preset(1, CaptureSizePreset::P720); + + let report = SnapshotReport::from_state( + &state, + &DiagnosticsLog::new(1), + quality_probe_command().to_string(), + ); + + assert_eq!(report.left_feed_source, "Right Eye (mirrored)"); + assert!(report.left_capture_profile.contains("720p")); + assert!(report.left_capture_profile.contains("1280x720")); +} + +#[test] +fn quality_probe_command_mentions_both_gates() { + let cmd = quality_probe_command(); + assert!(cmd.contains("hygiene_gate.sh")); + assert!(cmd.contains("quality_gate.sh")); +} + +#[test] +fn source_capture_profile_prefers_observed_stream_caps_when_available() { + let capture = CaptureSizeChoice { + preset: CaptureSizePreset::P1080, + width: 1920, + height: 1080, + fps: 60, + max_bitrate_kbit: 18_000, + }; + let label = capture_profile_label( + &capture, + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1", + ); + assert_eq!( + label, + "1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit" + ); +} + +#[test] +fn capture_profile_falls_back_when_stream_caps_are_incomplete() { + let capture = CaptureSizeChoice { + preset: CaptureSizePreset::P1080, + width: 1920, + height: 1080, + fps: 60, + max_bitrate_kbit: 18_000, + }; + let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920"); + assert_eq!( + label, + "1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit" + ); +} + +#[test] +fn recommendations_do_not_suggest_hardware_decode_when_nvdec_is_active() { + let mut log = DiagnosticsLog::new(1); + let mut sample = sample(1); + sample.client_process_cpu_pct = 96.0; + sample.left_receive_fps = 40.0; + sample.left_present_fps = 30.0; + sample.left_decoder_label = "nvh264dec".to_string(); + sample.right_decoder_label = "nvh264dec".to_string(); + log.record(sample); + + let items = recommendations_for(&LauncherState::new(), &log); + let joined = items.join("\n"); + assert!(!joined.contains("hardware decoder before adding more bitrate")); + assert!(!joined.contains("lighter breakout sizes or hardware decode")); + assert!(joined.contains("cheaper source mode")); +} + +#[test] +fn recommendations_cover_video_network_queue_cpu_and_decoder_pressure() { + let mut state = LauncherState::new(); + state.set_server_available(true); + state.set_display_surface(0, DisplaySurface::Window); + state.set_display_surface(1, DisplaySurface::Window); + + let mut sample = sample(12); + sample.probe_loss_pct = 4.0; + sample.probe_spread_ms = 22.0; + sample.video_loss_pct = 3.0; + sample.dropped_frames = 2; + sample.left_receive_fps = 58.0; + sample.left_present_fps = 42.0; + sample.right_receive_fps = 58.0; + sample.right_present_fps = 42.0; + sample.left_packet_gap_peak_ms = 180.0; + sample.right_packet_gap_peak_ms = 181.0; + sample.left_present_gap_peak_ms = 250.0; + sample.right_present_gap_peak_ms = 260.0; + sample.queue_depth = 9; + sample.left_queue_peak = 5; + sample.right_queue_peak = 5; + sample.left_server_send_gap_peak_ms = 40.0; + sample.right_server_send_gap_peak_ms = 40.0; + sample.left_server_source_gap_peak_ms = 130.0; + sample.right_server_source_gap_peak_ms = 131.0; + sample.left_server_queue_peak = 5; + sample.right_server_queue_peak = 5; + sample.client_process_cpu_pct = 90.0; + sample.server_process_cpu_pct = 88.0; + sample.left_decoder_label = "avdec_h264".to_string(); + sample.right_decoder_label = "avdec_h264".to_string(); + sample.left_server_encoder_label = "x264enc".to_string(); + sample.right_server_encoder_label = "x264enc".to_string(); + + let mut log = DiagnosticsLog::new(1); + log.record(sample); + let joined = recommendations_for(&state, &log).join("\n"); + + for needle in [ + "Control-plane probe spread or loss is elevated", + "Video packets are arriving with gaps", + "receiving more frames than it is presenting", + "Present-gap spikes are materially larger", + "preview queue is backing up", + "Queue depth is spiking", + "Client packet-gap spikes are much larger", + "large source-frame gaps", + "server-side stream queue is peaking", + "Client process CPU is high", + "Server process CPU is high", + "Device H.264 pass-through is active", + "At least one eye is falling back", + "At least one eye is still leaning on `x264enc`", + "Both eye feeds are broken out", + ] { + assert!(joined.contains(needle), "{needle} missing from {joined}"); + } +} + +#[test] +fn recommendations_cover_low_receive_fps_and_bursty_gap_without_loss() { + let mut sample = sample(0); + sample.video_loss_pct = 0.0; + sample.dropped_frames = 0; + sample.left_server_fps = 60.0; + sample.left_receive_fps = 48.0; + sample.right_server_fps = 60.0; + sample.right_receive_fps = 48.0; + sample.left_packet_gap_peak_ms = 150.0; + sample.right_packet_gap_peak_ms = 151.0; + + let mut log = DiagnosticsLog::new(1); + log.record(sample); + let joined = recommendations_for(&LauncherState::new(), &log).join("\n"); + + assert!(joined.contains("Receive fps is well below the target without packet loss")); + assert!(joined.contains("Packet-gap spikes are high without packet loss")); +} + +#[test] +fn hardware_decoder_detection_recognizes_nvdec_labels() { + let mut sample = sample(1); + sample.left_decoder_label = "nvh264dec".to_string(); + assert!(sample_uses_hardware_decode(&sample)); + assert!(!sample_uses_software_decode(&sample)); +} diff --git a/client/src/launcher/tests/mod.rs b/client/src/launcher/tests/mod.rs new file mode 100644 index 0000000..036366b --- /dev/null +++ b/client/src/launcher/tests/mod.rs @@ -0,0 +1,393 @@ +use super::*; +use serial_test::serial; + +#[test] +fn resolve_server_addr_prefers_explicit_server_flag() { + let args = vec![ + "--launcher".to_string(), + "--server".to_string(), + "http://example:50051".to_string(), + "http://fallback:50051".to_string(), + ]; + assert_eq!(resolve_server_addr(&args), "http://example:50051"); +} + +#[test] +fn resolve_server_addr_uses_first_non_flag_or_default() { + let args = vec![ + "--launcher".to_string(), + "http://from-arg:50051".to_string(), + ]; + assert_eq!(resolve_server_addr(&args), "http://from-arg:50051"); + + let args = vec!["--launcher".to_string()]; + assert_eq!(resolve_server_addr(&args), DEFAULT_SERVER_ADDR); +} + +#[test] +#[serial] +fn resolve_server_addr_falls_back_to_env_before_default() { + temp_env::with_var("LESAVKA_SERVER_ADDR", Some("http://env:50051"), || { + let args = vec!["--launcher".to_string()]; + assert_eq!(resolve_server_addr(&args), "http://env:50051"); + }); +} + +#[test] +#[serial] +fn launcher_ipc_paths_have_stable_defaults_and_env_overrides() { + temp_env::with_vars( + [ + (LAUNCHER_FOCUS_SIGNAL_ENV, None::<&str>), + (LAUNCHER_CLIPBOARD_CONTROL_ENV, None::<&str>), + ], + || { + assert_eq!( + launcher_focus_signal_path(), + PathBuf::from(DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH) + ); + assert_eq!( + launcher_clipboard_control_path(), + PathBuf::from(DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH) + ); + }, + ); + + temp_env::with_vars( + [ + (LAUNCHER_FOCUS_SIGNAL_ENV, Some("/tmp/focus-now")), + (LAUNCHER_CLIPBOARD_CONTROL_ENV, Some("/tmp/clip-now")), + ], + || { + assert_eq!( + launcher_focus_signal_path(), + PathBuf::from("/tmp/focus-now") + ); + assert_eq!( + launcher_clipboard_control_path(), + PathBuf::from("/tmp/clip-now") + ); + }, + ); +} + +#[test] +#[serial] +fn launcher_parent_env_parsing_is_strict_and_trims_ticks() { + temp_env::with_vars( + [ + (LAUNCHER_PARENT_PID_ENV, None::<&str>), + (LAUNCHER_PARENT_START_TICKS_ENV, None::<&str>), + ], + || assert!(launcher_parent_process_from_env().is_none()), + ); + + temp_env::with_var(LAUNCHER_PARENT_PID_ENV, Some("not-a-pid"), || { + assert!(launcher_parent_process_from_env().is_none()); + }); + + temp_env::with_vars( + [ + (LAUNCHER_PARENT_PID_ENV, Some("42")), + (LAUNCHER_PARENT_START_TICKS_ENV, Some(" 123456 ")), + ], + || { + let parent = launcher_parent_process_from_env().expect("parent env"); + assert_eq!(parent.pid, 42); + assert_eq!(parent.start_ticks.as_deref(), Some("123456")); + }, + ); + + temp_env::with_vars( + [ + (LAUNCHER_PARENT_PID_ENV, Some("42")), + (LAUNCHER_PARENT_START_TICKS_ENV, Some(" ")), + ], + || { + let parent = launcher_parent_process_from_env().expect("parent env"); + assert_eq!(parent.pid, 42); + assert_eq!(parent.start_ticks, None); + }, + ); +} + +#[test] +#[serial] +#[cfg(coverage)] +fn launcher_parent_watchdog_stub_is_non_exiting_under_coverage() { + temp_env::with_var(LAUNCHER_PARENT_PID_ENV, None::<&str>, || { + start_launcher_child_parent_watchdog_from_env(); + }); +} + +#[test] +#[serial] +fn runtime_env_vars_emit_selected_controls() { + temp_env::with_vars( + [ + ("LESAVKA_PASTE_KEY", None::<&str>), + ("LESAVKA_PASTE_KEY_FILE", None::<&str>), + ("LESAVKA_PASTE_RPC", None::<&str>), + ("LESAVKA_PASTE_MAX", None::<&str>), + ("LESAVKA_PASTE_DELAY_MS", None::<&str>), + ("LESAVKA_CLIPBOARD_CMD", None::<&str>), + ("LESAVKA_CLIPBOARD_TIMEOUT_MS", None::<&str>), + ], + || { + let mut state = LauncherState::new(); + state.set_routing(InputRouting::Local); + state.set_view_mode(ViewMode::Unified); + state.select_camera(Some("/dev/video0".to_string())); + state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30))); + state.select_microphone(Some("alsa_input.test".to_string())); + state.select_speaker(Some("alsa_output.test".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + state.set_audio_channel_enabled(true); + state.select_keyboard(Some("/dev/input/event10".to_string())); + state.select_mouse(Some("/dev/input/event11".to_string())); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string())); + assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".to_string())); + assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE")); + assert!(!envs.contains_key("LESAVKA_MIC_DISABLE")); + assert_eq!( + envs.get("LESAVKA_CLIPBOARD_DELAY_MS"), + Some(&"18".to_string()) + ); + assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string())); + assert_eq!( + envs.get("LESAVKA_CAM_H264_KBIT"), + Some(&"12000".to_string()) + ); + assert_eq!( + envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), + Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) + ); + assert_eq!( + envs.get("LESAVKA_DISABLE_VIDEO_RENDER"), + Some(&"1".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_CAM_SOURCE"), + Some(&"/dev/video0".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_MIC_SOURCE"), + Some(&"alsa_input.test".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_AUDIO_SINK"), + Some(&"alsa_output.test".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_KEYBOARD_DEVICE"), + Some(&"/dev/input/event10".to_string()) + ); + assert_eq!( + envs.get("LESAVKA_MOUSE_DEVICE"), + Some(&"/dev/input/event11".to_string()) + ); + assert!(!envs.contains_key("LESAVKA_PASTE_KEY_FILE")); + }, + ); +} + +#[test] +#[serial] +fn runtime_env_vars_passes_through_clipboard_transport_env() { + temp_env::with_vars( + [ + ("LESAVKA_PASTE_KEY_FILE", Some("/tmp/paste-key")), + ("LESAVKA_PASTE_RPC", Some("1")), + ("LESAVKA_CLIPBOARD_CMD", Some("cat /tmp/secret")), + ], + || { + let state = LauncherState::new(); + let envs = runtime_env_vars(&state); + assert_eq!( + envs.get("LESAVKA_PASTE_KEY_FILE"), + Some(&"/tmp/paste-key".to_string()) + ); + assert_eq!(envs.get("LESAVKA_PASTE_RPC"), Some(&"1".to_string())); + assert_eq!( + envs.get("LESAVKA_CLIPBOARD_CMD"), + Some(&"cat /tmp/secret".to_string()) + ); + }, + ); +} + +#[test] +#[serial] +fn runtime_env_vars_passes_through_remote_failsafe_launch_option() { + temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("60"), || { + let state = LauncherState::new(); + let envs = runtime_env_vars(&state); + assert_eq!( + envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), + Some(&"60".to_string()) + ); + }); +} + +#[test] +#[serial] +fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() { + temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("later"), || { + let state = LauncherState::new(); + let envs = runtime_env_vars(&state); + assert_eq!( + envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), + Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) + ); + }); +} + +#[test] +fn runtime_env_vars_do_not_disable_breakout_video_windows() { + let mut state = LauncherState::new(); + state.set_view_mode(ViewMode::Breakout); + + let envs = runtime_env_vars(&state); + assert!(!envs.contains_key("LESAVKA_DISABLE_VIDEO_RENDER")); +} + +#[test] +fn runtime_env_vars_disable_enabled_channels_without_real_devices() { + let mut state = LauncherState::new(); + state.select_microphone(Some("auto".to_string())); + state.select_speaker(Some("auto".to_string())); + state.set_microphone_channel_enabled(true); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); + assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); + assert!(!envs.contains_key("LESAVKA_AUDIO_SINK")); +} + +#[test] +fn runtime_env_vars_emit_selected_audio_gain() { + let mut state = LauncherState::new(); + state.set_audio_gain_percent(425); + state.set_mic_gain_percent(275); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"4.250".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"2.750".to_string())); +} + +#[test] +fn runtime_env_vars_use_channel_toggles_for_media_inclusion() { + let mut state = LauncherState::new(); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); + + state.select_camera(Some("/dev/video0".to_string())); + state.select_microphone(Some("alsa_input.usb".to_string())); + state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + let envs = runtime_env_vars(&state); + assert!(!envs.contains_key("LESAVKA_CAM_DISABLE")); + assert!(!envs.contains_key("LESAVKA_MIC_DISABLE")); + assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE")); + + state.set_audio_channel_enabled(false); + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); +} + +#[test] +fn runtime_env_vars_disable_uplink_media_when_unstaged() { + let state = LauncherState::new(); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); + assert!(!envs.contains_key("LESAVKA_CAM_SOURCE")); + assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); +} + +#[test] +fn maybe_run_launcher_returns_false_with_explicit_opt_out() { + let args = vec!["--no-launcher".to_string()]; + assert!(!maybe_run_launcher(&args).expect("launcher check")); +} + +#[test] +#[cfg(coverage)] +fn maybe_run_launcher_returns_true_with_launcher_flag() { + let args = vec!["--launcher".to_string()]; + assert!(maybe_run_launcher(&args).expect("launcher should run")); +} + +#[test] +#[cfg(coverage)] +fn maybe_run_launcher_defaults_to_launcher_for_empty_args() { + let args: Vec = vec![]; + assert!(maybe_run_launcher(&args).expect("launcher should run")); +} + +#[test] +fn should_run_launcher_defaults_true_for_empty_args() { + assert!(should_run_launcher(&[])); +} + +#[test] +fn should_run_launcher_honors_explicit_opt_out() { + let args = vec!["--no-launcher".to_string()]; + assert!(!should_run_launcher(&args)); +} + +#[test] +fn should_run_launcher_includes_legacy_direct_server_args() { + let args = vec!["http://server:50051".to_string()]; + assert!(should_run_launcher(&args)); +} + +#[test] +fn should_run_launcher_with_server_flag() { + let args = vec!["--server".to_string(), "http://server:50051".to_string()]; + assert!(should_run_launcher(&args)); +} + +#[test] +fn proc_stat_start_ticks_handles_process_names_with_spaces() { + let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21"; + + assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242")); + assert_eq!(proc_stat_start_ticks("missing-parens"), None); +} + +#[test] +fn launcher_parent_start_ticks_is_available_for_current_process() { + assert!(launcher_parent_start_ticks().is_some()); + + let parent = LauncherParentProcess { + pid: std::process::id(), + start_ticks: launcher_parent_start_ticks(), + }; + assert!(launcher_parent_process_matches(&parent)); + + let mismatched = LauncherParentProcess { + pid: std::process::id(), + start_ticks: Some("definitely-not-current".to_string()), + }; + assert!(!launcher_parent_process_matches(&mismatched)); + + let missing = LauncherParentProcess { + pid: u32::MAX, + start_ticks: None, + }; + assert!(!launcher_parent_process_matches(&missing)); +} diff --git a/client/src/launcher/tests/preview.rs b/client/src/launcher/tests/preview.rs new file mode 100644 index 0000000..7180200 --- /dev/null +++ b/client/src/launcher/tests/preview.rs @@ -0,0 +1,480 @@ +use super::{ + DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, + INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, + LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, + preview_render_size, sanitize_preview_request, +}; +use crate::launcher::state::{CaptureSizePreset, LauncherState}; +use futures::stream; +use lesavka_common::lesavka::relay_server::{Relay, RelayServer}; +use lesavka_common::lesavka::{MonitorRequest, VideoPacket}; +use serial_test::serial; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status}; + +#[derive(Clone, Default)] +struct ProbeRelay { + requests: Arc>>, +} + +#[tonic::async_trait] +impl Relay for ProbeRelay { + type StreamKeyboardStream = Pin< + Box< + dyn futures::Stream> + + Send, + >, + >; + type StreamMouseStream = Pin< + Box< + dyn futures::Stream> + Send, + >, + >; + type CaptureVideoStream = + Pin> + Send>>; + type CaptureAudioStream = Pin< + Box< + dyn futures::Stream> + Send, + >, + >; + type StreamMicrophoneStream = + Pin> + Send>>; + type StreamCameraStream = + Pin> + Send>>; + + async fn stream_keyboard( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_mouse( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn capture_video( + &self, + request: Request, + ) -> Result, Status> { + self.requests.lock().unwrap().push(request.into_inner()); + let (_tx, rx) = mpsc::channel(1); + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + async fn capture_audio( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_microphone( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_camera( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn paste_text( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::PasteReply { + ok: true, + error: String::new(), + })) + } + + async fn reset_usb( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { + ok: true, + })) + } + + async fn get_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::CapturePowerState { + available: true, + enabled: true, + unit: "relay.service".to_string(), + detail: "active/running".to_string(), + active_leases: 1, + mode: "auto".to_string(), + detected_devices: 2, + })) + } + + async fn set_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {})) + .await + } +} + +#[test] +fn inline_preview_profile_uses_lightweight_defaults() { + let profile = PreviewSurface::Inline.profile(); + assert_eq!(profile.display_width, PREVIEW_WIDTH); + assert_eq!(profile.display_height, PREVIEW_HEIGHT); + assert_eq!(profile.requested_width, INLINE_PREVIEW_REQUEST_WIDTH); + assert_eq!(profile.requested_height, INLINE_PREVIEW_REQUEST_HEIGHT); + assert_eq!(profile.requested_fps, INLINE_PREVIEW_REQUEST_FPS); + assert_eq!(profile.max_bitrate_kbit, INLINE_PREVIEW_MAX_KBIT); +} + +#[test] +fn breakout_preview_profile_defaults_to_higher_quality() { + let profile = PreviewSurface::Window.profile(); + assert_eq!(profile.display_width, 1280); + assert_eq!(profile.display_height, 720); + assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH); + assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT); + assert_eq!(profile.requested_fps, 60); + assert_eq!(profile.max_bitrate_kbit, 18_000); +} + +#[test] +fn preview_render_size_fits_source_into_display_budget() { + let profile = PreviewSurface::Inline.profile(); + assert_eq!(preview_render_size(profile, 1920, 1080), (960, 540)); +} + +#[test] +fn preview_render_size_never_upscales_beyond_source_geometry() { + let profile = PreviewSurface::Window.profile(); + assert_eq!(preview_render_size(profile, 1280, 720), (1280, 720)); +} + +#[test] +fn preview_request_sanitizer_keeps_requested_source_geometry() { + let adapted = sanitize_preview_request(1920, 1080, 60, 18_000); + assert_eq!(adapted, (1920, 1080, 60, 18_000)); +} + +#[test] +fn preview_request_sanitizer_clamps_invalid_values() { + let adapted = sanitize_preview_request(0, 0, 0, 0); + assert_eq!(adapted, (2, 2, 1, 800)); +} + +#[test] +fn preview_telemetry_reports_fps_jitter_loss_and_drop_metrics() { + let mut telemetry = PreviewTelemetry::default(); + let start = Instant::now(); + telemetry.note_decoder("nvh264dec"); + telemetry.record_packet_at(start, 1, 30, 0, 1, 41, 38, 2, "x264enc", 215); + telemetry.record_presented_frame_at(start + Duration::from_millis(5)); + telemetry.record_packet_at( + start + Duration::from_millis(33), + 2, + 30, + 0, + 1, + 41, + 38, + 2, + "x264enc", + 215, + ); + telemetry.record_presented_frame_at(start + Duration::from_millis(37)); + telemetry.record_packet_at( + start + Duration::from_millis(80), + 4, + 27, + 2, + 3, + 77, + 88, + 4, + "x264enc", + 382, + ); + telemetry.record_presented_frame_at(start + Duration::from_millis(90)); + + let snapshot = telemetry.snapshot_at(start + Duration::from_millis(120)); + assert!(snapshot.receive_fps >= 12.0); + assert!(snapshot.present_fps >= 12.0); + assert_eq!(snapshot.server_fps, 27.0); + assert!(snapshot.stream_spread_ms > 0.0); + assert!(snapshot.packet_loss_pct > 0.0); + assert_eq!(snapshot.dropped_frames, 2); + assert_eq!(snapshot.queue_depth, 3); + assert_eq!(snapshot.queue_depth_peak, 3); + assert!(snapshot.packet_gap_peak_ms >= 47.0); + assert!(snapshot.present_gap_peak_ms >= 53.0); + assert_eq!(snapshot.server_source_gap_peak_ms, 77.0); + assert_eq!(snapshot.server_send_gap_peak_ms, 88.0); + assert_eq!(snapshot.server_queue_peak, 4); + assert_eq!(snapshot.server_process_cpu_pct, 38.2); + assert_eq!(snapshot.server_encoder_label, "x264enc"); + assert_eq!(snapshot.decoder_label, "nvh264dec"); +} + +#[test] +#[serial] +fn inline_preview_requests_selected_source_profile_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let state = LauncherState::default(); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 18_000); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a capture request within timeout"); +} + +#[test] +#[serial] +fn inline_preview_requests_honest_source_profile_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 18_000); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a source capture request within timeout"); +} + +#[test] +#[serial] +fn inline_preview_requests_native_720p_source_mode_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P720); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1280); + assert_eq!(request.requested_height, 720); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 12_000); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a 720p source capture request within timeout"); +} + +#[test] +#[serial] +fn inline_preview_legacy_low_modes_fall_forward_to_720p_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P480); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1280); + assert_eq!(request.requested_height, 720); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 12_000); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a 720p fallback source capture request within timeout"); +} + +#[test] +#[serial] +fn preview_can_request_other_eye_as_a_distinct_stream() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + preview.set_capture_profile(0, 1, 1920, 1080, 30, 12_000); + preview.activate_surface_for_test(0, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 0); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a mirrored capture request within timeout"); +} diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs new file mode 100644 index 0000000..bb149a4 --- /dev/null +++ b/client/src/launcher/tests/state.rs @@ -0,0 +1,613 @@ +use super::*; + +#[test] +fn routing_and_view_env_values_are_stable() { + assert_eq!(InputRouting::Local.as_env(), "0"); + assert_eq!(InputRouting::Remote.as_env(), "1"); + assert_eq!(ViewMode::Unified.as_env(), "unified"); + assert_eq!(ViewMode::Breakout.as_env(), "breakout"); + assert_eq!(DisplaySurface::Preview.label(), "preview"); + assert_eq!(DisplaySurface::Window.label(), "window"); +} + +#[test] +fn preset_ids_labels_and_legacy_aliases_are_stable() { + for (preset, id, label) in [ + (FeedSourcePreset::ThisEye, "self", "Left Eye"), + (FeedSourcePreset::OtherEye, "other", "Right Eye"), + (FeedSourcePreset::Off, "off", "Off"), + ] { + assert_eq!(preset.as_id(), id); + assert_eq!(FeedSourcePreset::from_id(id), Some(preset)); + assert_eq!(preset.label(0), label); + } + assert_eq!(FeedSourcePreset::ThisEye.label(1), "Right Eye"); + assert_eq!(FeedSourcePreset::OtherEye.label(1), "Left Eye"); + assert_eq!(FeedSourcePreset::ThisEye.label(9), "This Eye"); + assert_eq!(FeedSourcePreset::from_id("bogus"), None); + + for (preset, id, label) in [ + (BreakoutSizePreset::P360, "360p", "360p"), + (BreakoutSizePreset::P540, "540p", "540p"), + (BreakoutSizePreset::P720, "720p", "720p"), + (BreakoutSizePreset::P900, "900p", "900p"), + (BreakoutSizePreset::P1080, "1080p", "1080p"), + (BreakoutSizePreset::P1440, "1440p", "1440p"), + (BreakoutSizePreset::Source, "source", "Source"), + (BreakoutSizePreset::FillDisplay, "fill", "Display"), + ] { + assert_eq!(preset.as_id(), id); + assert_eq!(BreakoutSizePreset::from_id(id), Some(preset)); + assert_eq!(preset.label(), label); + } + assert_eq!(BreakoutSizePreset::from_id("giant"), None); + + for (preset, id, label) in [ + (CaptureSizePreset::Vga, "vga", "VGA"), + (CaptureSizePreset::P480, "480p", "480p"), + (CaptureSizePreset::P576, "576p", "576p"), + (CaptureSizePreset::P720, "720p", "720p"), + (CaptureSizePreset::P1080, "1080p", "1080p"), + ] { + assert_eq!(preset.as_id(), id); + assert_eq!(preset.label(), label); + assert_eq!(preset.transport_label(), "device H.264 pass-through"); + } + assert_eq!( + CaptureSizePreset::from_id("360p"), + Some(CaptureSizePreset::Vga) + ); + assert_eq!( + CaptureSizePreset::from_id("540p"), + Some(CaptureSizePreset::P480) + ); + assert_eq!( + CaptureSizePreset::from_id("900p"), + Some(CaptureSizePreset::P1080) + ); + assert_eq!( + CaptureSizePreset::from_id("source"), + Some(CaptureSizePreset::P1080) + ); + assert_eq!(CaptureSizePreset::from_id("unknown"), None); + assert_eq!(CaptureSizePreset::P720.display_size(), (1280, 720)); + assert!(CaptureSizePreset::P1080.display_aspect_ratio() > 1.7); +} + +#[test] +fn defaults_pick_remote_unified_and_inactive_session() { + let state = LauncherState::new(); + assert_eq!(state.routing, InputRouting::Remote); + assert_eq!(state.view_mode, ViewMode::Unified); + assert_eq!(state.display_surface(0), DisplaySurface::Preview); + assert_eq!(state.display_surface(1), DisplaySurface::Preview); + assert_eq!(state.preview_source_size(), PreviewSourceSize::default()); + assert_eq!(state.breakout_limit_size(), PreviewSourceSize::default()); + assert_eq!(state.capture_size_preset(0), CaptureSizePreset::P1080); + assert_eq!(state.breakout_size_preset(0), BreakoutSizePreset::Source); + assert!(!state.server_available); + assert!(!state.remote_active); + assert!(state.devices.camera.is_none()); + assert!(state.devices.microphone.is_none()); + assert!(state.devices.speaker.is_none()); + assert!(state.devices.keyboard.is_none()); + assert!(state.devices.mouse.is_none()); + assert!(!state.channels.camera); + assert!(!state.channels.microphone); + assert!(state.channels.audio); + assert_eq!(state.audio_gain_percent, DEFAULT_AUDIO_GAIN_PERCENT); + assert_eq!(state.audio_gain_env_value(), "2.000"); + assert_eq!(state.audio_gain_label(), "200%"); + assert_eq!(state.mic_gain_percent, DEFAULT_MIC_GAIN_PERCENT); + assert_eq!(state.mic_gain_env_value(), "1.000"); + assert_eq!(state.mic_gain_label(), "100%"); + assert_eq!(state.capture_power.unit, "relay.service"); + assert_eq!(state.capture_power.mode, "auto"); +} + +#[test] +fn display_surface_updates_global_view_summary() { + let mut state = LauncherState::new(); + state.set_display_surface(1, DisplaySurface::Window); + assert_eq!(state.view_mode, ViewMode::Breakout); + assert_eq!(state.breakout_count(), 1); + + state.set_display_surface(1, DisplaySurface::Preview); + assert_eq!(state.view_mode, ViewMode::Unified); + assert_eq!(state.breakout_count(), 0); + + state.set_view_mode(ViewMode::Breakout); + assert_eq!(state.display_surface(0), DisplaySurface::Window); + assert_eq!(state.display_surface(1), DisplaySurface::Window); + + state.set_display_surface(9, DisplaySurface::Window); + assert_eq!(state.display_surface(9), DisplaySurface::Preview); + assert_eq!(state.breakout_count(), 2); +} + +#[test] +fn feed_sources_can_mirror_or_disable_a_pane() { + let mut state = LauncherState::new(); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); + + assert_eq!(state.resolved_feed_monitor_id(0), Some(0)); + assert_eq!(state.resolved_feed_monitor_id(1), Some(1)); + + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + assert_eq!(state.resolved_feed_monitor_id(0), Some(1)); + assert_eq!( + state.display_capture_size_choice(0), + Some(state.capture_size_choice(1)) + ); + + state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); + assert_eq!(state.resolved_feed_monitor_id(1), Some(0)); + + state.set_feed_source_preset(0, FeedSourcePreset::Off); + assert_eq!(state.resolved_feed_monitor_id(0), None); + assert!(state.display_capture_size_choice(0).is_none()); + + state.set_feed_source_preset(9, FeedSourcePreset::Off); + assert_eq!(state.feed_source_preset(9), FeedSourcePreset::ThisEye); + let labels: Vec<_> = state + .feed_source_options(1) + .into_iter() + .map(|choice| (choice.preset, choice.label)) + .collect(); + assert_eq!( + labels, + vec![ + (FeedSourcePreset::ThisEye, "Right Eye"), + (FeedSourcePreset::OtherEye, "Left Eye"), + (FeedSourcePreset::Off, "Off"), + ] + ); +} + +#[test] +fn mirrored_panes_use_their_effective_source_size_for_breakout_source_labels() { + let mut state = LauncherState::new(); + state.set_capture_size_preset(1, CaptureSizePreset::P720); + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + + let mirrored_source = state.effective_preview_source_size(0); + assert_eq!(mirrored_source.width, 1280); + assert_eq!(mirrored_source.height, 720); + assert_eq!(mirrored_source.fps, 60); + + let mirrored_breakout = state.breakout_size_choice(0); + assert_eq!(mirrored_breakout.preset, BreakoutSizePreset::Source); + assert_eq!(mirrored_breakout.width, 1280); + assert_eq!(mirrored_breakout.height, 720); + + assert_eq!( + state.display_capture_size_preset(0), + Some(CaptureSizePreset::P720) + ); + assert_eq!(state.display_capture_fps(0), Some(60)); + assert_eq!(state.display_capture_bitrate_kbit(0), Some(12_000)); +} + +#[test] +fn zero_and_out_of_range_profile_updates_are_safe_noops() { + let mut state = LauncherState::new(); + let original_source = state.preview_source_size(); + state.set_preview_source_profile(0, 1080, 0); + assert_eq!(state.preview_source_size(), original_source); + + let original_limit = state.breakout_limit_size(); + state.set_breakout_limit_size(1920, 0); + assert_eq!(state.breakout_limit_size(), original_limit); + + let original_display = state.breakout_display_size(); + state.set_breakout_display_size(0, 1080); + assert_eq!(state.breakout_display_size(), original_display); + + state.set_breakout_display_size(3440, 1440); + assert_eq!(state.breakout_display_size().width, 3440); + assert_eq!(state.breakout_display_size().height, 1440); + + state.set_capture_size_preset(9, CaptureSizePreset::P720); + assert_eq!(state.capture_size_preset(9), CaptureSizePreset::P1080); + state.set_capture_fps(9, 0); + assert_eq!(state.capture_fps(9), default_eye_source_mode().fps); + state.set_capture_bitrate_kbit(9, 1); + assert!(state.capture_bitrate_kbit(9) >= 18_000); + state.set_breakout_size_preset(9, BreakoutSizePreset::P360); + assert_eq!(state.breakout_size_preset(9), BreakoutSizePreset::Source); +} + +#[test] +fn selecting_auto_or_blank_clears_explicit_device() { + let mut state = LauncherState::new(); + state.select_camera(Some("/dev/video0".to_string())); + assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0")); + + state.select_camera(Some("auto".to_string())); + assert!(state.devices.camera.is_none()); + + state.select_microphone(Some(" ".to_string())); + assert!(state.devices.microphone.is_none()); +} + +#[test] +fn catalog_defaults_stage_real_media_devices_without_enabling_channels() { + let mut state = LauncherState::new(); + state.select_camera(Some("/dev/video-special".to_string())); + + let catalog = DeviceCatalog { + cameras: vec!["/dev/video0".to_string()], + camera_modes: [( + "/dev/video0".to_string(), + vec![CameraMode::new(1920, 1080, 30)], + )] + .into_iter() + .collect(), + microphones: vec!["alsa_input.usb".to_string()], + speakers: vec!["alsa_output.usb".to_string()], + keyboards: vec!["/dev/input/event10".to_string()], + mice: vec!["/dev/input/event11".to_string()], + }; + + state.apply_catalog_defaults(&catalog); + + assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); + assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb")); + assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb")); + assert!(!state.channels.camera); + assert!(!state.channels.microphone); + assert!(state.channels.audio); + + let mut fresh = LauncherState::new(); + fresh.apply_catalog_defaults(&catalog); + assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0")); + assert_eq!(fresh.camera_quality, Some(CameraMode::new(1920, 1080, 30))); + assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb")); + assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb")); +} + +#[test] +fn camera_quality_tracks_selected_camera_supported_modes() { + let catalog = DeviceCatalog { + cameras: vec!["cam-a".to_string(), "cam-b".to_string()], + camera_modes: [ + ( + "cam-a".to_string(), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30), + ], + ), + ("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]), + ] + .into_iter() + .collect(), + ..DeviceCatalog::default() + }; + + let mut state = LauncherState::new(); + state.apply_catalog_defaults(&catalog); + assert_eq!(state.devices.camera.as_deref(), Some("cam-a")); + assert_eq!( + state.camera_quality_options(&catalog), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30) + ] + ); + + state.select_camera_quality(Some(CameraMode::new(1280, 720, 30))); + assert_eq!( + state.selected_camera_quality(&catalog), + Some(CameraMode::new(1280, 720, 30)) + ); + + state.select_camera(Some("cam-b".to_string())); + state.normalize_camera_quality(&catalog); + assert_eq!(state.camera_quality, Some(CameraMode::new(1280, 720, 30))); + + state.select_camera(None); + state.normalize_camera_quality(&catalog); + assert_eq!(state.camera_quality, None); +} + +#[test] +fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() { + let mut state = LauncherState::new(); + state.set_audio_gain_percent(350); + assert_eq!(state.audio_gain_percent, 350); + assert_eq!(state.audio_gain_label(), "350%"); + assert_eq!(state.audio_gain_env_value(), "3.500"); + + state.set_audio_gain_percent(10_000); + assert_eq!(state.audio_gain_percent, MAX_AUDIO_GAIN_PERCENT); + assert_eq!(state.audio_gain_label(), "800%"); + assert_eq!(state.audio_gain_env_value(), "8.000"); + + state.set_mic_gain_percent(325); + assert_eq!(state.mic_gain_percent, 325); + assert_eq!(state.mic_gain_label(), "325%"); + assert_eq!(state.mic_gain_env_value(), "3.250"); + + state.set_mic_gain_percent(10_000); + assert_eq!(state.mic_gain_percent, MAX_MIC_GAIN_PERCENT); + assert_eq!(state.mic_gain_label(), "400%"); + assert_eq!(state.mic_gain_env_value(), "4.000"); +} + +#[test] +fn start_and_stop_remote_only_report_changes_once() { + let mut state = LauncherState::new(); + assert!(state.start_remote()); + assert!(!state.start_remote()); + assert!(state.remote_active); + + assert!(state.stop_remote()); + assert!(!state.stop_remote()); + assert!(!state.remote_active); +} + +#[test] +fn status_line_mentions_all_user_visible_controls() { + let mut state = LauncherState::new(); + state.set_server_available(true); + state.set_routing(InputRouting::Local); + state.set_view_mode(ViewMode::Unified); + state.select_camera(Some("/dev/video0".to_string())); + state.select_camera_quality(Some(CameraMode::new(1920, 1080, 30))); + state.select_microphone(Some("alsa_input.usb".to_string())); + state.select_speaker(Some("alsa_output.usb".to_string())); + state.set_camera_channel_enabled(true); + state.set_microphone_channel_enabled(true); + state.set_audio_channel_enabled(true); + state.select_keyboard(Some("/dev/input/event-kbd".to_string())); + state.select_mouse(Some("/dev/input/event-mouse".to_string())); + state.set_preview_source_profile(1920, 1080, 30); + state.start_remote(); + + let status = state.status_line(); + assert!(status.contains("mode=local")); + assert!(status.contains("server=true")); + assert!(status.contains("view=unified")); + assert!(status.contains("active=true")); + assert!(status.contains("source=1920x1080")); + assert!(status.contains("d1=preview")); + assert!(status.contains("d2=preview")); + assert!(status.contains("camera=/dev/video0")); + assert!(status.contains("camera_quality=1080p@30")); + assert!(status.contains("mic=alsa_input.usb")); + assert!(status.contains("speaker=alsa_output.usb")); + assert!(status.contains("audio_gain=200%")); + assert!(status.contains("kbd=/dev/input/event-kbd")); + assert!(status.contains("mouse=/dev/input/event-mouse")); +} + +#[test] +fn capture_power_status_updates_snapshot_state() { + let mut state = LauncherState::new(); + state.set_capture_power(CapturePowerStatus { + available: true, + enabled: true, + unit: "relay.service".to_string(), + detail: "active/running".to_string(), + active_leases: 2, + mode: "forced-on".to_string(), + detected_devices: 2, + }); + + assert!(state.capture_power.available); + assert!(state.capture_power.enabled); + assert_eq!(state.capture_power.active_leases, 2); + assert!(state.status_line().contains("power=on")); +} + +#[test] +fn server_availability_tracks_reachability() { + let mut state = LauncherState::new(); + assert!(!state.server_available); + state.set_server_available(true); + assert!(state.server_available); +} + +#[test] +fn breakout_size_choices_track_the_negotiated_source_size() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 60); + state.set_breakout_limit_size(2560, 1440); + + let source = state.capture_size_choice(0); + assert_eq!(source.width, 1920); + assert_eq!(source.height, 1080); + assert_eq!(source.fps, 60); + assert_eq!(source.max_bitrate_kbit, 18_000); + + state.set_capture_size_preset(0, CaptureSizePreset::P480); + let compact_capture = state.capture_size_choice(0); + assert_eq!(compact_capture.preset, CaptureSizePreset::P720); + assert_eq!(compact_capture.width, 1280); + assert_eq!(compact_capture.height, 720); + assert_eq!(compact_capture.fps, 60); + assert_eq!(compact_capture.max_bitrate_kbit, 12_000); + + let effective_source = state.effective_preview_source_size(0); + assert_eq!(effective_source.width, 1280); + assert_eq!(effective_source.height, 720); + assert_eq!(effective_source.fps, 60); + + let display = state.breakout_size_choice(0); + assert_eq!(display.width, 1280); + assert_eq!(display.height, 720); + + state.set_breakout_size_preset(0, BreakoutSizePreset::P360); + let smaller = state.breakout_size_choice(0); + assert_eq!(smaller.width, 640); + assert_eq!(smaller.height, 360); + + state.set_breakout_size_preset(0, BreakoutSizePreset::P540); + let compact = state.breakout_size_choice(0); + assert_eq!(compact.width, 960); + assert_eq!(compact.height, 540); + + let capture_options = state.capture_size_options(); + assert_eq!(capture_options.len(), 2); + assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080); + assert_eq!(capture_options[0].width, 1920); + assert_eq!(capture_options[0].height, 1080); + assert_eq!(capture_options[0].fps, 60); + assert_eq!(capture_options[0].max_bitrate_kbit, 18_000); + + let breakout_options = state.breakout_size_options(0); + assert!(breakout_options.len() >= 5); + assert!(breakout_options.iter().any(|choice| { + choice.preset == BreakoutSizePreset::Source && choice.width == 1280 && choice.height == 720 + })); +} + +#[test] +fn swap_key_binding_tracks_selected_key_and_binding_mode() { + let mut state = LauncherState::new(); + assert_eq!(state.swap_key, "pause"); + assert!(!state.swap_key_binding); + + let token = state.begin_swap_key_binding(); + assert!(state.swap_key_binding); + assert_eq!(token, state.swap_key_binding_token); + + state.set_swap_key("F8"); + assert_eq!(state.swap_key, "f8"); + + state.set_swap_key(" "); + assert_eq!(state.swap_key, "off"); + + state.finish_swap_key_binding(); + assert!(!state.swap_key_binding); +} + +#[test] +fn swap_key_binding_timeout_only_cancels_matching_attempt() { + let mut state = LauncherState::new(); + let first = state.begin_swap_key_binding(); + let second = state.begin_swap_key_binding(); + + assert!(!state.cancel_swap_key_binding(first)); + assert!(state.swap_key_binding); + assert!(state.cancel_swap_key_binding(second)); + assert!(!state.swap_key_binding); +} + +#[test] +fn complete_swap_key_binding_updates_value_and_ends_binding() { + let mut state = LauncherState::new(); + state.begin_swap_key_binding(); + + state.complete_swap_key_binding("F12"); + + assert_eq!(state.swap_key, "f12"); + assert!(!state.swap_key_binding); +} + +#[test] +fn push_note_accumulates_operator_context() { + let mut state = LauncherState::new(); + state.push_note("preview warm"); + state.push_note("relay linked"); + + assert_eq!(state.notes, vec!["preview warm", "relay linked"]); +} + +#[test] +fn capture_size_presets_map_to_real_device_modes() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 60); + state.set_capture_size_preset(0, CaptureSizePreset::P1080); + let source = state.capture_size_choice(0); + assert_eq!(source.width, 1920); + assert_eq!(source.height, 1080); + assert_eq!(source.fps, 60); + assert!(source.max_bitrate_kbit >= 18_000); + + state.set_capture_size_preset(0, CaptureSizePreset::P720); + let hd = state.capture_size_choice(0); + assert_eq!(hd.preset, CaptureSizePreset::P720); + assert_eq!(hd.width, 1280); + assert_eq!(hd.height, 720); + assert_eq!(hd.fps, 60); + + state.set_capture_size_preset(0, CaptureSizePreset::P576); + let compact = state.capture_size_choice(0); + assert_eq!(compact.preset, CaptureSizePreset::P720); + assert_eq!(compact.width, 1280); + assert_eq!(compact.height, 720); + assert_eq!(compact.fps, 60); + + state.set_capture_size_preset(0, CaptureSizePreset::Vga); + let small = state.capture_size_choice(0); + assert_eq!(small.preset, CaptureSizePreset::P720); + assert_eq!(small.width, 1280); + assert_eq!(small.height, 720); + assert_eq!(small.fps, 60); +} + +#[test] +fn source_capture_knobs_follow_the_selected_native_mode() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 60); + + state.set_capture_size_preset(1, CaptureSizePreset::P1080); + let defaults = state.capture_size_choice(1); + assert_eq!(defaults.width, 1920); + assert_eq!(defaults.height, 1080); + assert_eq!(defaults.fps, 60); + assert_eq!(defaults.max_bitrate_kbit, 18_000); + + state.set_capture_fps(1, 24); + state.set_capture_bitrate_kbit(1, 8_500); + let tuned = state.capture_size_choice(1); + assert_eq!(tuned.preset, CaptureSizePreset::P1080); + assert_eq!(tuned.width, 1920); + assert_eq!(tuned.height, 1080); + assert_eq!(tuned.fps, 60); + assert_eq!(tuned.max_bitrate_kbit, 18_000); +} + +#[test] +fn capture_option_helpers_report_native_fps_and_bitrate_tiers() { + let full_hd = PreviewSourceSize { + width: 1920, + height: 1080, + fps: 30, + }; + assert_eq!(capture_fps_options(full_hd)[0].fps, 30); + assert_eq!(capture_bitrate_options(full_hd)[0].max_bitrate_kbit, 12_000); + assert_eq!(estimate_source_bitrate_kbit(1280, 720, 30), 6_000); + assert_eq!(estimate_source_bitrate_kbit(640, 480, 15), 2_500); + + let compact = PreviewSourceSize { + width: 640, + height: 480, + fps: 0, + }; + assert_eq!(capture_fps_options(compact)[0].fps, 1); + assert_eq!(capture_bitrate_options(compact)[0].max_bitrate_kbit, 2_500); +} + +#[test] +fn standard_fit_preserves_aspect_when_width_is_limiting() { + assert_eq!(fit_standard_dimensions(1000, 2000, 1920, 1080), (1000, 562)); +} + +#[test] +fn source_capture_ignores_manual_fps_and_bitrate_knobs() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 60); + state.set_capture_size_preset(0, CaptureSizePreset::P720); + state.set_capture_fps(0, 60); + state.set_capture_bitrate_kbit(0, 24_000); + + let source = state.capture_size_choice(0); + assert_eq!(source.preset, CaptureSizePreset::P720); + assert_eq!(source.width, 1280); + assert_eq!(source.height, 720); + assert_eq!(source.fps, 60); + assert_eq!(source.max_bitrate_kbit, 12_000); +} diff --git a/client/src/launcher/tests/ui_components_scale.rs b/client/src/launcher/tests/ui_components_scale.rs new file mode 100644 index 0000000..f73b98a --- /dev/null +++ b/client/src/launcher/tests/ui_components_scale.rs @@ -0,0 +1,9 @@ +use super::should_reset_scale_on_double_click; + +#[test] +fn scale_reset_helper_requires_a_true_double_click_and_a_real_change() { + assert!(!should_reset_scale_on_double_click(1, 350.0, 200.0)); + assert!(!should_reset_scale_on_double_click(2, 200.0, 200.0)); + assert!(should_reset_scale_on_double_click(2, 350.0, 200.0)); + assert!(should_reset_scale_on_double_click(2, 75.0, 100.0)); +} diff --git a/client/src/launcher/tests/ui_coverage.rs b/client/src/launcher/tests/ui_coverage.rs new file mode 100644 index 0000000..40fe5ff --- /dev/null +++ b/client/src/launcher/tests/ui_coverage.rs @@ -0,0 +1,24 @@ +use super::{run_gui_launcher, session_preview_active}; +use crate::launcher::state::{CapturePowerStatus, LauncherState}; + +#[test] +fn coverage_stub_returns_ok() { + assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok()); +} + +#[test] +fn session_preview_stays_idle_when_capture_is_forced_off() { + let mut state = LauncherState::new(); + state.start_remote(); + state.set_capture_power(CapturePowerStatus { + available: true, + enabled: false, + unit: "relay.service".to_string(), + detail: "inactive/dead".to_string(), + active_leases: 1, + mode: "forced-off".to_string(), + detected_devices: 0, + }); + + assert!(!session_preview_active(&state, true)); +} diff --git a/client/src/launcher/tests/ui_preview_profiles.rs b/client/src/launcher/tests/ui_preview_profiles.rs new file mode 100644 index 0000000..01cfc4a --- /dev/null +++ b/client/src/launcher/tests/ui_preview_profiles.rs @@ -0,0 +1,117 @@ +use super::apply_preview_profiles; +use crate::launcher::preview::{LauncherPreview, PreviewSurface}; +use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState}; + +#[test] +fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let state = LauncherState::default(); + + let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); + assert_eq!(bootstrap.0, 0); + assert_eq!(bootstrap.3, 1920); + assert_eq!(bootstrap.4, 1080); + assert_eq!(bootstrap.5, 60); + assert_eq!(bootstrap.6, 18_000); + + apply_preview_profiles(&preview, &state); + + let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); + assert_eq!(inline.0, 1); + assert_eq!(inline.3, 1920); + assert_eq!(inline.4, 1080); + assert_eq!(inline.5, 60); + assert_eq!(inline.6, 18_000); + + let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap(); + assert_eq!(window.0, 1); + assert_eq!(window.3, 1920); + assert_eq!(window.4, 1080); + assert_eq!(window.5, 60); + assert_eq!(window.6, 18_000); + + preview.shutdown_all(); +} + +#[test] +fn source_preview_profile_stays_honest_after_apply() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); + + apply_preview_profiles(&preview, &state); + + let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); + assert_eq!(inline.0, 1); + assert_eq!(inline.3, 1920); + assert_eq!(inline.4, 1080); + assert_eq!(inline.5, 60); + assert_eq!(inline.6, 18_000); + + preview.shutdown_all(); +} + +#[test] +fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + + apply_preview_profiles(&preview, &state); + + let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap(); + let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap(); + assert_eq!(inline.0, 1); + assert_eq!(window.0, 1); + + preview.shutdown_all(); +} + +#[test] +fn mirrored_preview_profile_inherits_the_source_eye_mode() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + state.set_capture_size_preset(1, CaptureSizePreset::P720); + + apply_preview_profiles(&preview, &state); + + let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap(); + let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap(); + assert_eq!(inline.0, 1); + assert_eq!(window.0, 1); + assert_eq!(inline.3, 1280); + assert_eq!(inline.4, 720); + assert_eq!(inline.5, 60); + assert_eq!(inline.6, 12_000); + assert_eq!(window.3, 1280); + assert_eq!(window.4, 720); + assert_eq!(window.5, 60); + assert_eq!(window.6, 12_000); + + preview.shutdown_all(); +} + +#[test] +fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_feed_source_preset(0, FeedSourcePreset::Off); + + apply_preview_profiles(&preview, &state); + + assert_eq!( + preview.feed_disabled_for_test(0, PreviewSurface::Inline), + Some(true) + ); + assert_eq!( + preview.feed_disabled_for_test(0, PreviewSurface::Window), + Some(true) + ); + assert_eq!( + preview.feed_disabled_for_test(1, PreviewSurface::Inline), + Some(false) + ); + + preview.shutdown_all(); +} diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs new file mode 100644 index 0000000..9144be4 --- /dev/null +++ b/client/src/launcher/tests/ui_runtime.rs @@ -0,0 +1,329 @@ +use super::*; +use crate::launcher::{ + devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState, + ui_components::build_launcher_view, +}; +use serial_test::serial; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn local_test_detail_mentions_idle_and_running_modes() { + assert!(local_test_detail(false, false, false, false).contains("idle")); + let running = local_test_detail(true, true, false, false); + assert!(running.contains("camera preview")); + assert!(running.contains("mic monitor")); +} + +#[test] +fn gpio_power_label_tracks_detected_devices() { + let mut power = CapturePowerStatus::default(); + assert_eq!(gpio_power_label(&power), "Unavailable"); + + power.available = true; + assert_eq!(gpio_power_label(&power), "Power Off"); + + power.enabled = true; + assert_eq!(gpio_power_label(&power), "No Eyes"); + + power.detected_devices = 1; + assert_eq!(gpio_power_label(&power), "1 Eye"); + + power.detected_devices = 2; + assert_eq!(gpio_power_label(&power), "2 Eyes"); +} + +#[test] +fn server_chip_state_tracks_connection_not_just_reachability() { + let mut state = LauncherState::new(); + assert_eq!(server_light_state(&state, false), StatusLightState::Idle); + assert_eq!(server_version_label(&state), "-"); + + state.set_server_available(true); + state.set_server_version(Some("0.12.3".to_string())); + assert_eq!(server_light_state(&state, false), StatusLightState::Caution); + assert_eq!(server_version_label(&state), "v0.12.3"); + + assert_eq!(server_light_state(&state, true), StatusLightState::Live); + + state.set_server_version(Some("v0.12.4".to_string())); + assert_eq!(server_version_label(&state), "v0.12.4"); + + state.set_server_version(Some(" ".to_string())); + assert_eq!(server_version_label(&state), "-"); +} + +#[test] +fn capture_power_detail_mentions_detected_eyes_when_powered() { + let power = CapturePowerStatus { + available: true, + enabled: true, + detail: "active/running".to_string(), + detected_devices: 1, + ..Default::default() + }; + + assert!(capture_power_detail(&power).contains("1 eye detected")); +} + +#[test] +fn compact_device_name_prefers_basename_when_available() { + assert_eq!(compact_device_name("/dev/video0"), "video0"); + assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb"); +} + +#[test] +fn strip_ansi_sequences_removes_terminal_codes() { + let raw = "\u{1b}[32mINFO\u{1b}[0m hello"; + assert_eq!(strip_ansi_sequences(raw), "INFO hello"); +} + +#[test] +fn classify_log_tags_assigns_prefix_and_severity_colors() { + let tags = classify_log_tags("[relay] WARN pipeline failed"); + assert!(tags.contains(&"log-relay")); + assert!(tags.contains(&"log-error") || tags.contains(&"log-warn")); +} + +#[test] +#[doc = "Verifies the default console filter hides relay INFO noise."] +fn session_log_filter_hides_noisy_info_by_default_but_keeps_errors() { + assert!(!should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ThreadId(01) audio packet received packet=3000", + ConsoleLogLevel::Warn + )); + assert!(!should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ThreadId(04) decoded audio level rms=-32", + ConsoleLogLevel::Warn + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z WARN pipeline is recovering", + ConsoleLogLevel::Warn + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO ❌ connect failed", + ConsoleLogLevel::Error + )); + assert!(should_show_session_log_line( + "[launcher] Relay connected with inputs routed to remote.", + ConsoleLogLevel::Error + )); + assert!(should_show_session_log_line( + "[relay] 2026-04-22T23:20:17Z INFO audio packet received", + ConsoleLogLevel::Info + )); +} + +#[test] +fn write_audio_gain_request_formats_live_control_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("gain.control"); + write_audio_gain_request(&path, 425).expect("write gain"); + let raw = std::fs::read_to_string(path).expect("read gain"); + assert!(raw.starts_with("4.250 "), "{raw}"); +} + +#[test] +fn write_mic_gain_request_formats_live_control_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mic-gain.control"); + write_mic_gain_request(&path, 325).expect("write gain"); + let raw = std::fs::read_to_string(path).expect("read gain"); + assert!(raw.starts_with("3.250 "), "{raw}"); +} + +#[gtk::test] +#[serial] +fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-dock") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let state = Rc::new(RefCell::new(LauncherState::new())); + state + .borrow_mut() + .set_display_surface(0, DisplaySurface::Window); + state + .borrow_mut() + .set_display_surface(1, DisplaySurface::Window); + let state_snapshot = state.borrow().clone(); + let view = build_launcher_view( + &app, + "http://127.0.0.1:50051", + &DeviceCatalog::default(), + &state_snapshot, + ); + let child_proc = Rc::new(RefCell::new(None::)); + + let left_binding = PreviewBinding::test_stub(); + let right_binding = PreviewBinding::test_stub(); + { + let mut popouts = view.popouts.borrow_mut(); + popouts[0] = Some(PopoutWindowHandle { + window: gtk::ApplicationWindow::builder() + .application(&app) + .title("Left") + .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), + picture: gtk::Picture::new(), + status_label: gtk::Label::new(None), + binding: left_binding, + }); + popouts[1] = Some(PopoutWindowHandle { + window: gtk::ApplicationWindow::builder() + .application(&app) + .title("Right") + .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), + picture: gtk::Picture::new(), + status_label: gtk::Label::new(None), + binding: right_binding, + }); + } + + dock_all_displays_to_preview(&state, &child_proc, &view.popouts, &view.widgets); + + assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); + assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); + assert_eq!(state.borrow().display_surface(1), DisplaySurface::Preview); +} + +#[gtk::test] +#[serial] +fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-reentrant-dock") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let state = Rc::new(RefCell::new(LauncherState::new())); + state + .borrow_mut() + .set_display_surface(0, DisplaySurface::Window); + let state_snapshot = state.borrow().clone(); + let view = build_launcher_view( + &app, + "http://127.0.0.1:50051", + &DeviceCatalog::default(), + &state_snapshot, + ); + let child_proc = Rc::new(RefCell::new(None::)); + + let popouts = Rc::clone(&view.popouts); + let window = gtk::ApplicationWindow::builder() + .application(&app) + .title("Reentrant") + .build(); + { + let popouts = Rc::clone(&popouts); + window.connect_close_request(move |_| { + let _ = popouts.borrow_mut()[0].take(); + glib::Propagation::Proceed + }); + } + { + let mut slot = popouts.borrow_mut(); + slot[0] = Some(PopoutWindowHandle { + window, + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), + picture: gtk::Picture::new(), + status_label: gtk::Label::new(None), + binding: PreviewBinding::test_stub(), + }); + } + + dock_all_displays_to_preview(&state, &child_proc, &popouts, &view.widgets); + + assert!(popouts.borrow().iter().all(|handle| handle.is_none())); + assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); +} + +#[gtk::test] +#[serial] +fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() { + if gtk::gdk::Display::default().is_none() { + return; + } + + let app = gtk::Application::builder() + .application_id("dev.lesavka.test-shutdown") + .build(); + let _ = app.register(None::<>k::gio::Cancellable>); + + let state = Rc::new(RefCell::new(LauncherState::new())); + let state_snapshot = state.borrow().clone(); + let view = build_launcher_view( + &app, + "http://127.0.0.1:50051", + &DeviceCatalog::default(), + &state_snapshot, + ); + let child_proc = Rc::new(RefCell::new(None::)); + let tests = Rc::new(RefCell::new(DeviceTestController::new())); + + let left_binding = PreviewBinding::test_stub(); + let right_binding = PreviewBinding::test_stub(); + *view.widgets.display_panes[0].preview_binding.borrow_mut() = Some(left_binding.clone()); + *view.widgets.display_panes[1].preview_binding.borrow_mut() = Some(right_binding.clone()); + + { + let mut popouts = view.popouts.borrow_mut(); + popouts[0] = Some(PopoutWindowHandle { + window: gtk::ApplicationWindow::builder() + .application(&app) + .title("Left") + .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), + picture: gtk::Picture::new(), + status_label: gtk::Label::new(None), + binding: PreviewBinding::test_stub(), + }); + } + + *view.diagnostics_popout.borrow_mut() = Some( + gtk::ApplicationWindow::builder() + .application(&app) + .title("Diagnostics") + .build(), + ); + *view.log_popout.borrow_mut() = Some( + gtk::ApplicationWindow::builder() + .application(&app) + .title("Log") + .build(), + ); + + shutdown_launcher_runtime( + &child_proc, + &tests, + None, + &view.widgets, + &view.popouts, + &view.diagnostics_popout, + &view.log_popout, + ); + + assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); + assert!( + view.widgets.display_panes[0] + .preview_binding + .borrow() + .is_none() + ); + assert!( + view.widgets.display_panes[1] + .preview_binding + .borrow() + .is_none() + ); + assert!(view.diagnostics_popout.borrow().is_none()); + assert!(view.log_popout.borrow().is_none()); +} diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 84c27b7..05606d4 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -44,683 +44,15 @@ use { std::time::{Duration, Instant}, }; +include!("ui/message_and_network_state.rs"); #[cfg(not(coverage))] -enum PowerMessage { - Refresh(std::result::Result), - Command(std::result::Result), -} - +include!("ui/control_requests.rs"); #[cfg(not(coverage))] -enum RelayMessage { - Spawned(std::result::Result), -} - +include!("ui/diagnostic_sampling.rs"); #[cfg(not(coverage))] -enum CapsMessage { - Refresh(HandshakeProbe), -} - +include!("ui/preview_profiles.rs"); #[cfg(not(coverage))] -enum ClipboardMessage { - Finished(std::result::Result), -} - -#[cfg(not(coverage))] -const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8); - -#[cfg(not(coverage))] -fn usb_audio_kernel_support_missing() -> bool { - Command::new("modinfo") - .arg("snd_usb_audio") - .status() - .map(|status| !status.success()) - .unwrap_or(true) -} - -#[cfg(not(coverage))] -#[derive(Default)] -struct NetworkTelemetry { - rtt_samples: VecDeque<(Instant, f32)>, - failures: VecDeque, -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy, Debug, Default)] -struct NetworkSnapshot { - rtt_ms: f32, - probe_spread_ms: f32, - probe_loss_pct: f32, -} - -#[cfg(not(coverage))] -impl NetworkTelemetry { - fn record(&mut self, probe: &HandshakeProbe) { - let now = Instant::now(); - self.trim(now); - if let Some(rtt_ms) = probe.rtt_ms { - self.rtt_samples.push_back((now, rtt_ms)); - } else { - self.failures.push_back(now); - } - self.trim(now); - } - - fn snapshot(&mut self) -> NetworkSnapshot { - let now = Instant::now(); - self.trim(now); - let rtt_ms = self.rtt_samples.back().map(|(_, rtt)| *rtt).unwrap_or(0.0); - let probe_spread_ms = network_spread_ms(&self.rtt_samples); - let probe_count = self.rtt_samples.len() + self.failures.len(); - let probe_loss_pct = if probe_count == 0 { - 0.0 - } else { - self.failures.len() as f32 * 100.0 / probe_count as f32 - }; - NetworkSnapshot { - rtt_ms, - probe_spread_ms, - probe_loss_pct, - } - } - - fn trim(&mut self, now: Instant) { - while let Some((oldest, _)) = self.rtt_samples.front().copied() { - if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW { - let _ = self.rtt_samples.pop_front(); - } else { - break; - } - } - while let Some(oldest) = self.failures.front().copied() { - if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW { - let _ = self.failures.pop_front(); - } else { - break; - } - } - } -} - -#[cfg(not(coverage))] -fn retained_stage_selection(current: Option<&str>, values: &[String]) -> Option { - current - .filter(|selected| values.iter().any(|value| value == *selected)) - .map(str::to_string) - .or_else(|| values.first().cloned()) -} - -#[cfg(not(coverage))] -fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option { - current - .filter(|selected| values.iter().any(|value| value == *selected)) - .map(str::to_string) -} - -#[cfg(not(coverage))] -fn selected_camera_quality(combo: >k::ComboBoxText) -> Option { - combo.active_id().as_deref().and_then(CameraMode::from_id) -} - -#[cfg(not(coverage))] -fn sync_camera_quality_selection( - combo: >k::ComboBoxText, - state: &mut LauncherState, - catalog: &DeviceCatalog, -) { - state.normalize_camera_quality(catalog); - sync_camera_quality_combo( - combo, - &state.camera_quality_options(catalog), - state.camera_quality, - ); -} - -#[cfg(not(coverage))] -fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { - if samples.len() < 2 { - return 0.0; - } - let mut values = samples.iter().map(|(_, value)| *value).collect::>(); - values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let median = values[values.len() / 2]; - let mut deviations = values - .into_iter() - .map(|value| (value - median).abs()) - .collect::>(); - deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - deviations[deviations.len() / 2] -} - -#[cfg(not(coverage))] -/// Apply a remote-audio gain slider update without unwinding through GTK callbacks. -fn apply_audio_gain_change( - scale: >k::Scale, - state: &Rc>, - widgets: &super::ui_components::LauncherWidgets, - child_proc: &Rc>>, -) -> bool { - let percent = scale - .value() - .round() - .clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) as u32; - let label = { - let Ok(mut state) = state.try_borrow_mut() else { - return false; - }; - if state.audio_gain_percent == percent { - widgets.audio_gain_value.set_text(&state.audio_gain_label()); - return true; - } - state.set_audio_gain_percent(percent); - state.audio_gain_label() - }; - widgets.audio_gain_value.set_text(&label); - let relay_live = child_proc - .try_borrow() - .map(|child| child.is_some()) - .unwrap_or(false); - if relay_live { - let path = audio_gain_control_path(); - match write_audio_gain_request(&path, percent) { - Ok(()) => widgets - .status_label - .set_text(&format!("Remote audio gain set to {label}.")), - Err(err) => widgets.status_label.set_text(&format!( - "Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}" - )), - } - } else { - widgets.status_label.set_text(&format!( - "Remote audio gain set to {label} for the next relay launch." - )); - } - true -} - -#[cfg(not(coverage))] -/// Apply a microphone uplink gain slider update without unwinding through GTK callbacks. -fn apply_mic_gain_change( - scale: >k::Scale, - state: &Rc>, - widgets: &super::ui_components::LauncherWidgets, - child_proc: &Rc>>, -) -> bool { - let percent = scale - .value() - .round() - .clamp(0.0, MAX_MIC_GAIN_PERCENT as f64) as u32; - let label = { - let Ok(mut state) = state.try_borrow_mut() else { - return false; - }; - if state.mic_gain_percent == percent { - widgets.mic_gain_value.set_text(&state.mic_gain_label()); - return true; - } - state.set_mic_gain_percent(percent); - state.mic_gain_label() - }; - widgets.mic_gain_value.set_text(&label); - let relay_live = child_proc - .try_borrow() - .map(|child| child.is_some()) - .unwrap_or(false); - if relay_live { - let path = mic_gain_control_path(); - match write_mic_gain_request(&path, percent) { - Ok(()) => widgets.status_label.set_text(&format!("Mic gain set to {label}.")), - Err(err) => widgets.status_label.set_text(&format!( - "Mic gain set to {label} for the next relay launch, but live gain control could not be written: {err}" - )), - } - } else { - widgets.status_label.set_text(&format!( - "Mic gain set to {label} for the next relay launch." - )); - } - true -} - -#[cfg(not(coverage))] -fn request_capture_power_refresh( - power_tx: std::sync::mpsc::Sender, - server_addr: String, - delay: Duration, -) { - std::thread::spawn(move || { - if !delay.is_zero() { - std::thread::sleep(delay); - } - let result = fetch_capture_power(&server_addr).map_err(|err| err.to_string()); - let _ = power_tx.send(PowerMessage::Refresh(result)); - }); -} - -#[cfg(not(coverage))] -fn request_capture_power_command( - power_tx: std::sync::mpsc::Sender, - server_addr: String, - command: CapturePowerCommand, -) { - std::thread::spawn(move || { - let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string()); - let _ = power_tx.send(PowerMessage::Command(result)); - }); -} - -#[cfg(not(coverage))] -fn request_handshake_caps( - caps_tx: std::sync::mpsc::Sender, - server_addr: String, - delay: Duration, -) { - std::thread::spawn(move || { - if !delay.is_zero() { - std::thread::sleep(delay); - } - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build(); - let probe = match runtime { - Ok(runtime) => runtime.block_on(probe(&server_addr)), - Err(_) => HandshakeProbe::default(), - }; - let _ = caps_tx.send(CapsMessage::Refresh(probe)); - }); -} - -#[cfg(not(coverage))] -fn unavailable_capture_power(detail: String) -> CapturePowerStatus { - CapturePowerStatus { - available: false, - enabled: false, - unit: "relay.service".to_string(), - detail, - active_leases: 0, - mode: "auto".to_string(), - detected_devices: 0, - } -} - -#[cfg(not(coverage))] -fn refresh_eye_feed_controls( - widgets: &super::ui_components::LauncherWidgets, - state: &LauncherState, -) { - for monitor_id in 0..2 { - super::ui_components::sync_feed_source_combo( - &widgets.display_panes[monitor_id].feed_source_combo, - state.feed_source_options(monitor_id), - state.feed_source_preset(monitor_id), - ); - if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off { - let choice = state - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.capture_size_choice(monitor_id)); - if state.feed_source_preset(monitor_id) == FeedSourcePreset::ThisEye { - super::ui_components::sync_capture_resolution_combo( - &widgets.display_panes[monitor_id].capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(monitor_id), - ); - } else { - super::ui_components::sync_capture_resolution_locked( - &widgets.display_panes[monitor_id].capture_resolution_combo, - state.capture_size_options(), - choice.preset, - ); - } - } else { - super::ui_components::sync_capture_resolution_disabled( - &widgets.display_panes[monitor_id].capture_resolution_combo, - ); - } - super::ui_components::sync_breakout_size_combo( - &widgets.display_panes[monitor_id].breakout_combo, - state.breakout_size_options(monitor_id), - state.breakout_size_preset(monitor_id), - ); - refresh_preview_frame_ratio(widgets, monitor_id, state); - } -} - -#[cfg(not(coverage))] -fn refresh_preview_frame_ratio( - widgets: &super::ui_components::LauncherWidgets, - monitor_id: usize, - state: &LauncherState, -) { - let capture = state - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.capture_size_choice(monitor_id)); - widgets.display_panes[monitor_id] - .preview_frame - .set_ratio(capture.preset.display_aspect_ratio()); -} - -#[cfg(not(coverage))] -fn eye_caps_changed(state: &LauncherState, caps: &crate::handshake::PeerCaps) -> bool { - let next_width = caps.eye_width.unwrap_or(state.preview_source.width); - let next_height = caps.eye_height.unwrap_or(state.preview_source.height); - let next_fps = caps.eye_fps.unwrap_or(state.preview_source.fps); - state.preview_source.width != next_width - || state.preview_source.height != next_height - || state.preview_source.fps != next_fps -} - -#[cfg(not(coverage))] -fn record_diagnostics_sample( - widgets: &super::ui_components::LauncherWidgets, - state: &LauncherState, - preview: Option<&super::preview::LauncherPreview>, - network: NetworkSnapshot, - client_process_cpu_pct: f32, -) { - let left_metrics = preview - .and_then(|preview| { - (state.feed_source_preset(0) != FeedSourcePreset::Off).then_some( - preview.snapshot_metrics( - 0, - match state.display_surface(0) { - DisplaySurface::Preview => super::preview::PreviewSurface::Inline, - DisplaySurface::Window => super::preview::PreviewSurface::Window, - }, - ), - ) - }) - .flatten() - .unwrap_or_default(); - let right_metrics = preview - .and_then(|preview| { - (state.feed_source_preset(1) != FeedSourcePreset::Off).then_some( - preview.snapshot_metrics( - 1, - match state.display_surface(1) { - DisplaySurface::Preview => super::preview::PreviewSurface::Inline, - DisplaySurface::Window => super::preview::PreviewSurface::Window, - }, - ), - ) - }) - .flatten() - .unwrap_or_default(); - - widgets - .diagnostics_log - .borrow_mut() - .record(PerformanceSample { - rtt_ms: network.rtt_ms, - probe_spread_ms: network.probe_spread_ms, - input_latency_ms: network.rtt_ms * 0.5, - probe_loss_pct: network.probe_loss_pct, - client_process_cpu_pct, - server_process_cpu_pct: left_metrics - .server_process_cpu_pct - .max(right_metrics.server_process_cpu_pct), - video_loss_pct: left_metrics - .packet_loss_pct - .max(right_metrics.packet_loss_pct), - left_receive_fps: left_metrics.receive_fps, - left_present_fps: left_metrics.present_fps, - left_server_fps: left_metrics.server_fps, - left_stream_spread_ms: left_metrics.stream_spread_ms, - left_packet_gap_peak_ms: left_metrics.packet_gap_peak_ms, - left_present_gap_peak_ms: left_metrics.present_gap_peak_ms, - left_queue_depth: left_metrics.queue_depth, - left_queue_peak: left_metrics.queue_depth_peak, - left_server_source_gap_peak_ms: left_metrics.server_source_gap_peak_ms, - left_server_send_gap_peak_ms: left_metrics.server_send_gap_peak_ms, - left_server_queue_peak: left_metrics.server_queue_peak, - left_server_encoder_label: left_metrics.server_encoder_label.clone(), - left_decoder_label: left_metrics.decoder_label.clone(), - left_stream_caps_label: left_metrics.stream_caps_label.clone(), - left_decoded_caps_label: left_metrics.decoded_caps_label.clone(), - left_rendered_caps_label: left_metrics.rendered_caps_label.clone(), - right_receive_fps: right_metrics.receive_fps, - right_present_fps: right_metrics.present_fps, - right_server_fps: right_metrics.server_fps, - right_stream_spread_ms: right_metrics.stream_spread_ms, - right_packet_gap_peak_ms: right_metrics.packet_gap_peak_ms, - right_present_gap_peak_ms: right_metrics.present_gap_peak_ms, - right_queue_depth: right_metrics.queue_depth, - right_queue_peak: right_metrics.queue_depth_peak, - right_server_source_gap_peak_ms: right_metrics.server_source_gap_peak_ms, - right_server_send_gap_peak_ms: right_metrics.server_send_gap_peak_ms, - right_server_queue_peak: right_metrics.server_queue_peak, - right_server_encoder_label: right_metrics.server_encoder_label.clone(), - right_decoder_label: right_metrics.decoder_label.clone(), - right_stream_caps_label: right_metrics.stream_caps_label.clone(), - right_decoded_caps_label: right_metrics.decoded_caps_label.clone(), - right_rendered_caps_label: right_metrics.rendered_caps_label.clone(), - dropped_frames: left_metrics - .dropped_frames - .saturating_add(right_metrics.dropped_frames), - queue_depth: left_metrics.queue_depth.max(right_metrics.queue_depth), - }); -} - -#[cfg(not(coverage))] -fn largest_monitor_size() -> (u32, u32) { - let (width, height) = enumerate_monitors() - .into_iter() - .max_by_key(|monitor| { - effective_monitor_width(&monitor) as u64 * effective_monitor_height(&monitor) as u64 - }) - .map(|monitor| { - ( - effective_monitor_width(&monitor), - effective_monitor_height(&monitor), - ) - }) - .unwrap_or((1920, 1080)); - (width.max(2), height.max(2)) -} - -#[cfg(not(coverage))] -fn largest_monitor_physical_size() -> (u32, u32) { - if let Some((width, height)) = probe_kscreen_display_size() { - return (width, height); - } - normalize_breakout_limit(largest_monitor_size().0, largest_monitor_size().1) -} - -#[cfg(not(coverage))] -fn probe_kscreen_display_size() -> Option<(u32, u32)> { - let output = Command::new("kscreen-doctor").arg("-o").output().ok()?; - if !output.status.success() { - return None; - } - let text = String::from_utf8(output.stdout).ok()?; - let mut best = None; - for line in text.lines() { - if !line.contains("Modes:") { - continue; - } - let active = line - .split_whitespace() - .find(|token| token.contains('*') && token.contains('x'))?; - let dims = active - .trim_matches(|ch: char| ch == '*' || ch == '!') - .split('@') - .next()?; - let (width, height) = dims.split_once('x')?; - let width = width.parse::().ok()?; - let height = height.parse::().ok()?; - if best - .map(|(best_w, best_h)| width as u64 * height as u64 > best_w as u64 * best_h as u64) - .unwrap_or(true) - { - best = Some((width, height)); - } - } - best -} - -#[cfg(not(coverage))] -fn effective_monitor_width(monitor: &crate::output::display::MonitorInfo) -> u32 { - let scale = monitor.scale_factor.max(1) as u32; - (monitor.geometry.width().max(1) as u32).saturating_mul(scale) -} - -#[cfg(not(coverage))] -fn effective_monitor_height(monitor: &crate::output::display::MonitorInfo) -> u32 { - let scale = monitor.scale_factor.max(1) as u32; - (monitor.geometry.height().max(1) as u32).saturating_mul(scale) -} - -#[cfg(not(coverage))] -fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) { - const STANDARD_SIZES: &[(u32, u32)] = &[ - (3840, 2160), - (2560, 1440), - (1920, 1080), - (1600, 900), - (1366, 768), - (1280, 720), - (960, 540), - ]; - - STANDARD_SIZES - .iter() - .copied() - .find(|(candidate_w, candidate_h)| *candidate_w <= width && *candidate_h <= height) - .unwrap_or((width.max(2), height.max(2))) -} - -#[cfg(not(coverage))] -fn launcher_default_size(width: u32, height: u32) -> (i32, i32) { - let max_width = width.saturating_sub(48).max(640) as i32; - let max_height = height.saturating_sub(72).max(520) as i32; - (1380.min(max_width), 860.min(max_height)) -} - -#[cfg(not(coverage))] -fn rebind_inline_preview( - preview: &super::preview::LauncherPreview, - widgets: &super::ui_components::LauncherWidgets, - state: &LauncherState, - monitor_id: usize, -) { - if let Some(binding) = widgets.display_panes[monitor_id] - .preview_binding - .borrow_mut() - .take() - { - binding.close(); - } - if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { - widgets.display_panes[monitor_id] - .picture - .set_paintable(Option::<>k::gdk::Paintable>::None); - widgets.display_panes[monitor_id] - .stream_status - .set_text("Feed disabled."); - return; - } - let binding = preview.install_on_picture( - monitor_id, - super::preview::PreviewSurface::Inline, - &widgets.display_panes[monitor_id].picture, - &widgets.display_panes[monitor_id].stream_status, - ); - *widgets.display_panes[monitor_id] - .preview_binding - .borrow_mut() = binding; -} - -#[cfg(not(coverage))] -fn rebind_popout_preview( - preview: &super::preview::LauncherPreview, - popouts: &Rc; 2]>>, - state: &LauncherState, - monitor_id: usize, -) { - let mut popouts = popouts.borrow_mut(); - let Some(handle) = popouts.get_mut(monitor_id).and_then(|slot| slot.as_mut()) else { - return; - }; - handle.binding.close(); - if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { - handle - .picture - .set_paintable(Option::<>k::gdk::Paintable>::None); - handle.status_label.set_text("Feed disabled."); - return; - } - if let Some(binding) = preview.install_on_picture( - monitor_id, - super::preview::PreviewSurface::Window, - &handle.picture, - &handle.status_label, - ) { - handle.binding = binding; - } - let capture = state - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.capture_size_choice(monitor_id)); - handle - .frame - .set_ratio(capture.preset.display_aspect_ratio()); -} - -#[cfg(not(coverage))] -fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) { - for monitor_id in 0..2 { - let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off; - preview.set_monitor_enabled(monitor_id, enabled); - let capture = state - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.capture_size_choice(monitor_id)); - let source_monitor_id = state - .resolved_feed_monitor_id(monitor_id) - .unwrap_or(monitor_id); - let breakout = state.breakout_size_choice(monitor_id); - preview.set_capture_profile( - monitor_id, - source_monitor_id, - capture.width, - capture.height, - capture.fps, - capture.max_bitrate_kbit, - ); - preview.set_breakout_profile(monitor_id, breakout.width, breakout.height); - } -} - -#[cfg(not(coverage))] -fn sync_preview_profiles( - preview: &super::preview::LauncherPreview, - widgets: &super::ui_components::LauncherWidgets, - popouts: &Rc; 2]>>, - state: &LauncherState, -) { - apply_preview_profiles(preview, state); - for monitor_id in 0..2 { - rebind_inline_preview(preview, widgets, state, monitor_id); - rebind_popout_preview(preview, popouts, state, monitor_id); - } -} - -#[cfg(not(coverage))] -fn disconnected_capture_note(mode: &str) -> &'static str { - match mode { - "forced-on" => "Relay disconnected. Capture is still forced on for staging.", - "forced-off" => { - "Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On." - } - _ => { - "Relay disconnected. The server will hold capture briefly, then let it return to standby." - } - } -} - -/// Keeps remote eye previews tied to a live session while respecting forced-off staging. -fn session_preview_active( - state: &crate::launcher::state::LauncherState, - child_running: bool, -) -> bool { - (child_running || state.remote_active) && state.capture_power.mode != "forced-off" -} +include!("ui/activation_context.rs"); #[cfg(not(coverage))] pub fn run_gui_launcher(server_addr: String) -> Result<()> { @@ -775,1717 +107,51 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let input_toggle_control_path = Rc::clone(&input_toggle_control_path); app.connect_activate(move |app| { - let (display_width, display_height) = largest_monitor_size(); - let (physical_width, physical_height) = largest_monitor_physical_size(); - { - let mut state = state.borrow_mut(); - state.set_breakout_display_size(display_width, display_height); - state.set_breakout_limit_size(physical_width, physical_height); - } - let view = - build_launcher_view(app, server_addr.as_ref(), &catalog.borrow(), &state.borrow()); - let window = view.window.clone(); - let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height); - window.set_default_size(launcher_width, launcher_height); - let server_entry = view.server_entry.clone(); - let camera_combo = view.camera_combo.clone(); - let camera_quality_combo = view.camera_quality_combo.clone(); - let microphone_combo = view.microphone_combo.clone(); - let speaker_combo = view.speaker_combo.clone(); - let keyboard_combo = view.keyboard_combo.clone(); - let mouse_combo = view.mouse_combo.clone(); - let widgets = view.widgets.clone(); - let preview = view.preview.clone(); - let popouts = Rc::clone(&view.popouts); - let diagnostics_popout = Rc::clone(&view.diagnostics_popout); - let log_popout = Rc::clone(&view.log_popout); - let shutdown_cleaned = Rc::new(Cell::new(false)); - let camera_quality_syncing = Rc::new(Cell::new(false)); + let ActivationContext { + window, + server_entry, + camera_combo, + camera_quality_combo, + microphone_combo, + speaker_combo, + keyboard_combo, + mouse_combo, + widgets, + preview, + popouts, + diagnostics_popout, + log_popout, + camera_quality_syncing, + power_tx, + power_rx, + power_request_in_flight, + relay_tx, + relay_rx, + relay_request_in_flight, + caps_tx, + caps_rx, + caps_request_in_flight, + diagnostics_network, + diagnostics_process, + next_power_probe, + next_diagnostics_probe, + next_diagnostics_sample, + preview_session_active, + clipboard_tx, + clipboard_rx, + log_tx, + log_rx, + } = include!("ui/activation_setup.rs"); - { - let shutdown_cleaned = Rc::clone(&shutdown_cleaned); - let app = app.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let preview = preview.clone(); - let widgets = widgets.clone(); - let popouts = Rc::clone(&popouts); - let diagnostics_popout = Rc::clone(&diagnostics_popout); - let log_popout = Rc::clone(&log_popout); - window.connect_close_request(move |_| { - if !shutdown_cleaned.replace(true) { - shutdown_launcher_runtime( - &child_proc, - &tests, - preview.as_deref(), - &widgets, - &popouts, - &diagnostics_popout, - &log_popout, - ); - } - let app = app.clone(); - glib::idle_add_local_once(move || { - app.quit(); - }); - glib::Propagation::Stop - }); - } - - { - let shutdown_cleaned = Rc::clone(&shutdown_cleaned); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let preview = preview.clone(); - let widgets = widgets.clone(); - let popouts = Rc::clone(&popouts); - let diagnostics_popout = Rc::clone(&diagnostics_popout); - let log_popout = Rc::clone(&log_popout); - app.connect_shutdown(move |_| { - if !shutdown_cleaned.replace(true) { - shutdown_launcher_runtime( - &child_proc, - &tests, - preview.as_deref(), - &widgets, - &popouts, - &diagnostics_popout, - &log_popout, - ); - } - }); - } - - { - let mut tests = tests.borrow_mut(); - if let Err(err) = tests.bind_camera_preview( - &view.device_stage.camera_preview, - &view.device_stage.camera_status, - ) { - widgets - .status_label - .set_text(&format!("Camera preview setup failed: {err}")); - } - if let Err(err) = - tests.set_camera_selection(state.borrow().devices.camera.as_deref()) - { - widgets - .status_label - .set_text(&format!("Camera staging setup failed: {err}")); - } - if let Err(err) = tests.set_camera_quality(state.borrow().camera_quality) { - widgets - .status_label - .set_text(&format!("Camera quality staging setup failed: {err}")); - } - } - - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - - let (power_tx, power_rx) = std::sync::mpsc::channel::(); - let power_request_in_flight = Rc::new(Cell::new(false)); - let (relay_tx, relay_rx) = std::sync::mpsc::channel::(); - let relay_request_in_flight = Rc::new(Cell::new(false)); - let (caps_tx, caps_rx) = std::sync::mpsc::channel::(); - let caps_request_in_flight = Rc::new(Cell::new(false)); - let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default())); - let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new())); - let next_power_probe = - Rc::new(Cell::new(Instant::now() + Duration::from_millis(500))); - let next_diagnostics_probe = - Rc::new(Cell::new(Instant::now() + Duration::from_millis(250))); - let next_diagnostics_sample = - Rc::new(Cell::new(Instant::now() + Duration::from_secs(1))); - let preview_session_active = Rc::new(Cell::new(false)); - let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::(); - let (log_tx, log_rx) = std::sync::mpsc::channel::(); - - if let Some(preview) = preview.as_ref() { - preview.set_log_sink(log_tx.clone()); - sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); - } - - { - let state = Rc::clone(&state); - let catalog = Rc::clone(&catalog); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let camera_quality_syncing = Rc::clone(&camera_quality_syncing); - let camera_combo = camera_combo.clone(); - let camera_quality_combo = camera_quality_combo.clone(); - let camera_combo_read = camera_combo.clone(); - camera_combo.connect_changed(move |_| { - let selected = selected_combo_value(&camera_combo_read); - let preview_was_running = - tests.borrow_mut().is_running(DeviceTestKind::Camera); - { - let catalog = catalog.borrow(); - let mut state = state.borrow_mut(); - state.select_camera(selected.clone()); - camera_quality_syncing.set(true); - sync_camera_quality_selection(&camera_quality_combo, &mut state, &catalog); - camera_quality_syncing.set(false); - } - let quality = state.borrow().camera_quality; - if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) { - widgets - .status_label - .set_text(&format!("Camera preview update failed: {err}")); - } else if let Err(err) = tests.borrow_mut().set_camera_quality(quality) { - widgets - .status_label - .set_text(&format!("Camera quality update failed: {err}")); - } else if preview_was_running { - widgets.status_label.set_text(&format!( - "Local camera preview switched to {}{}.", - selected.as_deref().unwrap_or("no camera"), - quality - .map(|mode| format!(" at {}", mode.short_label())) - .unwrap_or_default() - )); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let camera_quality_syncing = Rc::clone(&camera_quality_syncing); - let camera_quality_combo = camera_quality_combo.clone(); - let camera_quality_combo_read = camera_quality_combo.clone(); - camera_quality_combo.connect_changed(move |_| { - if camera_quality_syncing.get() { - return; - } - let selected = selected_camera_quality(&camera_quality_combo_read); - let preview_was_running = - tests.borrow_mut().is_running(DeviceTestKind::Camera); - let Ok(mut state_mut) = state.try_borrow_mut() else { - return; - }; - state_mut.select_camera_quality(selected); - drop(state_mut); - if let Err(err) = tests.borrow_mut().set_camera_quality(selected) { - widgets - .status_label - .set_text(&format!("Camera quality update failed: {err}")); - } else if preview_was_running { - widgets.status_label.set_text(&format!( - "Local camera preview switched to {}.", - selected - .map(CameraMode::short_label) - .unwrap_or_else(|| "default quality".to_string()) - )); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let keyboard_combo = keyboard_combo.clone(); - let keyboard_combo_read = keyboard_combo.clone(); - keyboard_combo.connect_changed(move |_| { - let selected = selected_combo_value(&keyboard_combo_read); - state.borrow_mut().select_keyboard(selected.clone()); - let message = match selected.as_deref() { - Some(path) => { - format!("The next relay launch will listen only to keyboard {path}.") - } - None => "The next relay launch will listen to all keyboards.".to_string(), - }; - widgets.status_label.set_text(&message); - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let mouse_combo = mouse_combo.clone(); - let mouse_combo_read = mouse_combo.clone(); - mouse_combo.connect_changed(move |_| { - let selected = selected_combo_value(&mouse_combo_read); - state.borrow_mut().select_mouse(selected.clone()); - let message = match selected.as_deref() { - Some(path) => { - format!("The next relay launch will listen only to pointer {path}.") - } - None => { - "The next relay launch will listen to all pointer devices." - .to_string() - } - }; - widgets.status_label.set_text(&message); - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - }); - } - - if let Some(preview) = preview.as_ref() { - preview.set_session_active(false); - } - request_capture_power_refresh( - power_tx.clone(), - selected_server_addr(&server_entry, server_addr.as_ref()), - Duration::ZERO, - ); - caps_request_in_flight.set(true); - request_handshake_caps( - caps_tx.clone(), - selected_server_addr(&server_entry, server_addr.as_ref()), - Duration::ZERO, - ); - - { - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let server_entry = server_entry.clone(); - let server_entry_read = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let preview = preview.clone(); - let power_tx = power_tx.clone(); - let caps_tx = caps_tx.clone(); - let caps_request_in_flight = Rc::clone(&caps_request_in_flight); - server_entry.connect_changed(move |_| { - let server_addr = - selected_server_addr(&server_entry_read, server_addr_fallback.as_ref()); - { - let mut state = state.borrow_mut(); - state.set_server_available(false); - state.set_server_version(None); - } - if let Some(preview) = preview.as_ref() { - preview.set_server_addr(server_addr.clone()); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - request_capture_power_refresh( - power_tx.clone(), - server_addr.clone(), - Duration::from_millis(150), - ); - caps_request_in_flight.set(true); - request_handshake_caps(caps_tx.clone(), server_addr, Duration::from_millis(150)); - }); - } - - for monitor_id in 0..2 { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let popouts = Rc::clone(&popouts); - let child_proc = Rc::clone(&child_proc); - let preview = preview.clone(); - let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone(); - feed_source_combo.connect_changed(move |combo| { - let Some(active_id) = combo.active_id() else { - return; - }; - let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else { - return; - }; - if state.borrow().feed_source_preset(monitor_id) == preset { - return; - } - { - let mut state = state.borrow_mut(); - state.set_feed_source_preset(monitor_id, preset); - } - if let Some(preview) = preview.as_ref() { - sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - }); - } - - for monitor_id in 0..2 { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let popouts = Rc::clone(&popouts); - let child_proc = Rc::clone(&child_proc); - let preview = preview.clone(); - let resolution_combo = - widgets.display_panes[monitor_id].capture_resolution_combo.clone(); - resolution_combo.connect_changed(move |combo| { - let Some(active_id) = combo.active_id() else { - return; - }; - let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else { - return; - }; - if state.borrow().feed_source_preset(monitor_id) != FeedSourcePreset::ThisEye { - return; - } - if state.borrow().capture_size_preset(monitor_id) == preset { - return; - } - { - let mut state = state.borrow_mut(); - state.set_capture_size_preset(monitor_id, preset); - } - if let Some(preview) = preview.as_ref() { - let choice = state - .borrow() - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.borrow().capture_size_choice(monitor_id)); - let source_monitor_id = state - .borrow() - .resolved_feed_monitor_id(monitor_id) - .unwrap_or(monitor_id); - preview.set_capture_profile( - monitor_id, - source_monitor_id, - choice.width, - choice.height, - choice.fps, - choice.max_bitrate_kbit, - ); - sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - }); - } - - for monitor_id in 0..2 { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let popouts = Rc::clone(&popouts); - let child_proc = Rc::clone(&child_proc); - let preview = preview.clone(); - let breakout_combo = widgets.display_panes[monitor_id].breakout_combo.clone(); - breakout_combo.connect_changed(move |combo| { - let Some(active_id) = combo.active_id() else { - return; - }; - let Some(preset) = BreakoutSizePreset::from_id(active_id.as_str()) else { - return; - }; - if state.borrow().breakout_size_preset(monitor_id) == preset { - return; - } - { - let mut state = state.borrow_mut(); - state.set_breakout_size_preset(monitor_id, preset); - } - let size = state.borrow().breakout_size_choice(monitor_id); - if let Some(preview) = preview.as_ref() { - preview.set_breakout_profile(monitor_id, size.width, size.height); - } - let popout_open = { - popouts - .borrow() - .get(monitor_id) - .and_then(|slot| slot.as_ref()) - .is_some() - }; - if popout_open { - if let Some(preview) = preview.as_ref() { - rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id); - } - if let Some(handle) = popouts - .borrow() - .get(monitor_id) - .and_then(|slot| slot.as_ref()) - { - let display_limit = state.borrow().breakout_display_size(); - apply_popout_window_size(handle, size, display_limit); - } - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let microphone_combo = microphone_combo.clone(); - let microphone_combo_read = microphone_combo.clone(); - microphone_combo.connect_changed(move |_| { - state - .borrow_mut() - .select_microphone(selected_combo_value(µphone_combo_read)); - if tests.borrow_mut().is_running(DeviceTestKind::Microphone) { - widgets.status_label.set_text( - "Microphone selection changed. Restart Monitor Mic to audition the new input.", - ); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let speaker_combo = speaker_combo.clone(); - let speaker_combo_read = speaker_combo.clone(); - speaker_combo.connect_changed(move |_| { - state - .borrow_mut() - .select_speaker(selected_combo_value(&speaker_combo_read)); - let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker); - let microphone_running = - tests.borrow_mut().is_running(DeviceTestKind::Microphone); - if speaker_running || microphone_running { - widgets.status_label.set_text( - "Speaker selection changed. Restart the local audio tests to hear the new output.", - ); - } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let audio_gain_scale = widgets.audio_gain_scale.clone(); - audio_gain_scale.connect_value_changed(move |scale| { - if !apply_audio_gain_change(scale, &state, &widgets, &child_proc) { - let scale = scale.clone(); - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - glib::idle_add_local_once(move || { - let _ = apply_audio_gain_change(&scale, &state, &widgets, &child_proc); - }); - } - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let mic_gain_scale = widgets.mic_gain_scale.clone(); - mic_gain_scale.connect_value_changed(move |scale| { - if !apply_mic_gain_change(scale, &state, &widgets, &child_proc) { - let scale = scale.clone(); - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - glib::idle_add_local_once(move || { - let _ = apply_mic_gain_change(&scale, &state, &widgets, &child_proc); - }); - } - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let toggle = widgets.camera_channel_toggle.clone(); - toggle.connect_toggled(move |toggle| { - if let Ok(mut state) = state.try_borrow_mut() { - state.set_camera_channel_enabled(toggle.is_active()); - } - if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { - refresh_launcher_ui( - &widgets, - &state_snapshot, - child_proc.borrow().is_some(), - ); - } - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let toggle = widgets.microphone_channel_toggle.clone(); - toggle.connect_toggled(move |toggle| { - if let Ok(mut state) = state.try_borrow_mut() { - state.set_microphone_channel_enabled(toggle.is_active()); - } - if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { - refresh_launcher_ui( - &widgets, - &state_snapshot, - child_proc.borrow().is_some(), - ); - } - }); - } - - { - let state = Rc::clone(&state); - let widgets = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let toggle = widgets.audio_channel_toggle.clone(); - toggle.connect_toggled(move |toggle| { - if let Ok(mut state) = state.try_borrow_mut() { - state.set_audio_channel_enabled(toggle.is_active()); - } - if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { - refresh_launcher_ui( - &widgets, - &state_snapshot, - child_proc.borrow().is_some(), - ); - } - }); - } - - { - let state = Rc::clone(&state); - let catalog_state = Rc::clone(&catalog); - let widgets = widgets.clone(); - let widgets_handle = widgets.clone(); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let camera_combo = camera_combo.clone(); - let camera_quality_combo = camera_quality_combo.clone(); - let microphone_combo = microphone_combo.clone(); - let speaker_combo = speaker_combo.clone(); - let keyboard_combo = keyboard_combo.clone(); - let mouse_combo = mouse_combo.clone(); - let camera_quality_syncing = Rc::clone(&camera_quality_syncing); - widgets.device_refresh_button.connect_clicked(move |_| { - let fresh_catalog = DeviceCatalog::discover(); - let ( - selected_camera, - selected_microphone, - selected_speaker, - selected_keyboard, - selected_mouse, - ) = { - let state = state.borrow(); - ( - retained_stage_selection( - state.devices.camera.as_deref(), - &fresh_catalog.cameras, - ), - retained_stage_selection( - state.devices.microphone.as_deref(), - &fresh_catalog.microphones, - ), - retained_stage_selection( - state.devices.speaker.as_deref(), - &fresh_catalog.speakers, - ), - retained_input_selection( - state.devices.keyboard.as_deref(), - &fresh_catalog.keyboards, - ), - retained_input_selection( - state.devices.mouse.as_deref(), - &fresh_catalog.mice, - ), - ) - }; - { - let mut state = state.borrow_mut(); - state.select_camera(selected_camera); - camera_quality_syncing.set(true); - sync_camera_quality_selection( - &camera_quality_combo, - &mut state, - &fresh_catalog, - ); - camera_quality_syncing.set(false); - state.select_microphone(selected_microphone); - state.select_speaker(selected_speaker); - state.select_keyboard(selected_keyboard); - state.select_mouse(selected_mouse); - } - *catalog_state.borrow_mut() = fresh_catalog.clone(); - let state_snapshot = state.borrow().clone(); - sync_stage_device_combo( - &camera_combo, - &fresh_catalog.cameras, - state_snapshot.devices.camera.as_deref(), - ); - sync_stage_device_combo( - µphone_combo, - &fresh_catalog.microphones, - state_snapshot.devices.microphone.as_deref(), - ); - sync_stage_device_combo( - &speaker_combo, - &fresh_catalog.speakers, - state_snapshot.devices.speaker.as_deref(), - ); - sync_input_device_combo( - &keyboard_combo, - &fresh_catalog.keyboards, - state_snapshot.devices.keyboard.as_deref(), - "all keyboards", - ); - sync_input_device_combo( - &mouse_combo, - &fresh_catalog.mice, - state_snapshot.devices.mouse.as_deref(), - "all mice", - ); - if let Err(err) = tests - .borrow_mut() - .set_camera_selection(state_snapshot.devices.camera.as_deref()) - { - widgets_handle - .status_label - .set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}")); - } else if let Err(err) = - tests.borrow_mut().set_camera_quality(state_snapshot.camera_quality) - { - widgets_handle.status_label.set_text(&format!( - "Device refresh succeeded, but the webcam quality test could not switch cleanly: {err}" - )); - } else { - let message = if usb_audio_kernel_support_missing() { - "Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker." - } else { - "Device staging refreshed. Newly attached devices are ready for local tests; all-keyboard/all-mouse relay sessions will pick up new input devices automatically." - }; - widgets_handle.status_label.set_text(message); - } - refresh_launcher_ui( - &widgets_handle, - &state.borrow(), - child_proc.borrow().is_some(), - ); - refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut()); - }); - } - - { - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let tests = Rc::clone(&tests); - let widgets = widgets.clone(); - let server_entry = server_entry.clone(); - let camera_combo = camera_combo.clone(); - let camera_quality_combo = camera_quality_combo.clone(); - let microphone_combo = microphone_combo.clone(); - let speaker_combo = speaker_combo.clone(); - let input_control_path = Rc::clone(&input_control_path); - let input_state_path = Rc::clone(&input_state_path); - let input_toggle_control_path = Rc::clone(&input_toggle_control_path); - let server_addr_fallback = Rc::clone(&server_addr); - let preview = preview.clone(); - let power_tx = power_tx.clone(); - let relay_tx = relay_tx.clone(); - let relay_request_in_flight = Rc::clone(&relay_request_in_flight); - let popouts = Rc::clone(&popouts); - let window = window.clone(); - let start_button = widgets.start_button.clone(); - let widgets_handle = widgets.clone(); - start_button.connect_clicked(move |_| { - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - if relay_request_in_flight.get() { - return; - } - if child_proc.borrow().is_some() { - stop_child_process(&child_proc); - let power_mode = { - let mut state = state.borrow_mut(); - let _ = state.stop_remote(); - state.capture_power.mode.clone() - }; - dock_all_displays_to_preview( - &state, - &child_proc, - &popouts, - &widgets_handle, - ); - window.present(); - if let Some(preview) = preview.as_ref() { - preview.set_server_addr(server_addr.clone()); - preview.set_session_active(false); - } - if power_mode != "auto" { - widgets_handle.status_label.set_text( - "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.", - ); - request_capture_power_command( - power_tx.clone(), - server_addr.clone(), - CapturePowerCommand::Auto, - ); - } else { - widgets_handle - .status_label - .set_text(disconnected_capture_note(&power_mode)); - } - request_capture_power_refresh( - power_tx.clone(), - server_addr.clone(), - Duration::from_millis(250), - ); - request_capture_power_refresh( - power_tx.clone(), - server_addr, - Duration::from_secs(31), - ); - refresh_launcher_ui(&widgets_handle, &state.borrow(), false); - return; - } - { - let mut state = state.borrow_mut(); - state.select_camera(selected_combo_value(&camera_combo)); - state.select_camera_quality(selected_camera_quality(&camera_quality_combo)); - state.select_microphone(selected_combo_value(µphone_combo)); - state.select_speaker(selected_combo_value(&speaker_combo)); - state.select_keyboard(selected_combo_value(&keyboard_combo)); - state.select_mouse(selected_combo_value(&mouse_combo)); - } - let _ = std::fs::remove_file(input_control_path.as_path()); - let _ = std::fs::remove_file(input_state_path.as_path()); - let _ = std::fs::remove_file(input_toggle_control_path.as_path()); - let launch_state = state.borrow().clone(); - tests.borrow_mut().stop_local_capture_for_relay(); - refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut()); - let input_toggle_key = launch_state.swap_key.clone(); - let input_control_path = input_control_path.as_ref().clone(); - let input_state_path = input_state_path.as_ref().clone(); - let input_toggle_control_path = input_toggle_control_path.as_ref().clone(); - relay_request_in_flight.set(true); - widgets_handle.status_label.set_text(&format!( - "Connecting relay with {} as the swap key...", - toggle_key_label(&input_toggle_key) - )); - refresh_launcher_ui( - &widgets_handle, - &state.borrow(), - child_proc.borrow().is_some(), - ); - let relay_tx = relay_tx.clone(); - std::thread::spawn(move || { - let result = spawn_client_process( - &server_addr, - &launch_state, - &input_toggle_key, - input_control_path.as_path(), - input_state_path.as_path(), - input_toggle_control_path.as_path(), - ) - .map_err(|err| err.to_string()); - let _ = relay_tx.send(RelayMessage::Spawned(result)); - }); - }); - } - - { - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let input_control_path = Rc::clone(&input_control_path); - let popouts = Rc::clone(&popouts); - let window = window.clone(); - let input_toggle_button = widgets.input_toggle_button.clone(); - let widgets_handle = widgets.clone(); - input_toggle_button.connect_clicked(move |_| { - let next = next_input_routing(state.borrow().routing); - let child_running = child_proc.borrow().is_some(); - if child_running { - if let Err(err) = - write_input_routing_request(input_control_path.as_path(), next) - { - widgets_handle - .status_label - .set_text(&format!("Could not update live input target: {err}")); - refresh_launcher_ui(&widgets_handle, &state.borrow(), true); - return; - } - widgets_handle.status_label.set_text(&format!( - "Input routing switched toward {}.", - routing_name(next) - )); - } else { - widgets_handle.status_label.set_text(&format!( - "Relay will start with {} input ownership.", - routing_name(next) - )); - } - state.borrow_mut().set_routing(next); - refresh_launcher_ui(&widgets_handle, &state.borrow(), child_running); - if matches!(next, InputRouting::Remote) { - present_popout_windows(&popouts); - } else { - window.present(); - } - }); - } - - { - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let swap_key_button = widgets.swap_key_button.clone(); - swap_key_button.connect_clicked(move |_| { - let token = state.borrow_mut().begin_swap_key_binding(); - widgets - .status_label - .set_text("Press a single key within 3 seconds to make it the swap shortcut."); - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - glib::timeout_add_local_once(Duration::from_secs(3), move || { - if state.borrow_mut().cancel_swap_key_binding(token) { - widgets.status_label.set_text( - "Swap-key capture timed out. The previous shortcut is still in place.", - ); - refresh_launcher_ui( - &widgets, - &state.borrow(), - child_proc.borrow().is_some(), - ); - } - }); - }); - } - - { - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let clipboard_tx = clipboard_tx.clone(); - widgets.clipboard_button.connect_clicked(move |_| { - if child_proc.borrow().is_none() { - widgets - .status_label - .set_text("Start the relay before sending clipboard text."); - return; - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - let Some(display) = gtk::gdk::Display::default() else { - widgets - .status_label - .set_text("No desktop clipboard is available in this session."); - return; - }; - widgets - .status_label - .set_text("Reading the local clipboard and preparing remote paste..."); - let clipboard = display.clipboard(); - let clipboard_tx = clipboard_tx.clone(); - clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| { - match result { - Ok(Some(text)) => { - let text = text.trim_end_matches(['\r', '\n']).to_string(); - if text.is_empty() { - let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( - "clipboard is empty".to_string(), - ))); - return; - } - let clipboard_tx = clipboard_tx.clone(); - std::thread::spawn(move || { - let result = send_clipboard_text_to_remote(&server_addr, &text) - .map_err(|err| err.to_string()); - let _ = clipboard_tx - .send(ClipboardMessage::Finished(result)); - }); - } - Ok(None) => { - let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( - "clipboard is empty".to_string(), - ))); - } - Err(err) => { - let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( - format!("clipboard read failed: {err}"), - ))); - } - } - }); - }); - } - - { - let widgets = widgets.clone(); - widgets.probe_button.connect_clicked(move |_| { - if let Some(display) = gtk::gdk::Display::default() { - let clipboard = display.clipboard(); - clipboard.set_text(quality_probe_command()); - widgets - .status_label - .set_text("Quality probe command copied to the local clipboard."); - } else { - widgets - .status_label - .set_text("No desktop clipboard is available in this session."); - } - }); - } - - { - let widgets = widgets.clone(); - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let widgets_for_click = widgets.clone(); - widgets.usb_recover_button.connect_clicked(move |_| { - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets_for_click.status_label.set_text( - "Requesting a forced USB gadget re-enumeration on the relay host...", - ); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = - reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); - let _ = tx.send(result); - }); - let widgets = widgets_for_click.clone(); - glib::timeout_add_local(Duration::from_millis(100), move || { - match rx.try_recv() { - Ok(Ok(())) => { - widgets.status_label.set_text( - "USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.", - ); - glib::ControlFlow::Break - } - Ok(Err(err)) => { - widgets - .status_label - .set_text(&format!("USB gadget recovery failed: {err}")); - glib::ControlFlow::Break - } - Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - widgets.status_label.set_text( - "USB gadget recovery ended unexpectedly before the relay answered.", - ); - glib::ControlFlow::Break - } - } - }); - }); - } - - { - let widgets = widgets.clone(); - widgets.diagnostics_copy_button.connect_clicked(move |_| { - if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) { - widgets - .status_label - .set_text(&format!("Could not copy the diagnostics report: {err}")); - } else { - widgets - .status_label - .set_text("Diagnostics report copied to the local clipboard."); - } - }); - } - - { - let app = app.clone(); - let widgets = widgets.clone(); - let diagnostics_popout = Rc::clone(&diagnostics_popout); - widgets.diagnostics_popout_button.connect_clicked(move |_| { - open_diagnostics_popout( - &app, - &diagnostics_popout, - &widgets.diagnostics_popout_label, - &widgets.diagnostics_popout_scroll, - &widgets.diagnostics_rendered_text, - ); - widgets - .status_label - .set_text("Diagnostics report moved into its own window."); - }); - } - - { - let widgets = widgets.clone(); - widgets.console_level_combo.connect_changed(move |combo| { - let level = combo - .active_id() - .as_deref() - .and_then(ConsoleLogLevel::from_id) - .unwrap_or_default(); - *widgets.session_log_level.borrow_mut() = level; - widgets.status_label.set_text(&format!( - "Console now shows {} relay logs and higher.", - level.label() - )); - }); - } - - { - let widgets = widgets.clone(); - widgets.console_copy_button.connect_clicked(move |_| { - if let Err(err) = copy_session_log(&widgets.session_log_buffer) { - widgets - .status_label - .set_text(&format!("Could not copy the session log: {err}")); - } else { - widgets - .status_label - .set_text("Session log copied to the local clipboard."); - } - }); - } - - { - let app = app.clone(); - let widgets = widgets.clone(); - let log_popout = Rc::clone(&log_popout); - widgets.console_popout_button.connect_clicked(move |_| { - open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer); - widgets - .status_label - .set_text("Session log moved into its own window."); - }); - } - - { - let widgets = widgets.clone(); - let tests = Rc::clone(&tests); - let camera_combo = camera_combo.clone(); - let camera_quality_combo = camera_quality_combo.clone(); - let camera_test_button = widgets.camera_test_button.clone(); - let widgets_handle = widgets.clone(); - camera_test_button.connect_clicked(move |_| { - let selected = selected_combo_value(&camera_combo); - let quality = selected_camera_quality(&camera_quality_combo); - let result = { - let mut tests = tests.borrow_mut(); - let _ = tests.set_camera_selection(selected.as_deref()); - let _ = tests.set_camera_quality(quality); - tests.toggle_camera() - }; - update_test_action_result( - &widgets_handle, - &mut tests.borrow_mut(), - result, - "Camera preview started inside the launcher. This stays local until you start the relay.", - "Camera preview stopped. The selected camera stays staged for the next launch.", - ); - }); - } - - { - let widgets = widgets.clone(); - let tests = Rc::clone(&tests); - let microphone_combo = microphone_combo.clone(); - let speaker_combo = speaker_combo.clone(); - let microphone_test_button = widgets.microphone_test_button.clone(); - let widgets_handle = widgets.clone(); - microphone_test_button.connect_clicked(move |_| { - let mic = selected_combo_value(µphone_combo); - let sink = selected_combo_value(&speaker_combo); - let result = tests - .borrow_mut() - .toggle_microphone(mic.as_deref(), sink.as_deref()); - update_test_action_result( - &widgets_handle, - &mut tests.borrow_mut(), - result, - "Microphone monitor started locally through the selected speaker.", - "Microphone monitor stopped.", - ); - }); - } - - { - let widgets = widgets.clone(); - let tests = Rc::clone(&tests); - let speaker_combo = speaker_combo.clone(); - let microphone_replay_button = widgets.microphone_replay_button.clone(); - let widgets_handle = widgets.clone(); - microphone_replay_button.connect_clicked(move |_| { - let result = tests - .borrow_mut() - .toggle_microphone_replay(selected_combo_value(&speaker_combo).as_deref()); - update_test_action_result( - &widgets_handle, - &mut tests.borrow_mut(), - result, - "Replaying the latest local mic capture through the selected speaker.", - "Mic replay stopped.", - ); - }); - } - - { - let widgets = widgets.clone(); - let tests = Rc::clone(&tests); - let speaker_combo = speaker_combo.clone(); - let speaker_test_button = widgets.speaker_test_button.clone(); - let widgets_handle = widgets.clone(); - speaker_test_button.connect_clicked(move |_| { - let result = tests - .borrow_mut() - .toggle_speaker(selected_combo_value(&speaker_combo).as_deref()); - update_test_action_result( - &widgets_handle, - &mut tests.borrow_mut(), - result, - "Speaker tone started locally.", - "Speaker test tone stopped.", - ); - }); - } - - { - let widgets = widgets.clone(); - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let power_tx = power_tx.clone(); - let power_request_in_flight = Rc::clone(&power_request_in_flight); - let power_auto_button = widgets.power_auto_button.clone(); - let widgets_handle = widgets.clone(); - power_auto_button.connect_clicked(move |_| { - if power_request_in_flight.replace(true) { - return; - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets_handle - .status_label - .set_text("Returning capture feeds to automatic mode..."); - request_capture_power_command( - power_tx.clone(), - server_addr, - CapturePowerCommand::Auto, - ); - }); - } - - { - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let power_tx = power_tx.clone(); - let power_request_in_flight = Rc::clone(&power_request_in_flight); - let power_on_button = widgets.power_on_button.clone(); - let widgets_handle = widgets.clone(); - power_on_button.connect_clicked(move |_| { - if power_request_in_flight.replace(true) { - return; - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets_handle - .status_label - .set_text("Forcing capture feeds on for staging..."); - request_capture_power_command( - power_tx.clone(), - server_addr, - CapturePowerCommand::ForceOn, - ); - }); - } - - { - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let power_tx = power_tx.clone(); - let power_request_in_flight = Rc::clone(&power_request_in_flight); - let power_off_button = widgets.power_off_button.clone(); - let widgets_handle = widgets.clone(); - power_off_button.connect_clicked(move |_| { - if power_request_in_flight.replace(true) { - return; - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets_handle - .status_label - .set_text("Forcing capture feeds off for staging..."); - request_capture_power_command( - power_tx.clone(), - server_addr, - CapturePowerCommand::ForceOff, - ); - }); - } - - for monitor_id in 0..2 { - let app = app.clone(); - let preview = preview.clone(); - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let popouts = Rc::clone(&popouts); - let widgets = widgets.clone(); - let action_button = widgets.display_panes[monitor_id].action_button.clone(); - action_button.connect_clicked(move |_| { - let Some(preview) = preview.as_ref() else { - widgets - .status_label - .set_text("Preview is unavailable for breakout windows."); - return; - }; - let surface = { - let state = state.borrow(); - state.display_surface(monitor_id) - }; - match surface { - DisplaySurface::Preview => { - open_popout_window( - &app, - preview, - &state, - &child_proc, - &popouts, - &widgets, - monitor_id, - ); - widgets.status_label.set_text(&format!( - "{} moved into its own window.", - widgets.display_panes[monitor_id].title - )); - } - DisplaySurface::Window => { - dock_display_to_preview( - &state, - &child_proc, - &popouts, - &widgets, - monitor_id, - ); - widgets.status_label.set_text(&format!( - "{} returned to the launcher preview.", - widgets.display_panes[monitor_id].title - )); - } - } - }); - } - - { - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let input_toggle_control_path = Rc::clone(&input_toggle_control_path); - let key_controller = gtk::EventControllerKey::new(); - key_controller.connect_key_pressed(move |_, key, _, _| { - if !state.borrow().swap_key_binding { - return glib::Propagation::Proceed; - } - - let Some(swap_key) = capture_swap_key(key) else { - widgets.status_label.set_text( - "That key is not a good swap shortcut. Try a letter, digit, function key, or navigation key.", - ); - refresh_launcher_ui( - &widgets, - &state.borrow(), - child_proc.borrow().is_some(), - ); - return glib::Propagation::Stop; - }; - - let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active; - { - let mut state = state.borrow_mut(); - state.complete_swap_key_binding(swap_key.clone()); - } - let status_message = if relay_live { - match write_input_toggle_key_request( - input_toggle_control_path.as_path(), - &swap_key, - ) { - Ok(()) => format!( - "Swap key set to {} and applied to the live relay.", - toggle_key_label(&swap_key) - ), - Err(err) => format!( - "Swap key set to {}, but Lesavka could not push it live: {err}", - toggle_key_label(&swap_key) - ), - } - } else { - format!( - "Swap key set to {}. The next relay launch will use it.", - toggle_key_label(&swap_key) - ) - }; - widgets.status_label.set_text(&status_message); - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); - glib::Propagation::Stop - }); - window.add_controller(key_controller); - } - - { - let window = window.clone(); - let state = Rc::clone(&state); - let child_proc = Rc::clone(&child_proc); - let widgets = widgets.clone(); - let focus_signal_path = Rc::clone(&focus_signal_path); - let input_state_path = Rc::clone(&input_state_path); - let tests = Rc::clone(&tests); - let server_entry = server_entry.clone(); - let server_addr_fallback = Rc::clone(&server_addr); - let last_focus_marker = - Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); - let power_request_in_flight = Rc::clone(&power_request_in_flight); - let relay_request_in_flight = Rc::clone(&relay_request_in_flight); - let preview = preview.clone(); - let power_tx = power_tx.clone(); - let caps_tx = caps_tx.clone(); - let caps_request_in_flight = Rc::clone(&caps_request_in_flight); - let diagnostics_network = Rc::clone(&diagnostics_network); - let diagnostics_process = Rc::clone(&diagnostics_process); - let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe); - let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample); - let preview_session_active = Rc::clone(&preview_session_active); - let log_tx = log_tx.clone(); - let camera_preview_path = uplink_camera_preview_path(); - let mic_level_path = uplink_mic_level_path(); - glib::timeout_add_local(Duration::from_millis(180), move || { - let child_running = reap_exited_child(&child_proc); - if let Some(preview) = preview.as_ref() { - let desired_preview_active = { - let state_snapshot = state.borrow(); - session_preview_active(&state_snapshot, child_running) - }; - if preview_session_active.get() != desired_preview_active { - preview.set_session_active(desired_preview_active); - preview_session_active.set(desired_preview_active); - } - } - if !child_running && state.borrow().remote_active { - let power_mode = { - let mut state = state.borrow_mut(); - let _ = state.stop_remote(); - state.capture_power.mode.clone() - }; - dock_all_displays_to_preview(&state, &child_proc, &popouts, &widgets); - window.present(); - if let Some(preview) = preview.as_ref() { - preview.set_session_active(false); - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - if power_mode != "auto" { - widgets.status_label.set_text( - "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.", - ); - request_capture_power_command( - power_tx.clone(), - server_addr.clone(), - CapturePowerCommand::Auto, - ); - } else { - widgets - .status_label - .set_text(disconnected_capture_note(&power_mode)); - } - request_capture_power_refresh( - power_tx.clone(), - server_addr.clone(), - Duration::from_millis(250), - ); - request_capture_power_refresh( - power_tx.clone(), - server_addr, - Duration::from_secs(31), - ); - } - - if child_running - && let Some(routing) = read_input_routing_state(input_state_path.as_path()) - && routing != state.borrow().routing - { - state.borrow_mut().set_routing(routing); - refresh_launcher_ui(&widgets, &state.borrow(), child_running); - if matches!(routing, InputRouting::Remote) { - present_popout_windows(&popouts); - } else { - window.present(); - } - } - - let next_focus_marker = path_marker(focus_signal_path.as_path()); - let mut last_focus = last_focus_marker.borrow_mut(); - if next_focus_marker > *last_focus { - *last_focus = next_focus_marker; - state.borrow_mut().set_routing(InputRouting::Local); - refresh_launcher_ui(&widgets, &state.borrow(), child_running); - widgets - .status_label - .set_text("Local control restored and the launcher is focused."); - window.present(); - } - - while let Ok(message) = relay_rx.try_recv() { - relay_request_in_flight.set(false); - match message { - RelayMessage::Spawned(Ok(mut child)) => { - attach_child_log_streams(&mut child, log_tx.clone()); - *child_proc.borrow_mut() = Some(child); - { - let mut state = state.borrow_mut(); - state.set_server_available(true); - let _ = state.start_remote(); - } - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - if let Some(preview) = preview.as_ref() { - preview.set_server_addr(server_addr.clone()); - preview.set_session_active(session_preview_active( - &state.borrow(), - child_proc.borrow().is_some(), - )); - } - let routing = routing_name(state.borrow().routing); - let power_mode = state.borrow().capture_power.mode.clone(); - let message = match power_mode.as_str() { - "forced-off" => format!( - "Relay connected with inputs routed to {routing}, but capture is forced off. Return capture to Auto or Force On when you want remote video." - ), - "forced-on" => format!( - "Relay connected with inputs routed to {routing}. Capture is being held awake and the eye previews are coming online." - ), - _ => format!( - "Relay connected with inputs routed to {routing}. The eye previews will come up with the live session." - ), - }; - widgets.status_label.set_text(&message); - if matches!(state.borrow().routing, InputRouting::Remote) { - present_popout_windows(&popouts); - } - request_capture_power_refresh( - power_tx.clone(), - server_addr.clone(), - Duration::from_millis(250), - ); - request_capture_power_refresh( - power_tx.clone(), - server_addr, - Duration::from_millis(1250), - ); - } - RelayMessage::Spawned(Err(err)) => { - state.borrow_mut().set_server_available(false); - if let Some(preview) = preview.as_ref() { - preview.set_session_active(false); - } - widgets - .status_label - .set_text(&format!("Relay start failed: {err}")); - } - } - } - - while let Ok(line) = log_rx.try_recv() { - let level = *widgets.session_log_level.borrow(); - if append_session_log_for_level(&widgets.session_log_buffer, &line, level) - { - let mut end = widgets.session_log_buffer.end_iter(); - widgets - .session_log_view - .scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); - } - } - - while let Ok(message) = power_rx.try_recv() { - power_request_in_flight.set(false); - match message { - PowerMessage::Refresh(Ok(power)) => { - { - let mut state = state.borrow_mut(); - state.set_server_available(true); - state.set_capture_power(power); - } - if let Some(preview) = preview.as_ref() { - let preview_active = { - let state = state.borrow(); - session_preview_active( - &state, - child_proc.borrow().is_some(), - ) - }; - preview.set_session_active(preview_active); - } - } - PowerMessage::Refresh(Err(err)) => { - let relay_live = child_proc.borrow().is_some() - || state.borrow().remote_active; - { - let mut state = state.borrow_mut(); - if relay_live { - state.set_server_available(true); - if !state.capture_power.available { - state.set_capture_power(unavailable_capture_power(err)); - } - } else { - state.set_server_available(false); - state.set_capture_power(unavailable_capture_power(err)); - } - } - if let Some(preview) = preview.as_ref() { - let preview_active = { - let state = state.borrow(); - session_preview_active( - &state, - child_proc.borrow().is_some(), - ) - }; - preview.set_session_active(preview_active); - } - } - PowerMessage::Command(Ok(power)) => { - let mode = power.mode.clone(); - { - let mut state = state.borrow_mut(); - state.set_server_available(true); - state.set_capture_power(power); - } - if let Some(preview) = preview.as_ref() { - let preview_active = { - let state = state.borrow(); - session_preview_active( - &state, - child_proc.borrow().is_some(), - ) - }; - preview.set_session_active(preview_active); - } - widgets.status_label.set_text(match mode.as_str() { - "forced-on" => "Capture feeds forced on. Remote eyes stay awake even if previews or the relay stop.", - "forced-off" => "Capture feeds forced off. Remote eye previews and session video stay dark until you switch back.", - _ => "Capture feeds returned to automatic mode. Live previews and relay demand will wake them as needed.", - }); - } - PowerMessage::Command(Err(err)) => { - let mut state = state.borrow_mut(); - state.set_server_available(false); - state.set_capture_power(unavailable_capture_power(err.clone())); - widgets - .status_label - .set_text(&format!("Capture power update failed: {err}")); - } - } - } - - while let Ok(message) = caps_rx.try_recv() { - caps_request_in_flight.set(false); - match message { - CapsMessage::Refresh(probe_result) => { - diagnostics_network.borrow_mut().record(&probe_result); - let caps = probe_result.caps; - let rebind_preview = eye_caps_changed(&state.borrow(), &caps); - { - let mut state = state.borrow_mut(); - if probe_result.reachable { - state.set_server_available(true); - } else if child_proc.borrow().is_none() { - state.set_server_available(false); - } - state.set_server_version(caps.server_version.clone()); - } - if let (Some(width), Some(height)) = - (caps.eye_width, caps.eye_height) - { - let fps = caps - .eye_fps - .unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps); - { - let mut state = state.borrow_mut(); - state.set_preview_source_profile(width, height, fps); - } - if rebind_preview && let Some(preview) = preview.as_ref() { - sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); - } - refresh_eye_feed_controls(&widgets, &state.borrow()); - } else { - refresh_eye_feed_controls(&widgets, &state.borrow()); - } - } - } - } - - while let Ok(message) = clipboard_rx.try_recv() { - match message { - ClipboardMessage::Finished(Ok(detail)) => { - widgets.status_label.set_text(&format!("✨ {detail}")); - } - ClipboardMessage::Finished(Err(err)) => { - widgets - .status_label - .set_text(&format!("Clipboard send failed: {err}")); - } - } - } - - let now = Instant::now(); - let child_running = child_proc.borrow().is_some(); - - if now >= next_power_probe.get() - && !power_request_in_flight.get() - && (child_running - || state.borrow().capture_power.enabled - || state.borrow().remote_active) - { - power_request_in_flight.set(true); - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - request_capture_power_refresh(power_tx.clone(), server_addr, Duration::ZERO); - next_power_probe.set(now + Duration::from_secs(2)); - } - - if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() { - caps_request_in_flight.set(true); - let server_addr = - selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - request_handshake_caps(caps_tx.clone(), server_addr, Duration::ZERO); - next_diagnostics_probe.set(now + Duration::from_secs(2)); - } - - if now >= next_diagnostics_sample.get() { - let network = diagnostics_network.borrow_mut().snapshot(); - let client_process_cpu_pct = diagnostics_process - .borrow_mut() - .sample_percent() - .unwrap_or(0.0); - record_diagnostics_sample( - &widgets, - &state.borrow(), - preview.as_ref().map(|preview| preview.as_ref()), - network, - client_process_cpu_pct, - ); - next_diagnostics_sample.set(now + Duration::from_secs(1)); - } - - let (camera_probe_active, camera_label, mic_probe_active) = { - let state = state.borrow(); - ( - state.channels.camera && state.devices.camera.is_some(), - state.devices.camera.clone(), - state.channels.microphone && state.devices.microphone.is_some(), - ) - }; - if let Err(err) = tests.borrow_mut().sync_relay_uplink_probe( - child_running, - camera_probe_active, - camera_label.as_deref(), - &camera_preview_path, - mic_probe_active, - &mic_level_path, - ) { - widgets - .status_label - .set_text(&format!("Local uplink monitor could not start: {err}")); - } - - refresh_launcher_ui(&widgets, &state.borrow(), child_running); - refresh_test_buttons(&widgets, &mut tests.borrow_mut()); - glib::ControlFlow::Continue - }); - } + include!("ui/stage_device_bindings.rs"); + include!("ui/eye_display_bindings.rs"); + include!("ui/media_device_bindings.rs"); + let _: () = include!("ui/device_refresh_binding.rs"); + include!("ui/relay_input_bindings.rs"); + include!("ui/utility_button_bindings.rs"); + include!("ui/local_test_bindings.rs"); + include!("ui/power_display_key_bindings.rs"); + let _: () = include!("ui/runtime_poll.rs"); window.present(); }); @@ -2500,151 +166,19 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> { Ok(()) } -#[cfg(all(test, not(coverage)))] -mod tests { - use super::apply_preview_profiles; - use crate::launcher::preview::{LauncherPreview, PreviewSurface}; - use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState}; - - #[test] - fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { - let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); - let state = LauncherState::default(); - - let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(bootstrap.0, 0); - assert_eq!(bootstrap.3, 1920); - assert_eq!(bootstrap.4, 1080); - assert_eq!(bootstrap.5, 60); - assert_eq!(bootstrap.6, 18_000); - - apply_preview_profiles(&preview, &state); - - let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(inline.0, 1); - assert_eq!(inline.3, 1920); - assert_eq!(inline.4, 1080); - assert_eq!(inline.5, 60); - assert_eq!(inline.6, 18_000); - - let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap(); - assert_eq!(window.0, 1); - assert_eq!(window.3, 1920); - assert_eq!(window.4, 1080); - assert_eq!(window.5, 60); - assert_eq!(window.6, 18_000); - - preview.shutdown_all(); - } - - #[test] - fn source_preview_profile_stays_honest_after_apply() { - let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); - let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::P1080); - - apply_preview_profiles(&preview, &state); - - let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(inline.0, 1); - assert_eq!(inline.3, 1920); - assert_eq!(inline.4, 1080); - assert_eq!(inline.5, 60); - assert_eq!(inline.6, 18_000); - - preview.shutdown_all(); - } - - #[test] - fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() { - let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); - let mut state = LauncherState::default(); - state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); - - apply_preview_profiles(&preview, &state); - - let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap(); - let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap(); - assert_eq!(inline.0, 1); - assert_eq!(window.0, 1); - - preview.shutdown_all(); - } - - #[test] - fn mirrored_preview_profile_inherits_the_source_eye_mode() { - let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); - let mut state = LauncherState::default(); - state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); - state.set_capture_size_preset(1, CaptureSizePreset::P720); - - apply_preview_profiles(&preview, &state); - - let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap(); - let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap(); - assert_eq!(inline.0, 1); - assert_eq!(window.0, 1); - assert_eq!(inline.3, 1280); - assert_eq!(inline.4, 720); - assert_eq!(inline.5, 60); - assert_eq!(inline.6, 12_000); - assert_eq!(window.3, 1280); - assert_eq!(window.4, 720); - assert_eq!(window.5, 60); - assert_eq!(window.6, 12_000); - - preview.shutdown_all(); - } - - #[test] - fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() { - let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); - let mut state = LauncherState::default(); - state.set_feed_source_preset(0, FeedSourcePreset::Off); - - apply_preview_profiles(&preview, &state); - - assert_eq!( - preview.feed_disabled_for_test(0, PreviewSurface::Inline), - Some(true) - ); - assert_eq!( - preview.feed_disabled_for_test(0, PreviewSurface::Window), - Some(true) - ); - assert_eq!( - preview.feed_disabled_for_test(1, PreviewSurface::Inline), - Some(false) - ); - - preview.shutdown_all(); - } +/// Keep the coverage stub aligned with the real preview activation rule. +#[cfg(coverage)] +fn session_preview_active( + state: &crate::launcher::state::LauncherState, + child_running: bool, +) -> bool { + (child_running || state.remote_active) && state.capture_power.mode != "forced-off" } +#[cfg(all(test, not(coverage)))] +#[path = "tests/ui_preview_profiles.rs"] +mod tests; + #[cfg(all(test, coverage))] -mod tests { - use super::{run_gui_launcher, session_preview_active}; - use crate::launcher::state::{CapturePowerStatus, LauncherState}; - - #[test] - fn coverage_stub_returns_ok() { - assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok()); - } - - #[test] - fn session_preview_stays_idle_when_capture_is_forced_off() { - let mut state = LauncherState::new(); - state.start_remote(); - state.set_capture_power(CapturePowerStatus { - available: true, - enabled: false, - unit: "relay.service".to_string(), - detail: "inactive/dead".to_string(), - active_leases: 1, - mode: "forced-off".to_string(), - detected_devices: 0, - }); - - assert!(!session_preview_active(&state, true)); - } -} +#[path = "tests/ui_coverage.rs"] +mod tests; diff --git a/client/src/launcher/ui/activation_context.rs b/client/src/launcher/ui/activation_context.rs new file mode 100644 index 0000000..0d122f3 --- /dev/null +++ b/client/src/launcher/ui/activation_context.rs @@ -0,0 +1,36 @@ +#[cfg(not(coverage))] +struct ActivationContext { + window: gtk::ApplicationWindow, + server_entry: gtk::Entry, + camera_combo: gtk::ComboBoxText, + camera_quality_combo: gtk::ComboBoxText, + microphone_combo: gtk::ComboBoxText, + speaker_combo: gtk::ComboBoxText, + keyboard_combo: gtk::ComboBoxText, + mouse_combo: gtk::ComboBoxText, + widgets: super::ui_components::LauncherWidgets, + preview: Option>, + popouts: Rc; 2]>>, + diagnostics_popout: Rc>>, + log_popout: Rc>>, + camera_quality_syncing: Rc>, + power_tx: std::sync::mpsc::Sender, + power_rx: std::sync::mpsc::Receiver, + power_request_in_flight: Rc>, + relay_tx: std::sync::mpsc::Sender, + relay_rx: std::sync::mpsc::Receiver, + relay_request_in_flight: Rc>, + caps_tx: std::sync::mpsc::Sender, + caps_rx: std::sync::mpsc::Receiver, + caps_request_in_flight: Rc>, + diagnostics_network: Rc>, + diagnostics_process: Rc>, + next_power_probe: Rc>, + next_diagnostics_probe: Rc>, + next_diagnostics_sample: Rc>, + preview_session_active: Rc>, + clipboard_tx: std::sync::mpsc::Sender, + clipboard_rx: std::sync::mpsc::Receiver, + log_tx: std::sync::mpsc::Sender, + log_rx: std::sync::mpsc::Receiver, +} diff --git a/client/src/launcher/ui/activation_setup.rs b/client/src/launcher/ui/activation_setup.rs new file mode 100644 index 0000000..34a8e97 --- /dev/null +++ b/client/src/launcher/ui/activation_setup.rs @@ -0,0 +1,168 @@ +{ + let (display_width, display_height) = largest_monitor_size(); + let (physical_width, physical_height) = largest_monitor_physical_size(); + { + let mut state = state.borrow_mut(); + state.set_breakout_display_size(display_width, display_height); + state.set_breakout_limit_size(physical_width, physical_height); + } + let view = + build_launcher_view(app, server_addr.as_ref(), &catalog.borrow(), &state.borrow()); + let window = view.window.clone(); + let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height); + window.set_default_size(launcher_width, launcher_height); + let server_entry = view.server_entry.clone(); + let camera_combo = view.camera_combo.clone(); + let camera_quality_combo = view.camera_quality_combo.clone(); + let microphone_combo = view.microphone_combo.clone(); + let speaker_combo = view.speaker_combo.clone(); + let keyboard_combo = view.keyboard_combo.clone(); + let mouse_combo = view.mouse_combo.clone(); + let widgets = view.widgets.clone(); + let preview = view.preview.clone(); + let popouts = Rc::clone(&view.popouts); + let diagnostics_popout = Rc::clone(&view.diagnostics_popout); + let log_popout = Rc::clone(&view.log_popout); + let shutdown_cleaned = Rc::new(Cell::new(false)); + let camera_quality_syncing = Rc::new(Cell::new(false)); + + { + let shutdown_cleaned = Rc::clone(&shutdown_cleaned); + let app = app.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let preview = preview.clone(); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let diagnostics_popout = Rc::clone(&diagnostics_popout); + let log_popout = Rc::clone(&log_popout); + window.connect_close_request(move |_| { + if !shutdown_cleaned.replace(true) { + shutdown_launcher_runtime( + &child_proc, + &tests, + preview.as_deref(), + &widgets, + &popouts, + &diagnostics_popout, + &log_popout, + ); + } + let app = app.clone(); + glib::idle_add_local_once(move || { + app.quit(); + }); + glib::Propagation::Stop + }); + } + + { + let shutdown_cleaned = Rc::clone(&shutdown_cleaned); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let preview = preview.clone(); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let diagnostics_popout = Rc::clone(&diagnostics_popout); + let log_popout = Rc::clone(&log_popout); + app.connect_shutdown(move |_| { + if !shutdown_cleaned.replace(true) { + shutdown_launcher_runtime( + &child_proc, + &tests, + preview.as_deref(), + &widgets, + &popouts, + &diagnostics_popout, + &log_popout, + ); + } + }); + } + + { + let mut tests = tests.borrow_mut(); + if let Err(err) = tests.bind_camera_preview( + &view.device_stage.camera_preview, + &view.device_stage.camera_status, + ) { + widgets + .status_label + .set_text(&format!("Camera preview setup failed: {err}")); + } + if let Err(err) = + tests.set_camera_selection(state.borrow().devices.camera.as_deref()) + { + widgets + .status_label + .set_text(&format!("Camera staging setup failed: {err}")); + } + if let Err(err) = tests.set_camera_quality(state.borrow().camera_quality) { + widgets + .status_label + .set_text(&format!("Camera quality staging setup failed: {err}")); + } + } + + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + + let (power_tx, power_rx) = std::sync::mpsc::channel::(); + let power_request_in_flight = Rc::new(Cell::new(false)); + let (relay_tx, relay_rx) = std::sync::mpsc::channel::(); + let relay_request_in_flight = Rc::new(Cell::new(false)); + let (caps_tx, caps_rx) = std::sync::mpsc::channel::(); + let caps_request_in_flight = Rc::new(Cell::new(false)); + let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default())); + let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new())); + let next_power_probe = + Rc::new(Cell::new(Instant::now() + Duration::from_millis(500))); + let next_diagnostics_probe = + Rc::new(Cell::new(Instant::now() + Duration::from_millis(250))); + let next_diagnostics_sample = + Rc::new(Cell::new(Instant::now() + Duration::from_secs(1))); + let preview_session_active = Rc::new(Cell::new(false)); + let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::(); + let (log_tx, log_rx) = std::sync::mpsc::channel::(); + + if let Some(preview) = preview.as_ref() { + preview.set_log_sink(log_tx.clone()); + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); + } + + ActivationContext { + window, + server_entry, + camera_combo, + camera_quality_combo, + microphone_combo, + speaker_combo, + keyboard_combo, + mouse_combo, + widgets, + preview, + popouts, + diagnostics_popout, + log_popout, + camera_quality_syncing, + power_tx, + power_rx, + power_request_in_flight, + relay_tx, + relay_rx, + relay_request_in_flight, + caps_tx, + caps_rx, + caps_request_in_flight, + diagnostics_network, + diagnostics_process, + next_power_probe, + next_diagnostics_probe, + next_diagnostics_sample, + preview_session_active, + clipboard_tx, + clipboard_rx, + log_tx, + log_rx, + } +} diff --git a/client/src/launcher/ui/control_requests.rs b/client/src/launcher/ui/control_requests.rs new file mode 100644 index 0000000..9db6fcb --- /dev/null +++ b/client/src/launcher/ui/control_requests.rs @@ -0,0 +1,165 @@ +fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { + if samples.len() < 2 { + return 0.0; + } + let mut values = samples.iter().map(|(_, value)| *value).collect::>(); + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median = values[values.len() / 2]; + let mut deviations = values + .into_iter() + .map(|value| (value - median).abs()) + .collect::>(); + deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + deviations[deviations.len() / 2] +} + +#[cfg(not(coverage))] +/// Apply a remote-audio gain slider update without unwinding through GTK callbacks. +fn apply_audio_gain_change( + scale: >k::Scale, + state: &Rc>, + widgets: &super::ui_components::LauncherWidgets, + child_proc: &Rc>>, +) -> bool { + let percent = scale + .value() + .round() + .clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64) as u32; + let label = { + let Ok(mut state) = state.try_borrow_mut() else { + return false; + }; + if state.audio_gain_percent == percent { + widgets.audio_gain_value.set_text(&state.audio_gain_label()); + return true; + } + state.set_audio_gain_percent(percent); + state.audio_gain_label() + }; + widgets.audio_gain_value.set_text(&label); + let relay_live = child_proc + .try_borrow() + .map(|child| child.is_some()) + .unwrap_or(false); + if relay_live { + let path = audio_gain_control_path(); + match write_audio_gain_request(&path, percent) { + Ok(()) => widgets + .status_label + .set_text(&format!("Remote audio gain set to {label}.")), + Err(err) => widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}" + )), + } + } else { + widgets.status_label.set_text(&format!( + "Remote audio gain set to {label} for the next relay launch." + )); + } + true +} + +#[cfg(not(coverage))] +/// Apply a microphone uplink gain slider update without unwinding through GTK callbacks. +fn apply_mic_gain_change( + scale: >k::Scale, + state: &Rc>, + widgets: &super::ui_components::LauncherWidgets, + child_proc: &Rc>>, +) -> bool { + let percent = scale + .value() + .round() + .clamp(0.0, MAX_MIC_GAIN_PERCENT as f64) as u32; + let label = { + let Ok(mut state) = state.try_borrow_mut() else { + return false; + }; + if state.mic_gain_percent == percent { + widgets.mic_gain_value.set_text(&state.mic_gain_label()); + return true; + } + state.set_mic_gain_percent(percent); + state.mic_gain_label() + }; + widgets.mic_gain_value.set_text(&label); + let relay_live = child_proc + .try_borrow() + .map(|child| child.is_some()) + .unwrap_or(false); + if relay_live { + let path = mic_gain_control_path(); + match write_mic_gain_request(&path, percent) { + Ok(()) => widgets.status_label.set_text(&format!("Mic gain set to {label}.")), + Err(err) => widgets.status_label.set_text(&format!( + "Mic gain set to {label} for the next relay launch, but live gain control could not be written: {err}" + )), + } + } else { + widgets.status_label.set_text(&format!( + "Mic gain set to {label} for the next relay launch." + )); + } + true +} + +#[cfg(not(coverage))] +fn request_capture_power_refresh( + power_tx: std::sync::mpsc::Sender, + server_addr: String, + delay: Duration, +) { + std::thread::spawn(move || { + if !delay.is_zero() { + std::thread::sleep(delay); + } + let result = fetch_capture_power(&server_addr).map_err(|err| err.to_string()); + let _ = power_tx.send(PowerMessage::Refresh(result)); + }); +} + +#[cfg(not(coverage))] +fn request_capture_power_command( + power_tx: std::sync::mpsc::Sender, + server_addr: String, + command: CapturePowerCommand, +) { + std::thread::spawn(move || { + let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string()); + let _ = power_tx.send(PowerMessage::Command(result)); + }); +} + +#[cfg(not(coverage))] +fn request_handshake_caps( + caps_tx: std::sync::mpsc::Sender, + server_addr: String, + delay: Duration, +) { + std::thread::spawn(move || { + if !delay.is_zero() { + std::thread::sleep(delay); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let probe = match runtime { + Ok(runtime) => runtime.block_on(probe(&server_addr)), + Err(_) => HandshakeProbe::default(), + }; + let _ = caps_tx.send(CapsMessage::Refresh(probe)); + }); +} + +#[cfg(not(coverage))] +fn unavailable_capture_power(detail: String) -> CapturePowerStatus { + CapturePowerStatus { + available: false, + enabled: false, + unit: "relay.service".to_string(), + detail, + active_leases: 0, + mode: "auto".to_string(), + detected_devices: 0, + } +} diff --git a/client/src/launcher/ui/device_refresh_binding.rs b/client/src/launcher/ui/device_refresh_binding.rs new file mode 100644 index 0000000..b3756a6 --- /dev/null +++ b/client/src/launcher/ui/device_refresh_binding.rs @@ -0,0 +1,122 @@ +{ + { + let state = Rc::clone(&state); + let catalog_state = Rc::clone(&catalog); + let widgets = widgets.clone(); + let widgets_handle = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); + let microphone_combo = microphone_combo.clone(); + let speaker_combo = speaker_combo.clone(); + let keyboard_combo = keyboard_combo.clone(); + let mouse_combo = mouse_combo.clone(); + let camera_quality_syncing = Rc::clone(&camera_quality_syncing); + widgets.device_refresh_button.connect_clicked(move |_| { + let fresh_catalog = DeviceCatalog::discover(); + let ( + selected_camera, + selected_microphone, + selected_speaker, + selected_keyboard, + selected_mouse, + ) = { + let state = state.borrow(); + ( + retained_stage_selection( + state.devices.camera.as_deref(), + &fresh_catalog.cameras, + ), + retained_stage_selection( + state.devices.microphone.as_deref(), + &fresh_catalog.microphones, + ), + retained_stage_selection( + state.devices.speaker.as_deref(), + &fresh_catalog.speakers, + ), + retained_input_selection( + state.devices.keyboard.as_deref(), + &fresh_catalog.keyboards, + ), + retained_input_selection( + state.devices.mouse.as_deref(), + &fresh_catalog.mice, + ), + ) + }; + { + let mut state = state.borrow_mut(); + state.select_camera(selected_camera); + camera_quality_syncing.set(true); + sync_camera_quality_selection( + &camera_quality_combo, + &mut state, + &fresh_catalog, + ); + camera_quality_syncing.set(false); + state.select_microphone(selected_microphone); + state.select_speaker(selected_speaker); + state.select_keyboard(selected_keyboard); + state.select_mouse(selected_mouse); + } + *catalog_state.borrow_mut() = fresh_catalog.clone(); + let state_snapshot = state.borrow().clone(); + sync_stage_device_combo( + &camera_combo, + &fresh_catalog.cameras, + state_snapshot.devices.camera.as_deref(), + ); + sync_stage_device_combo( + µphone_combo, + &fresh_catalog.microphones, + state_snapshot.devices.microphone.as_deref(), + ); + sync_stage_device_combo( + &speaker_combo, + &fresh_catalog.speakers, + state_snapshot.devices.speaker.as_deref(), + ); + sync_input_device_combo( + &keyboard_combo, + &fresh_catalog.keyboards, + state_snapshot.devices.keyboard.as_deref(), + "all keyboards", + ); + sync_input_device_combo( + &mouse_combo, + &fresh_catalog.mice, + state_snapshot.devices.mouse.as_deref(), + "all mice", + ); + if let Err(err) = tests + .borrow_mut() + .set_camera_selection(state_snapshot.devices.camera.as_deref()) + { + widgets_handle + .status_label + .set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}")); + } else if let Err(err) = + tests.borrow_mut().set_camera_quality(state_snapshot.camera_quality) + { + widgets_handle.status_label.set_text(&format!( + "Device refresh succeeded, but the webcam quality test could not switch cleanly: {err}" + )); + } else { + let message = if usb_audio_kernel_support_missing() { + "Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker." + } else { + "Device staging refreshed. Newly attached devices are ready for local tests; all-keyboard/all-mouse relay sessions will pick up new input devices automatically." + }; + widgets_handle.status_label.set_text(message); + } + refresh_launcher_ui( + &widgets_handle, + &state.borrow(), + child_proc.borrow().is_some(), + ); + refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut()); + }); + } +} diff --git a/client/src/launcher/ui/diagnostic_sampling.rs b/client/src/launcher/ui/diagnostic_sampling.rs new file mode 100644 index 0000000..a4d69e4 --- /dev/null +++ b/client/src/launcher/ui/diagnostic_sampling.rs @@ -0,0 +1,156 @@ +#[cfg(not(coverage))] +fn refresh_eye_feed_controls( + widgets: &super::ui_components::LauncherWidgets, + state: &LauncherState, +) { + for monitor_id in 0..2 { + super::ui_components::sync_feed_source_combo( + &widgets.display_panes[monitor_id].feed_source_combo, + state.feed_source_options(monitor_id), + state.feed_source_preset(monitor_id), + ); + if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off { + let choice = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + if state.feed_source_preset(monitor_id) == FeedSourcePreset::ThisEye { + super::ui_components::sync_capture_resolution_combo( + &widgets.display_panes[monitor_id].capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(monitor_id), + ); + } else { + super::ui_components::sync_capture_resolution_locked( + &widgets.display_panes[monitor_id].capture_resolution_combo, + state.capture_size_options(), + choice.preset, + ); + } + } else { + super::ui_components::sync_capture_resolution_disabled( + &widgets.display_panes[monitor_id].capture_resolution_combo, + ); + } + super::ui_components::sync_breakout_size_combo( + &widgets.display_panes[monitor_id].breakout_combo, + state.breakout_size_options(monitor_id), + state.breakout_size_preset(monitor_id), + ); + refresh_preview_frame_ratio(widgets, monitor_id, state); + } +} + +#[cfg(not(coverage))] +fn refresh_preview_frame_ratio( + widgets: &super::ui_components::LauncherWidgets, + monitor_id: usize, + state: &LauncherState, +) { + let capture = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + widgets.display_panes[monitor_id] + .preview_frame + .set_ratio(capture.preset.display_aspect_ratio()); +} + +#[cfg(not(coverage))] +fn eye_caps_changed(state: &LauncherState, caps: &crate::handshake::PeerCaps) -> bool { + let next_width = caps.eye_width.unwrap_or(state.preview_source.width); + let next_height = caps.eye_height.unwrap_or(state.preview_source.height); + let next_fps = caps.eye_fps.unwrap_or(state.preview_source.fps); + state.preview_source.width != next_width + || state.preview_source.height != next_height + || state.preview_source.fps != next_fps +} + +#[cfg(not(coverage))] +fn record_diagnostics_sample( + widgets: &super::ui_components::LauncherWidgets, + state: &LauncherState, + preview: Option<&super::preview::LauncherPreview>, + network: NetworkSnapshot, + client_process_cpu_pct: f32, +) { + let left_metrics = preview + .and_then(|preview| { + (state.feed_source_preset(0) != FeedSourcePreset::Off).then_some( + preview.snapshot_metrics( + 0, + match state.display_surface(0) { + DisplaySurface::Preview => super::preview::PreviewSurface::Inline, + DisplaySurface::Window => super::preview::PreviewSurface::Window, + }, + ), + ) + }) + .flatten() + .unwrap_or_default(); + let right_metrics = preview + .and_then(|preview| { + (state.feed_source_preset(1) != FeedSourcePreset::Off).then_some( + preview.snapshot_metrics( + 1, + match state.display_surface(1) { + DisplaySurface::Preview => super::preview::PreviewSurface::Inline, + DisplaySurface::Window => super::preview::PreviewSurface::Window, + }, + ), + ) + }) + .flatten() + .unwrap_or_default(); + + widgets + .diagnostics_log + .borrow_mut() + .record(PerformanceSample { + rtt_ms: network.rtt_ms, + probe_spread_ms: network.probe_spread_ms, + input_latency_ms: network.rtt_ms * 0.5, + probe_loss_pct: network.probe_loss_pct, + client_process_cpu_pct, + server_process_cpu_pct: left_metrics + .server_process_cpu_pct + .max(right_metrics.server_process_cpu_pct), + video_loss_pct: left_metrics + .packet_loss_pct + .max(right_metrics.packet_loss_pct), + left_receive_fps: left_metrics.receive_fps, + left_present_fps: left_metrics.present_fps, + left_server_fps: left_metrics.server_fps, + left_stream_spread_ms: left_metrics.stream_spread_ms, + left_packet_gap_peak_ms: left_metrics.packet_gap_peak_ms, + left_present_gap_peak_ms: left_metrics.present_gap_peak_ms, + left_queue_depth: left_metrics.queue_depth, + left_queue_peak: left_metrics.queue_depth_peak, + left_server_source_gap_peak_ms: left_metrics.server_source_gap_peak_ms, + left_server_send_gap_peak_ms: left_metrics.server_send_gap_peak_ms, + left_server_queue_peak: left_metrics.server_queue_peak, + left_server_encoder_label: left_metrics.server_encoder_label.clone(), + left_decoder_label: left_metrics.decoder_label.clone(), + left_stream_caps_label: left_metrics.stream_caps_label.clone(), + left_decoded_caps_label: left_metrics.decoded_caps_label.clone(), + left_rendered_caps_label: left_metrics.rendered_caps_label.clone(), + right_receive_fps: right_metrics.receive_fps, + right_present_fps: right_metrics.present_fps, + right_server_fps: right_metrics.server_fps, + right_stream_spread_ms: right_metrics.stream_spread_ms, + right_packet_gap_peak_ms: right_metrics.packet_gap_peak_ms, + right_present_gap_peak_ms: right_metrics.present_gap_peak_ms, + right_queue_depth: right_metrics.queue_depth, + right_queue_peak: right_metrics.queue_depth_peak, + right_server_source_gap_peak_ms: right_metrics.server_source_gap_peak_ms, + right_server_send_gap_peak_ms: right_metrics.server_send_gap_peak_ms, + right_server_queue_peak: right_metrics.server_queue_peak, + right_server_encoder_label: right_metrics.server_encoder_label.clone(), + right_decoder_label: right_metrics.decoder_label.clone(), + right_stream_caps_label: right_metrics.stream_caps_label.clone(), + right_decoded_caps_label: right_metrics.decoded_caps_label.clone(), + right_rendered_caps_label: right_metrics.rendered_caps_label.clone(), + dropped_frames: left_metrics + .dropped_frames + .saturating_add(right_metrics.dropped_frames), + queue_depth: left_metrics.queue_depth.max(right_metrics.queue_depth), + }); +} diff --git a/client/src/launcher/ui/eye_display_bindings.rs b/client/src/launcher/ui/eye_display_bindings.rs new file mode 100644 index 0000000..436a226 --- /dev/null +++ b/client/src/launcher/ui/eye_display_bindings.rs @@ -0,0 +1,126 @@ +{ + for monitor_id in 0..2 { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let child_proc = Rc::clone(&child_proc); + let preview = preview.clone(); + let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone(); + feed_source_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else { + return; + }; + if state.borrow().feed_source_preset(monitor_id) == preset { + return; + } + { + let mut state = state.borrow_mut(); + state.set_feed_source_preset(monitor_id, preset); + } + if let Some(preview) = preview.as_ref() { + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + for monitor_id in 0..2 { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let child_proc = Rc::clone(&child_proc); + let preview = preview.clone(); + let resolution_combo = + widgets.display_panes[monitor_id].capture_resolution_combo.clone(); + resolution_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else { + return; + }; + if state.borrow().feed_source_preset(monitor_id) != FeedSourcePreset::ThisEye { + return; + } + if state.borrow().capture_size_preset(monitor_id) == preset { + return; + } + { + let mut state = state.borrow_mut(); + state.set_capture_size_preset(monitor_id, preset); + } + if let Some(preview) = preview.as_ref() { + let choice = state + .borrow() + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.borrow().capture_size_choice(monitor_id)); + let source_monitor_id = state + .borrow() + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); + preview.set_capture_profile( + monitor_id, + source_monitor_id, + choice.width, + choice.height, + choice.fps, + choice.max_bitrate_kbit, + ); + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + for monitor_id in 0..2 { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let child_proc = Rc::clone(&child_proc); + let preview = preview.clone(); + let breakout_combo = widgets.display_panes[monitor_id].breakout_combo.clone(); + breakout_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + let Some(preset) = BreakoutSizePreset::from_id(active_id.as_str()) else { + return; + }; + if state.borrow().breakout_size_preset(monitor_id) == preset { + return; + } + { + let mut state = state.borrow_mut(); + state.set_breakout_size_preset(monitor_id, preset); + } + let size = state.borrow().breakout_size_choice(monitor_id); + if let Some(preview) = preview.as_ref() { + preview.set_breakout_profile(monitor_id, size.width, size.height); + } + let popout_open = { + popouts + .borrow() + .get(monitor_id) + .and_then(|slot| slot.as_ref()) + .is_some() + }; + if popout_open { + if let Some(preview) = preview.as_ref() { + rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id); + } + if let Some(handle) = popouts + .borrow() + .get(monitor_id) + .and_then(|slot| slot.as_ref()) + { + let display_limit = state.borrow().breakout_display_size(); + apply_popout_window_size(handle, size, display_limit); + } + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } +} diff --git a/client/src/launcher/ui/local_test_bindings.rs b/client/src/launcher/ui/local_test_bindings.rs new file mode 100644 index 0000000..802b00a --- /dev/null +++ b/client/src/launcher/ui/local_test_bindings.rs @@ -0,0 +1,90 @@ +{ + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); + let camera_test_button = widgets.camera_test_button.clone(); + let widgets_handle = widgets.clone(); + camera_test_button.connect_clicked(move |_| { + let selected = selected_combo_value(&camera_combo); + let quality = selected_camera_quality(&camera_quality_combo); + let result = { + let mut tests = tests.borrow_mut(); + let _ = tests.set_camera_selection(selected.as_deref()); + let _ = tests.set_camera_quality(quality); + tests.toggle_camera() + }; + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Camera preview started inside the launcher. This stays local until you start the relay.", + "Camera preview stopped. The selected camera stays staged for the next launch.", + ); + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let microphone_combo = microphone_combo.clone(); + let speaker_combo = speaker_combo.clone(); + let microphone_test_button = widgets.microphone_test_button.clone(); + let widgets_handle = widgets.clone(); + microphone_test_button.connect_clicked(move |_| { + let mic = selected_combo_value(µphone_combo); + let sink = selected_combo_value(&speaker_combo); + let result = tests + .borrow_mut() + .toggle_microphone(mic.as_deref(), sink.as_deref()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Microphone monitor started locally through the selected speaker.", + "Microphone monitor stopped.", + ); + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let speaker_combo = speaker_combo.clone(); + let microphone_replay_button = widgets.microphone_replay_button.clone(); + let widgets_handle = widgets.clone(); + microphone_replay_button.connect_clicked(move |_| { + let result = tests + .borrow_mut() + .toggle_microphone_replay(selected_combo_value(&speaker_combo).as_deref()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Replaying the latest local mic capture through the selected speaker.", + "Mic replay stopped.", + ); + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let speaker_combo = speaker_combo.clone(); + let speaker_test_button = widgets.speaker_test_button.clone(); + let widgets_handle = widgets.clone(); + speaker_test_button.connect_clicked(move |_| { + let result = tests + .borrow_mut() + .toggle_speaker(selected_combo_value(&speaker_combo).as_deref()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Speaker tone started locally.", + "Speaker test tone stopped.", + ); + }); + } +} diff --git a/client/src/launcher/ui/media_device_bindings.rs b/client/src/launcher/ui/media_device_bindings.rs new file mode 100644 index 0000000..c2b9125 --- /dev/null +++ b/client/src/launcher/ui/media_device_bindings.rs @@ -0,0 +1,139 @@ +{ + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let microphone_combo = microphone_combo.clone(); + let microphone_combo_read = microphone_combo.clone(); + microphone_combo.connect_changed(move |_| { + state + .borrow_mut() + .select_microphone(selected_combo_value(µphone_combo_read)); + if tests.borrow_mut().is_running(DeviceTestKind::Microphone) { + widgets.status_label.set_text( + "Microphone selection changed. Restart Monitor Mic to audition the new input.", + ); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let speaker_combo = speaker_combo.clone(); + let speaker_combo_read = speaker_combo.clone(); + speaker_combo.connect_changed(move |_| { + state + .borrow_mut() + .select_speaker(selected_combo_value(&speaker_combo_read)); + let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker); + let microphone_running = + tests.borrow_mut().is_running(DeviceTestKind::Microphone); + if speaker_running || microphone_running { + widgets.status_label.set_text( + "Speaker selection changed. Restart the local audio tests to hear the new output.", + ); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let audio_gain_scale = widgets.audio_gain_scale.clone(); + audio_gain_scale.connect_value_changed(move |scale| { + if !apply_audio_gain_change(scale, &state, &widgets, &child_proc) { + let scale = scale.clone(); + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + glib::idle_add_local_once(move || { + let _ = apply_audio_gain_change(&scale, &state, &widgets, &child_proc); + }); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let mic_gain_scale = widgets.mic_gain_scale.clone(); + mic_gain_scale.connect_value_changed(move |scale| { + if !apply_mic_gain_change(scale, &state, &widgets, &child_proc) { + let scale = scale.clone(); + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + glib::idle_add_local_once(move || { + let _ = apply_mic_gain_change(&scale, &state, &widgets, &child_proc); + }); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.camera_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_camera_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.microphone_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_microphone_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let toggle = widgets.audio_channel_toggle.clone(); + toggle.connect_toggled(move |toggle| { + if let Ok(mut state) = state.try_borrow_mut() { + state.set_audio_channel_enabled(toggle.is_active()); + } + if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { + refresh_launcher_ui( + &widgets, + &state_snapshot, + child_proc.borrow().is_some(), + ); + } + }); + } +} diff --git a/client/src/launcher/ui/message_and_network_state.rs b/client/src/launcher/ui/message_and_network_state.rs new file mode 100644 index 0000000..3b16c93 --- /dev/null +++ b/client/src/launcher/ui/message_and_network_state.rs @@ -0,0 +1,130 @@ +#[cfg(not(coverage))] +enum PowerMessage { + Refresh(std::result::Result), + Command(std::result::Result), +} + +#[cfg(not(coverage))] +enum RelayMessage { + Spawned(std::result::Result), +} + +#[cfg(not(coverage))] +enum CapsMessage { + Refresh(HandshakeProbe), +} + +#[cfg(not(coverage))] +enum ClipboardMessage { + Finished(std::result::Result), +} + +#[cfg(not(coverage))] +const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8); + +#[cfg(not(coverage))] +fn usb_audio_kernel_support_missing() -> bool { + Command::new("modinfo") + .arg("snd_usb_audio") + .status() + .map(|status| !status.success()) + .unwrap_or(true) +} + +#[cfg(not(coverage))] +#[derive(Default)] +struct NetworkTelemetry { + rtt_samples: VecDeque<(Instant, f32)>, + failures: VecDeque, +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy, Debug, Default)] +struct NetworkSnapshot { + rtt_ms: f32, + probe_spread_ms: f32, + probe_loss_pct: f32, +} + +#[cfg(not(coverage))] +impl NetworkTelemetry { + fn record(&mut self, probe: &HandshakeProbe) { + let now = Instant::now(); + self.trim(now); + if let Some(rtt_ms) = probe.rtt_ms { + self.rtt_samples.push_back((now, rtt_ms)); + } else { + self.failures.push_back(now); + } + self.trim(now); + } + + fn snapshot(&mut self) -> NetworkSnapshot { + let now = Instant::now(); + self.trim(now); + let rtt_ms = self.rtt_samples.back().map(|(_, rtt)| *rtt).unwrap_or(0.0); + let probe_spread_ms = network_spread_ms(&self.rtt_samples); + let probe_count = self.rtt_samples.len() + self.failures.len(); + let probe_loss_pct = if probe_count == 0 { + 0.0 + } else { + self.failures.len() as f32 * 100.0 / probe_count as f32 + }; + NetworkSnapshot { + rtt_ms, + probe_spread_ms, + probe_loss_pct, + } + } + + fn trim(&mut self, now: Instant) { + while let Some((oldest, _)) = self.rtt_samples.front().copied() { + if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW { + let _ = self.rtt_samples.pop_front(); + } else { + break; + } + } + while let Some(oldest) = self.failures.front().copied() { + if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW { + let _ = self.failures.pop_front(); + } else { + break; + } + } + } +} + +#[cfg(not(coverage))] +fn retained_stage_selection(current: Option<&str>, values: &[String]) -> Option { + current + .filter(|selected| values.iter().any(|value| value == *selected)) + .map(str::to_string) + .or_else(|| values.first().cloned()) +} + +#[cfg(not(coverage))] +fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option { + current + .filter(|selected| values.iter().any(|value| value == *selected)) + .map(str::to_string) +} + +#[cfg(not(coverage))] +fn selected_camera_quality(combo: >k::ComboBoxText) -> Option { + combo.active_id().as_deref().and_then(CameraMode::from_id) +} + +#[cfg(not(coverage))] +fn sync_camera_quality_selection( + combo: >k::ComboBoxText, + state: &mut LauncherState, + catalog: &DeviceCatalog, +) { + state.normalize_camera_quality(catalog); + sync_camera_quality_combo( + combo, + &state.camera_quality_options(catalog), + state.camera_quality, + ); +} diff --git a/client/src/launcher/ui/power_display_key_bindings.rs b/client/src/launcher/ui/power_display_key_bindings.rs new file mode 100644 index 0000000..9dea833 --- /dev/null +++ b/client/src/launcher/ui/power_display_key_bindings.rs @@ -0,0 +1,181 @@ +{ + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let power_tx = power_tx.clone(); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let power_auto_button = widgets.power_auto_button.clone(); + let widgets_handle = widgets.clone(); + power_auto_button.connect_clicked(move |_| { + if power_request_in_flight.replace(true) { + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_handle + .status_label + .set_text("Returning capture feeds to automatic mode..."); + request_capture_power_command( + power_tx.clone(), + server_addr, + CapturePowerCommand::Auto, + ); + }); + } + + { + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let power_tx = power_tx.clone(); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let power_on_button = widgets.power_on_button.clone(); + let widgets_handle = widgets.clone(); + power_on_button.connect_clicked(move |_| { + if power_request_in_flight.replace(true) { + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_handle + .status_label + .set_text("Forcing capture feeds on for staging..."); + request_capture_power_command( + power_tx.clone(), + server_addr, + CapturePowerCommand::ForceOn, + ); + }); + } + + { + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let power_tx = power_tx.clone(); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let power_off_button = widgets.power_off_button.clone(); + let widgets_handle = widgets.clone(); + power_off_button.connect_clicked(move |_| { + if power_request_in_flight.replace(true) { + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_handle + .status_label + .set_text("Forcing capture feeds off for staging..."); + request_capture_power_command( + power_tx.clone(), + server_addr, + CapturePowerCommand::ForceOff, + ); + }); + } + + for monitor_id in 0..2 { + let app = app.clone(); + let preview = preview.clone(); + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let popouts = Rc::clone(&popouts); + let widgets = widgets.clone(); + let action_button = widgets.display_panes[monitor_id].action_button.clone(); + action_button.connect_clicked(move |_| { + let Some(preview) = preview.as_ref() else { + widgets + .status_label + .set_text("Preview is unavailable for breakout windows."); + return; + }; + let surface = { + let state = state.borrow(); + state.display_surface(monitor_id) + }; + match surface { + DisplaySurface::Preview => { + open_popout_window( + &app, + preview, + &state, + &child_proc, + &popouts, + &widgets, + monitor_id, + ); + widgets.status_label.set_text(&format!( + "{} moved into its own window.", + widgets.display_panes[monitor_id].title + )); + } + DisplaySurface::Window => { + dock_display_to_preview( + &state, + &child_proc, + &popouts, + &widgets, + monitor_id, + ); + widgets.status_label.set_text(&format!( + "{} returned to the launcher preview.", + widgets.display_panes[monitor_id].title + )); + } + } + }); + } + + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let input_toggle_control_path = Rc::clone(&input_toggle_control_path); + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(move |_, key, _, _| { + if !state.borrow().swap_key_binding { + return glib::Propagation::Proceed; + } + + let Some(swap_key) = capture_swap_key(key) else { + widgets.status_label.set_text( + "That key is not a good swap shortcut. Try a letter, digit, function key, or navigation key.", + ); + refresh_launcher_ui( + &widgets, + &state.borrow(), + child_proc.borrow().is_some(), + ); + return glib::Propagation::Stop; + }; + + let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active; + { + let mut state = state.borrow_mut(); + state.complete_swap_key_binding(swap_key.clone()); + } + let status_message = if relay_live { + match write_input_toggle_key_request( + input_toggle_control_path.as_path(), + &swap_key, + ) { + Ok(()) => format!( + "Swap key set to {} and applied to the live relay.", + toggle_key_label(&swap_key) + ), + Err(err) => format!( + "Swap key set to {}, but Lesavka could not push it live: {err}", + toggle_key_label(&swap_key) + ), + } + } else { + format!( + "Swap key set to {}. The next relay launch will use it.", + toggle_key_label(&swap_key) + ) + }; + widgets.status_label.set_text(&status_message); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + glib::Propagation::Stop + }); + window.add_controller(key_controller); + } +} diff --git a/client/src/launcher/ui/preview_profiles.rs b/client/src/launcher/ui/preview_profiles.rs new file mode 100644 index 0000000..0b03536 --- /dev/null +++ b/client/src/launcher/ui/preview_profiles.rs @@ -0,0 +1,221 @@ +fn largest_monitor_size() -> (u32, u32) { + let (width, height) = enumerate_monitors() + .into_iter() + .max_by_key(|monitor| { + effective_monitor_width(monitor) as u64 * effective_monitor_height(monitor) as u64 + }) + .map(|monitor| { + ( + effective_monitor_width(&monitor), + effective_monitor_height(&monitor), + ) + }) + .unwrap_or((1920, 1080)); + (width.max(2), height.max(2)) +} + +#[cfg(not(coverage))] +fn largest_monitor_physical_size() -> (u32, u32) { + if let Some((width, height)) = probe_kscreen_display_size() { + return (width, height); + } + normalize_breakout_limit(largest_monitor_size().0, largest_monitor_size().1) +} + +#[cfg(not(coverage))] +fn probe_kscreen_display_size() -> Option<(u32, u32)> { + let output = Command::new("kscreen-doctor").arg("-o").output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?; + let mut best = None; + for line in text.lines() { + if !line.contains("Modes:") { + continue; + } + let active = line + .split_whitespace() + .find(|token| token.contains('*') && token.contains('x'))?; + let dims = active + .trim_matches(|ch: char| ch == '*' || ch == '!') + .split('@') + .next()?; + let (width, height) = dims.split_once('x')?; + let width = width.parse::().ok()?; + let height = height.parse::().ok()?; + if best + .map(|(best_w, best_h)| width as u64 * height as u64 > best_w as u64 * best_h as u64) + .unwrap_or(true) + { + best = Some((width, height)); + } + } + best +} + +#[cfg(not(coverage))] +fn effective_monitor_width(monitor: &crate::output::display::MonitorInfo) -> u32 { + let scale = monitor.scale_factor.max(1) as u32; + (monitor.geometry.width().max(1) as u32).saturating_mul(scale) +} + +#[cfg(not(coverage))] +fn effective_monitor_height(monitor: &crate::output::display::MonitorInfo) -> u32 { + let scale = monitor.scale_factor.max(1) as u32; + (monitor.geometry.height().max(1) as u32).saturating_mul(scale) +} + +#[cfg(not(coverage))] +fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) { + const STANDARD_SIZES: &[(u32, u32)] = &[ + (3840, 2160), + (2560, 1440), + (1920, 1080), + (1600, 900), + (1366, 768), + (1280, 720), + (960, 540), + ]; + + STANDARD_SIZES + .iter() + .copied() + .find(|(candidate_w, candidate_h)| *candidate_w <= width && *candidate_h <= height) + .unwrap_or((width.max(2), height.max(2))) +} + +#[cfg(not(coverage))] +fn launcher_default_size(width: u32, height: u32) -> (i32, i32) { + let max_width = width.saturating_sub(48).max(640) as i32; + let max_height = height.saturating_sub(72).max(520) as i32; + (1380.min(max_width), 860.min(max_height)) +} + +#[cfg(not(coverage))] +fn rebind_inline_preview( + preview: &super::preview::LauncherPreview, + widgets: &super::ui_components::LauncherWidgets, + state: &LauncherState, + monitor_id: usize, +) { + if let Some(binding) = widgets.display_panes[monitor_id] + .preview_binding + .borrow_mut() + .take() + { + binding.close(); + } + if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { + widgets.display_panes[monitor_id] + .picture + .set_paintable(Option::<>k::gdk::Paintable>::None); + widgets.display_panes[monitor_id] + .stream_status + .set_text("Feed disabled."); + return; + } + let binding = preview.install_on_picture( + monitor_id, + super::preview::PreviewSurface::Inline, + &widgets.display_panes[monitor_id].picture, + &widgets.display_panes[monitor_id].stream_status, + ); + *widgets.display_panes[monitor_id] + .preview_binding + .borrow_mut() = binding; +} + +#[cfg(not(coverage))] +fn rebind_popout_preview( + preview: &super::preview::LauncherPreview, + popouts: &Rc; 2]>>, + state: &LauncherState, + monitor_id: usize, +) { + let mut popouts = popouts.borrow_mut(); + let Some(handle) = popouts.get_mut(monitor_id).and_then(|slot| slot.as_mut()) else { + return; + }; + handle.binding.close(); + if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { + handle + .picture + .set_paintable(Option::<>k::gdk::Paintable>::None); + handle.status_label.set_text("Feed disabled."); + return; + } + if let Some(binding) = preview.install_on_picture( + monitor_id, + super::preview::PreviewSurface::Window, + &handle.picture, + &handle.status_label, + ) { + handle.binding = binding; + } + let capture = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + handle + .frame + .set_ratio(capture.preset.display_aspect_ratio()); +} + +#[cfg(not(coverage))] +fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) { + for monitor_id in 0..2 { + let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off; + preview.set_monitor_enabled(monitor_id, enabled); + let capture = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + let source_monitor_id = state + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); + let breakout = state.breakout_size_choice(monitor_id); + preview.set_capture_profile( + monitor_id, + source_monitor_id, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.set_breakout_profile(monitor_id, breakout.width, breakout.height); + } +} + +#[cfg(not(coverage))] +fn sync_preview_profiles( + preview: &super::preview::LauncherPreview, + widgets: &super::ui_components::LauncherWidgets, + popouts: &Rc; 2]>>, + state: &LauncherState, +) { + apply_preview_profiles(preview, state); + for monitor_id in 0..2 { + rebind_inline_preview(preview, widgets, state, monitor_id); + rebind_popout_preview(preview, popouts, state, monitor_id); + } +} + +#[cfg(not(coverage))] +fn disconnected_capture_note(mode: &str) -> &'static str { + match mode { + "forced-on" => "Relay disconnected. Capture is still forced on for staging.", + "forced-off" => { + "Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On." + } + _ => { + "Relay disconnected. The server will hold capture briefly, then let it return to standby." + } + } +} + +/// Keeps remote eye previews tied to a live session while respecting forced-off staging. +fn session_preview_active( + state: &crate::launcher::state::LauncherState, + child_running: bool, +) -> bool { + (child_running || state.remote_active) && state.capture_power.mode != "forced-off" +} diff --git a/client/src/launcher/ui/relay_input_bindings.rs b/client/src/launcher/ui/relay_input_bindings.rs new file mode 100644 index 0000000..2e6579f --- /dev/null +++ b/client/src/launcher/ui/relay_input_bindings.rs @@ -0,0 +1,190 @@ +{ + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); + let microphone_combo = microphone_combo.clone(); + let speaker_combo = speaker_combo.clone(); + let input_control_path = Rc::clone(&input_control_path); + let input_state_path = Rc::clone(&input_state_path); + let input_toggle_control_path = Rc::clone(&input_toggle_control_path); + let server_addr_fallback = Rc::clone(&server_addr); + let preview = preview.clone(); + let power_tx = power_tx.clone(); + let relay_tx = relay_tx.clone(); + let relay_request_in_flight = Rc::clone(&relay_request_in_flight); + let popouts = Rc::clone(&popouts); + let window = window.clone(); + let start_button = widgets.start_button.clone(); + let widgets_handle = widgets.clone(); + start_button.connect_clicked(move |_| { + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + if relay_request_in_flight.get() { + return; + } + if child_proc.borrow().is_some() { + stop_child_process(&child_proc); + let power_mode = { + let mut state = state.borrow_mut(); + let _ = state.stop_remote(); + state.capture_power.mode.clone() + }; + dock_all_displays_to_preview( + &state, + &child_proc, + &popouts, + &widgets_handle, + ); + window.present(); + if let Some(preview) = preview.as_ref() { + preview.set_server_addr(server_addr.clone()); + preview.set_session_active(false); + } + if power_mode != "auto" { + widgets_handle.status_label.set_text( + "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.", + ); + request_capture_power_command( + power_tx.clone(), + server_addr.clone(), + CapturePowerCommand::Auto, + ); + } else { + widgets_handle + .status_label + .set_text(disconnected_capture_note(&power_mode)); + } + request_capture_power_refresh( + power_tx.clone(), + server_addr.clone(), + Duration::from_millis(250), + ); + request_capture_power_refresh( + power_tx.clone(), + server_addr, + Duration::from_secs(31), + ); + refresh_launcher_ui(&widgets_handle, &state.borrow(), false); + return; + } + { + let mut state = state.borrow_mut(); + state.select_camera(selected_combo_value(&camera_combo)); + state.select_camera_quality(selected_camera_quality(&camera_quality_combo)); + state.select_microphone(selected_combo_value(µphone_combo)); + state.select_speaker(selected_combo_value(&speaker_combo)); + state.select_keyboard(selected_combo_value(&keyboard_combo)); + state.select_mouse(selected_combo_value(&mouse_combo)); + } + let _ = std::fs::remove_file(input_control_path.as_path()); + let _ = std::fs::remove_file(input_state_path.as_path()); + let _ = std::fs::remove_file(input_toggle_control_path.as_path()); + let launch_state = state.borrow().clone(); + tests.borrow_mut().stop_local_capture_for_relay(); + refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut()); + let input_toggle_key = launch_state.swap_key.clone(); + let input_control_path = input_control_path.as_ref().clone(); + let input_state_path = input_state_path.as_ref().clone(); + let input_toggle_control_path = input_toggle_control_path.as_ref().clone(); + relay_request_in_flight.set(true); + widgets_handle.status_label.set_text(&format!( + "Connecting relay with {} as the swap key...", + toggle_key_label(&input_toggle_key) + )); + refresh_launcher_ui( + &widgets_handle, + &state.borrow(), + child_proc.borrow().is_some(), + ); + let relay_tx = relay_tx.clone(); + std::thread::spawn(move || { + let result = spawn_client_process( + &server_addr, + &launch_state, + &input_toggle_key, + input_control_path.as_path(), + input_state_path.as_path(), + input_toggle_control_path.as_path(), + ) + .map_err(|err| err.to_string()); + let _ = relay_tx.send(RelayMessage::Spawned(result)); + }); + }); + } + + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let input_control_path = Rc::clone(&input_control_path); + let popouts = Rc::clone(&popouts); + let window = window.clone(); + let input_toggle_button = widgets.input_toggle_button.clone(); + let widgets_handle = widgets.clone(); + input_toggle_button.connect_clicked(move |_| { + let next = next_input_routing(state.borrow().routing); + let child_running = child_proc.borrow().is_some(); + if child_running { + if let Err(err) = + write_input_routing_request(input_control_path.as_path(), next) + { + widgets_handle + .status_label + .set_text(&format!("Could not update live input target: {err}")); + refresh_launcher_ui(&widgets_handle, &state.borrow(), true); + return; + } + widgets_handle.status_label.set_text(&format!( + "Input routing switched toward {}.", + routing_name(next) + )); + } else { + widgets_handle.status_label.set_text(&format!( + "Relay will start with {} input ownership.", + routing_name(next) + )); + } + state.borrow_mut().set_routing(next); + refresh_launcher_ui(&widgets_handle, &state.borrow(), child_running); + if matches!(next, InputRouting::Remote) { + present_popout_windows(&popouts); + } else { + window.present(); + } + }); + } + + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let swap_key_button = widgets.swap_key_button.clone(); + swap_key_button.connect_clicked(move |_| { + let token = state.borrow_mut().begin_swap_key_binding(); + widgets + .status_label + .set_text("Press a single key within 3 seconds to make it the swap shortcut."); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + glib::timeout_add_local_once(Duration::from_secs(3), move || { + if state.borrow_mut().cancel_swap_key_binding(token) { + widgets.status_label.set_text( + "Swap-key capture timed out. The previous shortcut is still in place.", + ); + refresh_launcher_ui( + &widgets, + &state.borrow(), + child_proc.borrow().is_some(), + ); + } + }); + }); + } +} diff --git a/client/src/launcher/ui/runtime_poll.rs b/client/src/launcher/ui/runtime_poll.rs new file mode 100644 index 0000000..66aad6d --- /dev/null +++ b/client/src/launcher/ui/runtime_poll.rs @@ -0,0 +1,371 @@ +{ + { + let window = window.clone(); + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let focus_signal_path = Rc::clone(&focus_signal_path); + let input_state_path = Rc::clone(&input_state_path); + let tests = Rc::clone(&tests); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let last_focus_marker = + Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let relay_request_in_flight = Rc::clone(&relay_request_in_flight); + let preview = preview.clone(); + let power_tx = power_tx.clone(); + let caps_tx = caps_tx.clone(); + let caps_request_in_flight = Rc::clone(&caps_request_in_flight); + let diagnostics_network = Rc::clone(&diagnostics_network); + let diagnostics_process = Rc::clone(&diagnostics_process); + let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe); + let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample); + let preview_session_active = Rc::clone(&preview_session_active); + let log_tx = log_tx.clone(); + let camera_preview_path = uplink_camera_preview_path(); + let mic_level_path = uplink_mic_level_path(); + glib::timeout_add_local(Duration::from_millis(180), move || { + let child_running = reap_exited_child(&child_proc); + if let Some(preview) = preview.as_ref() { + let desired_preview_active = { + let state_snapshot = state.borrow(); + session_preview_active(&state_snapshot, child_running) + }; + if preview_session_active.get() != desired_preview_active { + preview.set_session_active(desired_preview_active); + preview_session_active.set(desired_preview_active); + } + } + if !child_running && state.borrow().remote_active { + let power_mode = { + let mut state = state.borrow_mut(); + let _ = state.stop_remote(); + state.capture_power.mode.clone() + }; + dock_all_displays_to_preview(&state, &child_proc, &popouts, &widgets); + window.present(); + if let Some(preview) = preview.as_ref() { + preview.set_session_active(false); + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + if power_mode != "auto" { + widgets.status_label.set_text( + "Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.", + ); + request_capture_power_command( + power_tx.clone(), + server_addr.clone(), + CapturePowerCommand::Auto, + ); + } else { + widgets + .status_label + .set_text(disconnected_capture_note(&power_mode)); + } + request_capture_power_refresh( + power_tx.clone(), + server_addr.clone(), + Duration::from_millis(250), + ); + request_capture_power_refresh( + power_tx.clone(), + server_addr, + Duration::from_secs(31), + ); + } + + if child_running + && let Some(routing) = read_input_routing_state(input_state_path.as_path()) + && routing != state.borrow().routing + { + state.borrow_mut().set_routing(routing); + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + if matches!(routing, InputRouting::Remote) { + present_popout_windows(&popouts); + } else { + window.present(); + } + } + + let next_focus_marker = path_marker(focus_signal_path.as_path()); + let mut last_focus = last_focus_marker.borrow_mut(); + if next_focus_marker > *last_focus { + *last_focus = next_focus_marker; + state.borrow_mut().set_routing(InputRouting::Local); + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + widgets + .status_label + .set_text("Local control restored and the launcher is focused."); + window.present(); + } + + while let Ok(message) = relay_rx.try_recv() { + relay_request_in_flight.set(false); + match message { + RelayMessage::Spawned(Ok(mut child)) => { + attach_child_log_streams(&mut child, log_tx.clone()); + *child_proc.borrow_mut() = Some(child); + { + let mut state = state.borrow_mut(); + state.set_server_available(true); + let _ = state.start_remote(); + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + if let Some(preview) = preview.as_ref() { + preview.set_server_addr(server_addr.clone()); + preview.set_session_active(session_preview_active( + &state.borrow(), + child_proc.borrow().is_some(), + )); + } + let routing = routing_name(state.borrow().routing); + let power_mode = state.borrow().capture_power.mode.clone(); + let message = match power_mode.as_str() { + "forced-off" => format!( + "Relay connected with inputs routed to {routing}, but capture is forced off. Return capture to Auto or Force On when you want remote video." + ), + "forced-on" => format!( + "Relay connected with inputs routed to {routing}. Capture is being held awake and the eye previews are coming online." + ), + _ => format!( + "Relay connected with inputs routed to {routing}. The eye previews will come up with the live session." + ), + }; + widgets.status_label.set_text(&message); + if matches!(state.borrow().routing, InputRouting::Remote) { + present_popout_windows(&popouts); + } + request_capture_power_refresh( + power_tx.clone(), + server_addr.clone(), + Duration::from_millis(250), + ); + request_capture_power_refresh( + power_tx.clone(), + server_addr, + Duration::from_millis(1250), + ); + } + RelayMessage::Spawned(Err(err)) => { + state.borrow_mut().set_server_available(false); + if let Some(preview) = preview.as_ref() { + preview.set_session_active(false); + } + widgets + .status_label + .set_text(&format!("Relay start failed: {err}")); + } + } + } + + while let Ok(line) = log_rx.try_recv() { + let level = *widgets.session_log_level.borrow(); + if append_session_log_for_level(&widgets.session_log_buffer, &line, level) + { + let mut end = widgets.session_log_buffer.end_iter(); + widgets + .session_log_view + .scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); + } + } + + while let Ok(message) = power_rx.try_recv() { + power_request_in_flight.set(false); + match message { + PowerMessage::Refresh(Ok(power)) => { + { + let mut state = state.borrow_mut(); + state.set_server_available(true); + state.set_capture_power(power); + } + if let Some(preview) = preview.as_ref() { + let preview_active = { + let state = state.borrow(); + session_preview_active( + &state, + child_proc.borrow().is_some(), + ) + }; + preview.set_session_active(preview_active); + } + } + PowerMessage::Refresh(Err(err)) => { + let relay_live = child_proc.borrow().is_some() + || state.borrow().remote_active; + { + let mut state = state.borrow_mut(); + if relay_live { + state.set_server_available(true); + if !state.capture_power.available { + state.set_capture_power(unavailable_capture_power(err)); + } + } else { + state.set_server_available(false); + state.set_capture_power(unavailable_capture_power(err)); + } + } + if let Some(preview) = preview.as_ref() { + let preview_active = { + let state = state.borrow(); + session_preview_active( + &state, + child_proc.borrow().is_some(), + ) + }; + preview.set_session_active(preview_active); + } + } + PowerMessage::Command(Ok(power)) => { + let mode = power.mode.clone(); + { + let mut state = state.borrow_mut(); + state.set_server_available(true); + state.set_capture_power(power); + } + if let Some(preview) = preview.as_ref() { + let preview_active = { + let state = state.borrow(); + session_preview_active( + &state, + child_proc.borrow().is_some(), + ) + }; + preview.set_session_active(preview_active); + } + widgets.status_label.set_text(match mode.as_str() { + "forced-on" => "Capture feeds forced on. Remote eyes stay awake even if previews or the relay stop.", + "forced-off" => "Capture feeds forced off. Remote eye previews and session video stay dark until you switch back.", + _ => "Capture feeds returned to automatic mode. Live previews and relay demand will wake them as needed.", + }); + } + PowerMessage::Command(Err(err)) => { + let mut state = state.borrow_mut(); + state.set_server_available(false); + state.set_capture_power(unavailable_capture_power(err.clone())); + widgets + .status_label + .set_text(&format!("Capture power update failed: {err}")); + } + } + } + + while let Ok(message) = caps_rx.try_recv() { + caps_request_in_flight.set(false); + match message { + CapsMessage::Refresh(probe_result) => { + diagnostics_network.borrow_mut().record(&probe_result); + let caps = probe_result.caps; + let rebind_preview = eye_caps_changed(&state.borrow(), &caps); + { + let mut state = state.borrow_mut(); + if probe_result.reachable { + state.set_server_available(true); + } else if child_proc.borrow().is_none() { + state.set_server_available(false); + } + state.set_server_version(caps.server_version.clone()); + } + if let (Some(width), Some(height)) = + (caps.eye_width, caps.eye_height) + { + let fps = caps + .eye_fps + .unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps); + { + let mut state = state.borrow_mut(); + state.set_preview_source_profile(width, height, fps); + } + if rebind_preview && let Some(preview) = preview.as_ref() { + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); + } + refresh_eye_feed_controls(&widgets, &state.borrow()); + } else { + refresh_eye_feed_controls(&widgets, &state.borrow()); + } + } + } + } + + while let Ok(message) = clipboard_rx.try_recv() { + match message { + ClipboardMessage::Finished(Ok(detail)) => { + widgets.status_label.set_text(&format!("✨ {detail}")); + } + ClipboardMessage::Finished(Err(err)) => { + widgets + .status_label + .set_text(&format!("Clipboard send failed: {err}")); + } + } + } + + let now = Instant::now(); + let child_running = child_proc.borrow().is_some(); + + if now >= next_power_probe.get() + && !power_request_in_flight.get() + && (child_running + || state.borrow().capture_power.enabled + || state.borrow().remote_active) + { + power_request_in_flight.set(true); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + request_capture_power_refresh(power_tx.clone(), server_addr, Duration::ZERO); + next_power_probe.set(now + Duration::from_secs(2)); + } + + if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() { + caps_request_in_flight.set(true); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + request_handshake_caps(caps_tx.clone(), server_addr, Duration::ZERO); + next_diagnostics_probe.set(now + Duration::from_secs(2)); + } + + if now >= next_diagnostics_sample.get() { + let network = diagnostics_network.borrow_mut().snapshot(); + let client_process_cpu_pct = diagnostics_process + .borrow_mut() + .sample_percent() + .unwrap_or(0.0); + record_diagnostics_sample( + &widgets, + &state.borrow(), + preview.as_ref().map(|preview| preview.as_ref()), + network, + client_process_cpu_pct, + ); + next_diagnostics_sample.set(now + Duration::from_secs(1)); + } + + let (camera_probe_active, camera_label, mic_probe_active) = { + let state = state.borrow(); + ( + state.channels.camera && state.devices.camera.is_some(), + state.devices.camera.clone(), + state.channels.microphone && state.devices.microphone.is_some(), + ) + }; + if let Err(err) = tests.borrow_mut().sync_relay_uplink_probe( + child_running, + camera_probe_active, + camera_label.as_deref(), + &camera_preview_path, + mic_probe_active, + &mic_level_path, + ) { + widgets + .status_label + .set_text(&format!("Local uplink monitor could not start: {err}")); + } + + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + glib::ControlFlow::Continue + }); + } +} diff --git a/client/src/launcher/ui/stage_device_bindings.rs b/client/src/launcher/ui/stage_device_bindings.rs new file mode 100644 index 0000000..21329b8 --- /dev/null +++ b/client/src/launcher/ui/stage_device_bindings.rs @@ -0,0 +1,174 @@ +{ + { + let state = Rc::clone(&state); + let catalog = Rc::clone(&catalog); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let camera_quality_syncing = Rc::clone(&camera_quality_syncing); + let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); + let camera_combo_read = camera_combo.clone(); + camera_combo.connect_changed(move |_| { + let selected = selected_combo_value(&camera_combo_read); + let preview_was_running = + tests.borrow_mut().is_running(DeviceTestKind::Camera); + { + let catalog = catalog.borrow(); + let mut state = state.borrow_mut(); + state.select_camera(selected.clone()); + camera_quality_syncing.set(true); + sync_camera_quality_selection(&camera_quality_combo, &mut state, &catalog); + camera_quality_syncing.set(false); + } + let quality = state.borrow().camera_quality; + if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) { + widgets + .status_label + .set_text(&format!("Camera preview update failed: {err}")); + } else if let Err(err) = tests.borrow_mut().set_camera_quality(quality) { + widgets + .status_label + .set_text(&format!("Camera quality update failed: {err}")); + } else if preview_was_running { + widgets.status_label.set_text(&format!( + "Local camera preview switched to {}{}.", + selected.as_deref().unwrap_or("no camera"), + quality + .map(|mode| format!(" at {}", mode.short_label())) + .unwrap_or_default() + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let camera_quality_syncing = Rc::clone(&camera_quality_syncing); + let camera_quality_combo = camera_quality_combo.clone(); + let camera_quality_combo_read = camera_quality_combo.clone(); + camera_quality_combo.connect_changed(move |_| { + if camera_quality_syncing.get() { + return; + } + let selected = selected_camera_quality(&camera_quality_combo_read); + let preview_was_running = + tests.borrow_mut().is_running(DeviceTestKind::Camera); + let Ok(mut state_mut) = state.try_borrow_mut() else { + return; + }; + state_mut.select_camera_quality(selected); + drop(state_mut); + if let Err(err) = tests.borrow_mut().set_camera_quality(selected) { + widgets + .status_label + .set_text(&format!("Camera quality update failed: {err}")); + } else if preview_was_running { + widgets.status_label.set_text(&format!( + "Local camera preview switched to {}.", + selected + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()) + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let keyboard_combo = keyboard_combo.clone(); + let keyboard_combo_read = keyboard_combo.clone(); + keyboard_combo.connect_changed(move |_| { + let selected = selected_combo_value(&keyboard_combo_read); + state.borrow_mut().select_keyboard(selected.clone()); + let message = match selected.as_deref() { + Some(path) => { + format!("The next relay launch will listen only to keyboard {path}.") + } + None => "The next relay launch will listen to all keyboards.".to_string(), + }; + widgets.status_label.set_text(&message); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let mouse_combo = mouse_combo.clone(); + let mouse_combo_read = mouse_combo.clone(); + mouse_combo.connect_changed(move |_| { + let selected = selected_combo_value(&mouse_combo_read); + state.borrow_mut().select_mouse(selected.clone()); + let message = match selected.as_deref() { + Some(path) => { + format!("The next relay launch will listen only to pointer {path}.") + } + None => { + "The next relay launch will listen to all pointer devices." + .to_string() + } + }; + widgets.status_label.set_text(&message); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + if let Some(preview) = preview.as_ref() { + preview.set_session_active(false); + } + request_capture_power_refresh( + power_tx.clone(), + selected_server_addr(&server_entry, server_addr.as_ref()), + Duration::ZERO, + ); + caps_request_in_flight.set(true); + request_handshake_caps( + caps_tx.clone(), + selected_server_addr(&server_entry, server_addr.as_ref()), + Duration::ZERO, + ); + + { + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_entry_read = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let preview = preview.clone(); + let power_tx = power_tx.clone(); + let caps_tx = caps_tx.clone(); + let caps_request_in_flight = Rc::clone(&caps_request_in_flight); + server_entry.connect_changed(move |_| { + let server_addr = + selected_server_addr(&server_entry_read, server_addr_fallback.as_ref()); + { + let mut state = state.borrow_mut(); + state.set_server_available(false); + state.set_server_version(None); + } + if let Some(preview) = preview.as_ref() { + preview.set_server_addr(server_addr.clone()); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + request_capture_power_refresh( + power_tx.clone(), + server_addr.clone(), + Duration::from_millis(150), + ); + caps_request_in_flight.set(true); + request_handshake_caps(caps_tx.clone(), server_addr, Duration::from_millis(150)); + }); + } +} diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs new file mode 100644 index 0000000..89e61ad --- /dev/null +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -0,0 +1,197 @@ +{ + { + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let clipboard_tx = clipboard_tx.clone(); + widgets.clipboard_button.connect_clicked(move |_| { + if child_proc.borrow().is_none() { + widgets + .status_label + .set_text("Start the relay before sending clipboard text."); + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + let Some(display) = gtk::gdk::Display::default() else { + widgets + .status_label + .set_text("No desktop clipboard is available in this session."); + return; + }; + widgets + .status_label + .set_text("Reading the local clipboard and preparing remote paste..."); + let clipboard = display.clipboard(); + let clipboard_tx = clipboard_tx.clone(); + clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| { + match result { + Ok(Some(text)) => { + let text = text.trim_end_matches(['\r', '\n']).to_string(); + if text.is_empty() { + let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( + "clipboard is empty".to_string(), + ))); + return; + } + let clipboard_tx = clipboard_tx.clone(); + std::thread::spawn(move || { + let result = send_clipboard_text_to_remote(&server_addr, &text) + .map_err(|err| err.to_string()); + let _ = clipboard_tx + .send(ClipboardMessage::Finished(result)); + }); + } + Ok(None) => { + let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( + "clipboard is empty".to_string(), + ))); + } + Err(err) => { + let _ = clipboard_tx.send(ClipboardMessage::Finished(Err( + format!("clipboard read failed: {err}"), + ))); + } + } + }); + }); + } + + { + let widgets = widgets.clone(); + widgets.probe_button.connect_clicked(move |_| { + if let Some(display) = gtk::gdk::Display::default() { + let clipboard = display.clipboard(); + clipboard.set_text(quality_probe_command()); + widgets + .status_label + .set_text("Quality probe command copied to the local clipboard."); + } else { + widgets + .status_label + .set_text("No desktop clipboard is available in this session."); + } + }); + } + + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let widgets_for_click = widgets.clone(); + widgets.usb_recover_button.connect_clicked(move |_| { + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_for_click.status_label.set_text( + "Requesting a forced USB gadget re-enumeration on the relay host...", + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = + reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); + let _ = tx.send(result); + }); + let widgets = widgets_for_click.clone(); + glib::timeout_add_local(Duration::from_millis(100), move || { + match rx.try_recv() { + Ok(Ok(())) => { + widgets.status_label.set_text( + "USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.", + ); + glib::ControlFlow::Break + } + Ok(Err(err)) => { + widgets + .status_label + .set_text(&format!("USB gadget recovery failed: {err}")); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + widgets.status_label.set_text( + "USB gadget recovery ended unexpectedly before the relay answered.", + ); + glib::ControlFlow::Break + } + } + }); + }); + } + + { + let widgets = widgets.clone(); + widgets.diagnostics_copy_button.connect_clicked(move |_| { + if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) { + widgets + .status_label + .set_text(&format!("Could not copy the diagnostics report: {err}")); + } else { + widgets + .status_label + .set_text("Diagnostics report copied to the local clipboard."); + } + }); + } + + { + let app = app.clone(); + let widgets = widgets.clone(); + let diagnostics_popout = Rc::clone(&diagnostics_popout); + widgets.diagnostics_popout_button.connect_clicked(move |_| { + open_diagnostics_popout( + &app, + &diagnostics_popout, + &widgets.diagnostics_popout_label, + &widgets.diagnostics_popout_scroll, + &widgets.diagnostics_rendered_text, + ); + widgets + .status_label + .set_text("Diagnostics report moved into its own window."); + }); + } + + { + let widgets = widgets.clone(); + widgets.console_level_combo.connect_changed(move |combo| { + let level = combo + .active_id() + .as_deref() + .and_then(ConsoleLogLevel::from_id) + .unwrap_or_default(); + *widgets.session_log_level.borrow_mut() = level; + widgets.status_label.set_text(&format!( + "Console now shows {} relay logs and higher.", + level.label() + )); + }); + } + + { + let widgets = widgets.clone(); + widgets.console_copy_button.connect_clicked(move |_| { + if let Err(err) = copy_session_log(&widgets.session_log_buffer) { + widgets + .status_label + .set_text(&format!("Could not copy the session log: {err}")); + } else { + widgets + .status_label + .set_text("Session log copied to the local clipboard."); + } + }); + } + + { + let app = app.clone(); + let widgets = widgets.clone(); + let log_popout = Rc::clone(&log_popout); + widgets.console_popout_button.connect_clicked(move |_| { + open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer); + widgets + .status_label + .set_text("Session log moved into its own window."); + }); + } +} diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index a3ed20c..10283cc 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -13,197 +13,13 @@ use super::{ }, }; -#[derive(Clone)] -pub struct SummaryWidgets { - pub relay_light: gtk::Box, - pub relay_value: gtk::Label, - pub routing_light: gtk::Box, - pub routing_value: gtk::Label, - pub gpio_light: gtk::Box, - pub gpio_value: gtk::Label, - pub shortcut_value: gtk::Label, -} - -#[derive(Clone)] -pub struct DisplayPaneWidgets { - pub root: gtk::Box, - pub stack: gtk::Stack, - pub preview_frame: gtk::AspectFrame, - pub picture: gtk::Picture, - pub stream_status: gtk::Label, - pub placeholder: gtk::Label, - pub feed_source_combo: gtk::ComboBoxText, - pub capture_resolution_combo: gtk::ComboBoxText, - pub breakout_combo: gtk::ComboBoxText, - pub action_button: gtk::Button, - pub preview_binding: Rc>>, - pub title: String, -} - -pub struct PopoutWindowHandle { - pub window: gtk::ApplicationWindow, - pub frame: gtk::AspectFrame, - pub picture: gtk::Picture, - pub status_label: gtk::Label, - pub binding: PreviewBinding, -} - -/// Minimum severity for relay log lines shown in the session console. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ConsoleLogLevel { - Error, - #[default] - Warn, - Info, - Debug, - Trace, -} - -impl ConsoleLogLevel { - pub const ALL: [Self; 5] = [ - Self::Error, - Self::Warn, - Self::Info, - Self::Debug, - Self::Trace, - ]; - - /// Stable value stored on the GTK combo row. - #[must_use] - pub const fn id(self) -> &'static str { - match self { - Self::Error => "error", - Self::Warn => "warn", - Self::Info => "info", - Self::Debug => "debug", - Self::Trace => "trace", - } - } - - /// Short label displayed in the launcher dropdown. - #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::Error => "Error", - Self::Warn => "Warn", - Self::Info => "Info", - Self::Debug => "Debug", - Self::Trace => "Trace", - } - } - - #[must_use] - #[doc = "Parses a GTK combo id back into a log level."] - pub fn from_id(raw: &str) -> Option { - match raw { - "error" => Some(Self::Error), - "warn" => Some(Self::Warn), - "info" => Some(Self::Info), - "debug" => Some(Self::Debug), - "trace" => Some(Self::Trace), - _ => None, - } - } - - /// Numeric ordering where lower values are more important. - #[must_use] - pub const fn rank(self) -> u8 { - match self { - Self::Error => 0, - Self::Warn => 1, - Self::Info => 2, - Self::Debug => 3, - Self::Trace => 4, - } - } -} - -#[derive(Clone)] -pub struct LauncherWidgets { - pub status_label: gtk::Label, - pub diagnostics_log: Rc>, - pub diagnostics_label: gtk::Label, - pub diagnostics_scroll: gtk::ScrolledWindow, - pub diagnostics_popout_label: Rc>>, - pub diagnostics_popout_scroll: Rc>>, - pub diagnostics_rendered_text: Rc>, - pub session_log_buffer: gtk::TextBuffer, - pub session_log_view: gtk::TextView, - pub summary: SummaryWidgets, - pub power_detail: gtk::Label, - pub audio_check_detail: gtk::Label, - pub audio_check_meter: gtk::ProgressBar, - pub display_panes: [DisplayPaneWidgets; 2], - pub server_entry: gtk::Entry, - pub start_button: gtk::Button, - pub camera_combo: gtk::ComboBoxText, - pub camera_quality_combo: gtk::ComboBoxText, - pub microphone_combo: gtk::ComboBoxText, - pub speaker_combo: gtk::ComboBoxText, - pub keyboard_combo: gtk::ComboBoxText, - pub mouse_combo: gtk::ComboBoxText, - pub camera_channel_toggle: gtk::CheckButton, - pub microphone_channel_toggle: gtk::CheckButton, - pub audio_channel_toggle: gtk::CheckButton, - pub power_auto_button: gtk::Button, - pub power_on_button: gtk::Button, - pub power_off_button: gtk::Button, - pub audio_gain_scale: gtk::Scale, - pub audio_gain_value: gtk::Label, - pub mic_gain_scale: gtk::Scale, - pub mic_gain_value: gtk::Label, - pub input_toggle_button: gtk::Button, - pub clipboard_button: gtk::Button, - pub probe_button: gtk::Button, - pub usb_recover_button: gtk::Button, - pub device_refresh_button: gtk::Button, - pub swap_key_button: gtk::Button, - pub camera_test_button: gtk::Button, - pub microphone_test_button: gtk::Button, - pub microphone_replay_button: gtk::Button, - pub speaker_test_button: gtk::Button, - pub diagnostics_copy_button: gtk::Button, - pub diagnostics_popout_button: gtk::Button, - pub console_copy_button: gtk::Button, - pub console_popout_button: gtk::Button, - pub console_level_combo: gtk::ComboBoxText, - pub session_log_level: Rc>, - pub _device_body_height_group: gtk::SizeGroup, -} - -#[derive(Clone)] -pub struct DeviceStageWidgets { - pub camera_preview: gtk::Picture, - pub camera_status: gtk::Label, -} - -pub struct LauncherView { - pub window: gtk::ApplicationWindow, - pub server_entry: gtk::Entry, - pub camera_combo: gtk::ComboBoxText, - pub camera_quality_combo: gtk::ComboBoxText, - pub microphone_combo: gtk::ComboBoxText, - pub speaker_combo: gtk::ComboBoxText, - pub keyboard_combo: gtk::ComboBoxText, - pub mouse_combo: gtk::ComboBoxText, - pub device_stage: DeviceStageWidgets, - pub widgets: LauncherWidgets, - pub preview: Option>, - pub popouts: Rc; 2]>>, - pub diagnostics_popout: Rc>>, - pub log_popout: Rc>>, -} - -pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; -const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons"); -const LAUNCHER_DEFAULT_WIDTH: i32 = 1360; -const LAUNCHER_DEFAULT_HEIGHT: i32 = 940; -const OPERATIONS_RAIL_WIDTH: i32 = 288; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; -const EYE_PREVIEW_MIN_HEIGHT: i32 = 320; -const EYE_PREVIEW_MIN_WIDTH: i32 = 568; -const SIDE_LOG_MIN_HEIGHT: i32 = 124; +include!("ui_components/types.rs"); +include!("ui_components/build_contexts.rs"); +include!("ui_components/style.rs"); +include!("ui_components/panel_chips.rs"); +include!("ui_components/combo_helpers.rs"); +include!("ui_components/display_pane.rs"); +include!("ui_components/scale_reset.rs"); pub fn build_launcher_view( app: >k::Application, @@ -211,1411 +27,78 @@ pub fn build_launcher_view( catalog: &DeviceCatalog, state: &LauncherState, ) -> LauncherView { - let window = gtk::ApplicationWindow::builder() - .application(app) - .title("Lesavka") - .default_width(LAUNCHER_DEFAULT_WIDTH) - .default_height(LAUNCHER_DEFAULT_HEIGHT) - .resizable(false) - .build(); - window.set_size_request(LAUNCHER_DEFAULT_WIDTH, LAUNCHER_DEFAULT_HEIGHT); - install_css(&window); - install_window_icon(&window); - - let root = gtk::Box::new(gtk::Orientation::Vertical, 8); - root.add_css_class("launcher-root"); - root.set_margin_start(10); - root.set_margin_end(10); - root.set_margin_top(10); - root.set_margin_bottom(10); - - let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8); - hero.set_hexpand(true); - - let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0); - brand_box.set_valign(gtk::Align::Center); - let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - brand_row.set_halign(gtk::Align::Start); - brand_row.set_valign(gtk::Align::Center); - let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME); - brand_icon.add_css_class("app-logo"); - brand_icon.set_pixel_size(44); - brand_icon.set_valign(gtk::Align::Center); - let heading = gtk::Label::new(Some("Lesavka")); - heading.add_css_class("title-2"); - heading.set_halign(gtk::Align::Start); - heading.set_valign(gtk::Align::Center); - let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION))); - version_tag.add_css_class("version-tag"); - version_tag.set_halign(gtk::Align::Start); - version_tag.set_valign(gtk::Align::Center); - brand_row.append(&brand_icon); - brand_row.append(&heading); - brand_row.append(&version_tag); - brand_box.append(&brand_row); - hero.append(&brand_box); - - let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6); - chips.set_halign(gtk::Align::End); - chips.set_hexpand(true); - let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", ""); - let (routing_chip, routing_light, routing_value) = - build_status_chip_with_light("Inputs", "Local"); - let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown"); - let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); - chips.append(&relay_chip); - chips.append(&routing_chip); - chips.append(&gpio_chip); - chips.append(&shortcut_chip); - hero.append(&chips); - root.append(&hero); - - let content = gtk::Box::new(gtk::Orientation::Horizontal, 8); - content.set_hexpand(true); - content.set_vexpand(true); - root.append(&content); - - let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8); - workspace.set_hexpand(true); - workspace.set_vexpand(true); - content.append(&workspace); - - let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); - operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1); - operations.set_hexpand(false); - operations.set_vexpand(true); - operations.set_valign(gtk::Align::Fill); - content.append(&operations); - - let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - display_row.set_hexpand(true); - display_row.set_vexpand(true); - display_row.set_homogeneous(true); - let left_pane = build_display_pane("Left Eye", "/dev/lesavka_l_eye"); - let right_pane = build_display_pane("Right Eye", "/dev/lesavka_r_eye"); - display_row.append(&left_pane.root); - display_row.append(&right_pane.root); - workspace.append(&display_row); - - let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - staging_row.set_hexpand(true); - staging_row.set_vexpand(false); - staging_row.set_valign(gtk::Align::Start); - staging_row.set_homogeneous(true); - workspace.append(&staging_row); - - let device_refresh_button = gtk::Button::with_label("Refresh Devices"); - stabilize_button(&device_refresh_button, 132); - device_refresh_button.set_tooltip_text(Some("Re-scan connected devices.")); - let (devices_panel, devices_body) = - build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref())); - devices_panel.set_hexpand(true); - devices_panel.set_vexpand(false); - devices_panel.set_valign(gtk::Align::Fill); - devices_body.set_spacing(8); - - let control_group = build_subgroup("Control Inputs"); - let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10); - control_group.append(&control_stack); - - let camera_combo = gtk::ComboBoxText::new(); - sync_stage_device_combo( - &camera_combo, - &catalog.cameras, - state.devices.camera.as_deref(), - ); - let camera_quality_combo = gtk::ComboBoxText::new(); - sync_camera_quality_combo( - &camera_quality_combo, - &state.camera_quality_options(catalog), - state.selected_camera_quality(catalog), - ); - camera_quality_combo.set_size_request(88, -1); - camera_quality_combo.set_tooltip_text(Some("Webcam uplink quality.")); - let camera_test_button = gtk::Button::with_label("Start Preview"); - stabilize_button(&camera_test_button, 118); - camera_test_button.set_tooltip_text(Some("Preview selected webcam locally.")); - - let speaker_combo = gtk::ComboBoxText::new(); - sync_stage_device_combo( - &speaker_combo, - &catalog.speakers, - state.devices.speaker.as_deref(), - ); - let speaker_test_button = gtk::Button::with_label("Play Tone"); - stabilize_button(&speaker_test_button, 118); - speaker_test_button.set_tooltip_text(Some("Play a local test tone.")); - - let keyboard_combo = gtk::ComboBoxText::new(); - keyboard_combo.append(Some("all"), "all keyboards"); - for keyboard in &catalog.keyboards { - append_input_choice(&keyboard_combo, keyboard); - } - super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref()); - keyboard_combo.set_tooltip_text(Some("Keyboard source for relay input.")); - let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo); - control_stack.append(&keyboard_row); - - let mouse_combo = gtk::ComboBoxText::new(); - mouse_combo.append(Some("all"), "all mice"); - for mouse in &catalog.mice { - append_input_choice(&mouse_combo, mouse); - } - super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref()); - mouse_combo.set_tooltip_text(Some("Pointer source for relay input.")); - let mouse_row = build_inline_selector_row("Mouse", &mouse_combo); - control_stack.append(&mouse_row); - devices_body.append(&control_group); - - let media_group = build_subgroup("Media Controls"); - let media_grid = gtk::Grid::new(); - media_grid.set_row_spacing(10); - media_grid.set_column_spacing(8); - media_group.append(&media_grid); - let camera_channel_toggle = gtk::CheckButton::with_label("Camera"); - camera_channel_toggle.set_active(state.channels.camera); - camera_channel_toggle.set_tooltip_text(Some("Send webcam during relay.")); - let audio_channel_toggle = gtk::CheckButton::with_label("Speaker"); - audio_channel_toggle.set_active(state.channels.audio); - audio_channel_toggle.set_tooltip_text(Some("Play remote audio here.")); - let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); - microphone_channel_toggle.set_active(state.channels.microphone); - microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay.")); - - let audio_gain_adjustment = gtk::Adjustment::new( - f64::from(state.audio_gain_percent), - 0.0, - f64::from(super::state::MAX_AUDIO_GAIN_PERCENT), - 25.0, - 100.0, - 0.0, - ); - let audio_gain_scale = - gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); - audio_gain_scale.set_draw_value(false); - audio_gain_scale.set_hexpand(false); - audio_gain_scale.set_size_request(96, -1); - audio_gain_scale.set_tooltip_text(Some("Speaker volume. Double-click resets to 200%.")); - attach_scale_reset_gesture( - &audio_gain_scale, - f64::from(super::state::DEFAULT_AUDIO_GAIN_PERCENT), - ); - let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); - audio_gain_value.set_visible(false); - - let mic_gain_adjustment = gtk::Adjustment::new( - f64::from(state.mic_gain_percent), - 0.0, - f64::from(super::state::MAX_MIC_GAIN_PERCENT), - 25.0, - 100.0, - 0.0, - ); - let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment)); - mic_gain_scale.set_draw_value(false); - mic_gain_scale.set_hexpand(false); - mic_gain_scale.set_size_request(96, -1); - mic_gain_scale.set_tooltip_text(Some("Mic gain. Double-click resets to 100%.")); - attach_scale_reset_gesture( - &mic_gain_scale, - f64::from(super::state::DEFAULT_MIC_GAIN_PERCENT), - ); - let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); - mic_gain_value.set_visible(false); - - camera_combo.set_size_request(0, -1); - let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); - camera_combo.set_hexpand(true); - camera_quality_combo.set_hexpand(false); - camera_selectors.append(&camera_combo); - camera_selectors.append(&camera_quality_combo); - speaker_combo.set_size_request(0, -1); - attach_device_control_row( - &media_grid, - 0, - &camera_channel_toggle, - &camera_selectors, - &camera_test_button, - ); - let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); - speaker_combo.set_hexpand(true); - speaker_selectors.append(&speaker_combo); - speaker_selectors.append(&audio_gain_scale); - attach_device_control_row( - &media_grid, - 1, - &audio_channel_toggle, - &speaker_selectors, - &speaker_test_button, - ); - - let microphone_combo = gtk::ComboBoxText::new(); - sync_stage_device_combo( - µphone_combo, - &catalog.microphones, - state.devices.microphone.as_deref(), - ); - let microphone_test_button = gtk::Button::with_label("Monitor Mic"); - stabilize_button(µphone_test_button, 118); - microphone_test_button.set_tooltip_text(Some("Monitor mic through speaker.")); - microphone_combo.set_size_request(0, -1); - let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); - microphone_combo.set_hexpand(true); - microphone_selectors.append(µphone_combo); - microphone_selectors.append(&mic_gain_scale); - attach_device_control_row( - &media_grid, - 2, - µphone_channel_toggle, - µphone_selectors, - µphone_test_button, - ); - - let audio_check_detail = gtk::Label::new(Some("Idle")); - audio_check_detail.add_css_class("dim-label"); - audio_check_detail.set_wrap(false); - audio_check_detail.set_ellipsize(pango::EllipsizeMode::End); - audio_check_detail.set_xalign(0.0); - audio_check_detail.set_visible(false); - let audio_check_meter = gtk::ProgressBar::new(); - audio_check_meter.add_css_class("audio-check-meter"); - audio_check_meter.set_show_text(false); - devices_body.append(&media_group); - staging_row.append(&devices_panel); - - let (preview_panel, preview_body) = build_panel("Device Testing"); - preview_panel.set_hexpand(true); - preview_panel.set_vexpand(false); - preview_panel.set_valign(gtk::Align::Fill); - preview_body.set_vexpand(false); - preview_body.set_spacing(8); - let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - testing_row.set_hexpand(true); - testing_row.set_vexpand(true); - testing_row.set_valign(gtk::Align::Fill); - let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical); - device_body_height_group.add_widget(&devices_body); - device_body_height_group.add_widget(&testing_row); - let camera_preview = gtk::Picture::new(); - camera_preview.set_can_shrink(false); - camera_preview.set_hexpand(true); - camera_preview.set_vexpand(true); - camera_preview.set_halign(gtk::Align::Fill); - camera_preview.set_valign(gtk::Align::Fill); - camera_preview.set_size_request( - CAMERA_PREVIEW_VIEWPORT_WIDTH, - CAMERA_PREVIEW_VIEWPORT_HEIGHT, - ); - camera_preview.set_keep_aspect_ratio(true); - camera_preview.add_css_class("camera-preview-frame"); - let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview.")); - camera_status.add_css_class("dim-label"); - camera_status.set_wrap(false); - camera_status.set_ellipsize(pango::EllipsizeMode::End); - camera_status.set_xalign(0.0); - camera_status.set_visible(false); - let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); - camera_preview_shell.set_hexpand(true); - camera_preview_shell.set_vexpand(true); - camera_preview_shell.set_halign(gtk::Align::Fill); - camera_preview_shell.set_valign(gtk::Align::Fill); - camera_preview_shell.set_size_request( - CAMERA_PREVIEW_VIEWPORT_WIDTH, - CAMERA_PREVIEW_VIEWPORT_HEIGHT, - ); - let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); - camera_preview_frame.set_hexpand(true); - camera_preview_frame.set_vexpand(true); - camera_preview_frame.set_halign(gtk::Align::Fill); - camera_preview_frame.set_valign(gtk::Align::Fill); - camera_preview_frame.set_size_request( - CAMERA_PREVIEW_VIEWPORT_WIDTH, - CAMERA_PREVIEW_VIEWPORT_HEIGHT, - ); - camera_preview_frame.set_child(Some(&camera_preview)); - camera_preview_shell.append(&camera_preview_frame); - let webcam_group = build_subgroup("Webcam Preview"); - webcam_group.set_hexpand(true); - webcam_group.set_vexpand(true); - webcam_group.set_valign(gtk::Align::Fill); - webcam_group.append(&camera_preview_shell); - testing_row.append(&webcam_group); - - let playback_group = build_subgroup("Mic Playback"); - playback_group.set_hexpand(false); - playback_group.set_vexpand(true); - playback_group.set_valign(gtk::Align::Fill); - playback_group.set_size_request(72, -1); - let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); - playback_body.set_halign(gtk::Align::Center); - playback_body.set_vexpand(true); - playback_body.set_valign(gtk::Align::Fill); - let microphone_replay_button = gtk::Button::with_label("Replay"); - stabilize_button(µphone_replay_button, 70); - audio_check_meter.set_orientation(gtk::Orientation::Vertical); - audio_check_meter.set_inverted(true); - audio_check_meter.set_hexpand(false); - audio_check_meter.set_vexpand(true); - audio_check_meter.set_halign(gtk::Align::Center); - audio_check_meter.set_size_request(20, 0); - audio_check_meter.set_show_text(false); - audio_check_meter.set_text(Some("Idle")); - playback_body.append(&audio_check_meter); - playback_body.append(µphone_replay_button); - playback_group.append(&playback_body); - testing_row.append(&playback_group); - preview_body.append(&testing_row); - staging_row.append(&preview_panel); - - let (connection_panel, connection_body) = build_panel("Relay Controls"); - let server_entry = gtk::Entry::new(); - server_entry.add_css_class("server-entry"); - server_entry.set_hexpand(true); - server_entry.set_width_chars(18); - server_entry.set_text(server_addr); - server_entry.set_tooltip_text(Some("Relay host address.")); - let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - relay_row.set_halign(gtk::Align::Fill); - relay_row.set_hexpand(true); - relay_row.append(&server_entry); - let start_button = gtk::Button::with_label("Connect"); - start_button.add_css_class("suggested-action"); - start_button.set_hexpand(false); - stabilize_button(&start_button, 108); - relay_row.append(&start_button); - connection_body.append(&relay_row); - - let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - live_actions_row.set_homogeneous(true); - let clipboard_button = gtk::Button::with_label("Send Clipboard"); - clipboard_button.set_hexpand(true); - stabilize_button(&clipboard_button, 108); - clipboard_button.set_tooltip_text(Some("Type clipboard remotely.")); - let probe_button = gtk::Button::with_label("Copy Gate Probe"); - probe_button.set_hexpand(true); - stabilize_button(&probe_button, 108); - probe_button.set_tooltip_text(Some("Copy quality probe.")); - let usb_recover_button = gtk::Button::with_label("Recover USB"); - usb_recover_button.set_hexpand(true); - stabilize_button(&usb_recover_button, 108); - usb_recover_button.set_tooltip_text(Some("Re-enumerate remote USB.")); - live_actions_row.append(&clipboard_button); - live_actions_row.append(&probe_button); - live_actions_row.append(&usb_recover_button); - connection_body.append(&live_actions_row); - - connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); - let power_heading = gtk::Label::new(Some("GPIO Power")); - power_heading.add_css_class("subgroup-title"); - power_heading.set_halign(gtk::Align::Start); - - let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); - power_shell.set_halign(gtk::Align::Fill); - let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - power_row.set_hexpand(true); - power_heading.set_width_chars(10); - power_row.append(&power_heading); - let power_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); - power_buttons.set_hexpand(true); - power_buttons.set_homogeneous(true); - let power_on_button = gtk::Button::with_label("On"); - power_on_button.set_hexpand(true); - stabilize_button(&power_on_button, 52); - power_on_button.add_css_class("pill-toggle"); - let power_auto_button = gtk::Button::with_label("Auto"); - power_auto_button.set_hexpand(true); - stabilize_button(&power_auto_button, 52); - power_auto_button.add_css_class("pill-toggle"); - let power_off_button = gtk::Button::with_label("Off"); - power_off_button.set_hexpand(true); - stabilize_button(&power_off_button, 52); - power_off_button.add_css_class("pill-toggle"); - let power_detail = gtk::Label::new(Some("Capture power status is loading...")); - power_detail.add_css_class("dim-label"); - power_detail.set_wrap(true); - power_detail.set_xalign(0.0); - power_buttons.append(&power_on_button); - power_buttons.append(&power_auto_button); - power_buttons.append(&power_off_button); - power_row.append(&power_buttons); - power_shell.append(&power_row); - connection_body.append(&power_shell); - let routing_heading = gtk::Label::new(Some("Inputs")); - routing_heading.add_css_class("subgroup-title"); - routing_heading.set_halign(gtk::Align::Start); - connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); - - let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - routing_row.set_hexpand(true); - routing_heading.set_width_chars(10); - routing_row.append(&routing_heading); - let routing_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); - routing_buttons.set_hexpand(true); - routing_buttons.set_homogeneous(true); - let input_toggle_button = gtk::Button::with_label("Route"); - input_toggle_button.set_hexpand(true); - stabilize_button(&input_toggle_button, 106); - input_toggle_button.set_tooltip_text(Some("Swap input ownership.")); - let swap_key_button = gtk::Button::with_label("Set Swap Key"); - swap_key_button.set_hexpand(true); - stabilize_button(&swap_key_button, 106); - routing_buttons.append(&input_toggle_button); - routing_buttons.append(&swap_key_button); - routing_row.append(&routing_buttons); - connection_body.append(&routing_row); - operations.append(&connection_panel); - - let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics"); - diagnostics_panel.set_vexpand(true); - diagnostics_panel.set_valign(gtk::Align::Fill); - diagnostics_body.set_vexpand(true); - let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); - diagnostics_toolbar.set_homogeneous(true); - let diagnostics_copy_button = gtk::Button::with_label("Copy Report"); - stabilize_button(&diagnostics_copy_button, 112); - let diagnostics_popout_button = gtk::Button::with_label("Break Out"); - stabilize_button(&diagnostics_popout_button, 112); - diagnostics_toolbar.append(&diagnostics_copy_button); - diagnostics_toolbar.append(&diagnostics_popout_button); - let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16))); - let diagnostics_label = gtk::Label::new(None); - diagnostics_label.add_css_class("status-log"); - diagnostics_label.set_selectable(true); - diagnostics_label.set_xalign(0.0); - diagnostics_label.set_yalign(0.0); - diagnostics_label.set_wrap(false); - diagnostics_label.set_halign(gtk::Align::Start); - diagnostics_label.set_valign(gtk::Align::Start); - diagnostics_label.set_hexpand(true); - let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); - diagnostics_shell.set_hexpand(true); - diagnostics_shell.set_vexpand(false); - diagnostics_shell.append(&diagnostics_label); - let diagnostics_scroll = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .min_content_height(SIDE_LOG_MIN_HEIGHT) - .child(&diagnostics_shell) - .build(); - diagnostics_body.append(&diagnostics_toolbar); - diagnostics_body.append(&diagnostics_scroll); - operations.append(&diagnostics_panel); - - let (console_panel, console_body) = build_panel("Session Console"); - console_panel.set_vexpand(true); - console_panel.set_valign(gtk::Align::Fill); - console_body.set_vexpand(true); - let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let session_log_level = Rc::new(RefCell::new(ConsoleLogLevel::default())); - let console_level_combo = gtk::ComboBoxText::new(); - for level in ConsoleLogLevel::ALL { - console_level_combo.append(Some(level.id()), level.label()); - } - console_level_combo.set_active_id(Some(ConsoleLogLevel::default().id())); - console_level_combo.set_size_request(78, 36); - console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher.")); - let console_copy_button = gtk::Button::with_label("Copy"); - console_copy_button.set_tooltip_text(Some("Copy visible log.")); - let console_popout_button = gtk::Button::with_label("Pop Out"); - console_popout_button.set_tooltip_text(Some("Open log window.")); - let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); - console_buttons.set_hexpand(true); - console_buttons.set_homogeneous(true); - console_copy_button.set_hexpand(true); - console_popout_button.set_hexpand(true); - console_buttons.append(&console_copy_button); - console_buttons.append(&console_popout_button); - console_toolbar.append(&console_level_combo); - console_toolbar.append(&console_buttons); - let status_label = gtk::Label::new(Some("Session log ready.")); - status_label.add_css_class("status-line"); - status_label.set_halign(gtk::Align::Start); - status_label.set_wrap(true); - status_label.set_xalign(0.0); - let session_log_buffer = gtk::TextBuffer::new(None); - session_log_buffer.create_tag(Some("log-launcher"), &[("foreground", &"#8bd5ca")]); - session_log_buffer.create_tag(Some("log-relay"), &[("foreground", &"#89b4fa")]); - session_log_buffer.create_tag(Some("log-preview"), &[("foreground", &"#cba6f7")]); - session_log_buffer.create_tag(Some("log-stderr"), &[("foreground", &"#f9e2af")]); - session_log_buffer.create_tag(Some("log-warn"), &[("foreground", &"#fab387")]); - session_log_buffer.create_tag(Some("log-error"), &[("foreground", &"#f38ba8")]); - super::ui_runtime::append_session_log(&session_log_buffer, "[launcher] Session log ready."); - let session_log_view = gtk::TextView::with_buffer(&session_log_buffer); - session_log_view.add_css_class("status-log"); - session_log_view.set_editable(false); - session_log_view.set_cursor_visible(false); - session_log_view.set_monospace(true); - session_log_view.set_wrap_mode(gtk::WrapMode::WordChar); - let log_scroll = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .min_content_height(SIDE_LOG_MIN_HEIGHT) - .child(&session_log_view) - .build(); - console_body.append(&console_toolbar); - console_body.append(&log_scroll); - operations.append(&console_panel); - - { - let buffer = session_log_buffer.clone(); - let view = session_log_view.clone(); - status_label.connect_notify_local(Some("label"), move |label, _| { - super::ui_runtime::append_session_log(&buffer, &format!("[launcher] {}", label.text())); - let mut end = buffer.end_iter(); - view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); - }); - } - - let preview = match LauncherPreview::new(server_addr.to_string()) { - Ok(preview) => Some(Rc::new(preview)), - Err(err) => { - status_label.set_text(&format!("Preview unavailable: {err}")); - None - } - }; - - let left_pane = left_pane; - let right_pane = right_pane; - if let Some(preview) = preview.as_ref() { - *left_pane.preview_binding.borrow_mut() = - if state.feed_source_preset(0) == FeedSourcePreset::Off { - None - } else { - preview.install_on_picture( - 0, - PreviewSurface::Inline, - &left_pane.picture, - &left_pane.stream_status, - ) - }; - *right_pane.preview_binding.borrow_mut() = - if state.feed_source_preset(1) == FeedSourcePreset::Off { - None - } else { - preview.install_on_picture( - 1, - PreviewSurface::Inline, - &right_pane.picture, - &right_pane.stream_status, - ) - }; - } else { - left_pane.stream_status.set_text("Preview unavailable"); - right_pane.stream_status.set_text("Preview unavailable"); - } - sync_feed_source_combo( - &left_pane.feed_source_combo, - state.feed_source_options(0), - state.feed_source_preset(0), - ); - sync_feed_source_combo( - &right_pane.feed_source_combo, - state.feed_source_options(1), - state.feed_source_preset(1), - ); - if state.feed_source_preset(0) != FeedSourcePreset::Off { - let choice = state - .display_capture_size_choice(0) - .unwrap_or_else(|| state.capture_size_choice(0)); - if state.feed_source_preset(0) == FeedSourcePreset::ThisEye { - sync_capture_resolution_combo( - &left_pane.capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(0), - ); - } else { - sync_capture_resolution_locked( - &left_pane.capture_resolution_combo, - state.capture_size_options(), - choice.preset, - ); - } - } else { - sync_capture_resolution_disabled(&left_pane.capture_resolution_combo); - } - if state.feed_source_preset(1) != FeedSourcePreset::Off { - let choice = state - .display_capture_size_choice(1) - .unwrap_or_else(|| state.capture_size_choice(1)); - if state.feed_source_preset(1) == FeedSourcePreset::ThisEye { - sync_capture_resolution_combo( - &right_pane.capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(1), - ); - } else { - sync_capture_resolution_locked( - &right_pane.capture_resolution_combo, - state.capture_size_options(), - choice.preset, - ); - } - } else { - sync_capture_resolution_disabled(&right_pane.capture_resolution_combo); - } - sync_breakout_size_combo( - &left_pane.breakout_combo, - state.breakout_size_options(0), - state.breakout_size_preset(0), - ); - sync_breakout_size_combo( - &right_pane.breakout_combo, - state.breakout_size_options(1), - state.breakout_size_preset(1), - ); - let diagnostics_popout_label = Rc::new(RefCell::new(None)); - let diagnostics_popout_scroll = Rc::new(RefCell::new(None)); - - let widgets = LauncherWidgets { - status_label: status_label.clone(), - diagnostics_log: diagnostics_log.clone(), - diagnostics_label: diagnostics_label.clone(), - diagnostics_scroll: diagnostics_scroll.clone(), - diagnostics_popout_label: diagnostics_popout_label.clone(), - diagnostics_popout_scroll: diagnostics_popout_scroll.clone(), - diagnostics_rendered_text: Rc::new(RefCell::new(String::new())), - session_log_buffer: session_log_buffer.clone(), - session_log_view: session_log_view.clone(), - summary: SummaryWidgets { - relay_light, - relay_value, - routing_light, - routing_value, - gpio_light, - gpio_value, - shortcut_value, - }, - power_detail, - audio_check_detail, - audio_check_meter, - display_panes: [left_pane.clone(), right_pane.clone()], - server_entry: server_entry.clone(), - start_button: start_button.clone(), - camera_combo: camera_combo.clone(), - camera_quality_combo: camera_quality_combo.clone(), - microphone_combo: microphone_combo.clone(), - speaker_combo: speaker_combo.clone(), - keyboard_combo: keyboard_combo.clone(), - mouse_combo: mouse_combo.clone(), - camera_channel_toggle: camera_channel_toggle.clone(), - microphone_channel_toggle: microphone_channel_toggle.clone(), - audio_channel_toggle: audio_channel_toggle.clone(), - power_auto_button: power_auto_button.clone(), - power_on_button: power_on_button.clone(), - power_off_button: power_off_button.clone(), - audio_gain_scale: audio_gain_scale.clone(), - audio_gain_value: audio_gain_value.clone(), - mic_gain_scale: mic_gain_scale.clone(), - mic_gain_value: mic_gain_value.clone(), - input_toggle_button: input_toggle_button.clone(), - clipboard_button: clipboard_button.clone(), - probe_button: probe_button.clone(), - usb_recover_button: usb_recover_button.clone(), - device_refresh_button: device_refresh_button.clone(), - swap_key_button: swap_key_button.clone(), - camera_test_button: camera_test_button.clone(), - microphone_test_button: microphone_test_button.clone(), - microphone_replay_button: microphone_replay_button.clone(), - speaker_test_button: speaker_test_button.clone(), - diagnostics_copy_button: diagnostics_copy_button.clone(), - diagnostics_popout_button: diagnostics_popout_button.clone(), - console_copy_button: console_copy_button.clone(), - console_popout_button: console_popout_button.clone(), - console_level_combo: console_level_combo.clone(), - session_log_level: session_log_level.clone(), - _device_body_height_group: device_body_height_group, - }; - let popouts = Rc::new(RefCell::new([None, None])); - let diagnostics_popout = Rc::new(RefCell::new(None)); - let log_popout = Rc::new(RefCell::new(None)); - - super::ui_runtime::refresh_diagnostics_report(&widgets, state, false); - - window.set_child(Some(&root)); - - LauncherView { + let LauncherShellContext { window, - server_entry, + root, + staging_row, + operations, + left_pane, + right_pane, + relay_light, + relay_value, + routing_light, + routing_value, + gpio_light, + gpio_value, + shortcut_value, + } = include!("ui_components/build_shell.rs"); + + let DeviceControlsContext { + device_refresh_button, camera_combo, camera_quality_combo, microphone_combo, speaker_combo, keyboard_combo, mouse_combo, - device_stage: DeviceStageWidgets { - camera_preview, - camera_status, - }, - widgets, + camera_channel_toggle, + microphone_channel_toggle, + audio_channel_toggle, + audio_gain_scale, + audio_gain_value, + mic_gain_scale, + mic_gain_value, + audio_check_detail, + audio_check_meter, + device_body_height_group, + camera_preview, + camera_status, + camera_test_button, + microphone_test_button, + microphone_replay_button, + speaker_test_button, + } = include!("ui_components/build_device_controls.rs"); + + let OperationsRailContext { + server_entry, + start_button, + clipboard_button, + probe_button, + usb_recover_button, + power_auto_button, + power_on_button, + power_off_button, + power_detail, + input_toggle_button, + swap_key_button, + diagnostics_copy_button, + diagnostics_popout_button, + diagnostics_log, + diagnostics_label, + diagnostics_scroll, + console_copy_button, + console_popout_button, + console_level_combo, + session_log_level, + status_label, + session_log_buffer, + session_log_view, preview, - popouts, - diagnostics_popout, - log_popout, - } -} + } = include!("ui_components/build_operations_rail.rs"); -pub fn install_css(window: >k::ApplicationWindow) { - let provider = gtk::CssProvider::new(); - provider.load_from_data( - r" - window.lesavka { - background: #101319; - color: #eef2f7; - } - box.launcher-root { - background: linear-gradient(180deg, #11161f 0%, #161d28 100%); - } - box.panel { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 18px; - padding: 10px; - } - box.subgroup { - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; - padding: 8px; - } - label.panel-title { - font-weight: 700; - font-size: 1.05rem; - margin-bottom: 4px; - } - label.subgroup-title { - font-weight: 700; - opacity: 0.92; - } - label.version-tag { - font-size: 0.76rem; - opacity: 0.72; - } - image.app-logo { - opacity: 0.96; - } - box.status-chip { - background: rgba(91, 179, 162, 0.12); - border: 1px solid rgba(91, 179, 162, 0.25); - border-radius: 999px; - padding: 6px 9px; - } - box.status-light { - min-width: 10px; - min-height: 10px; - border-radius: 999px; - background: rgba(214, 81, 81, 0.92); - } - box.status-light-live { - background: rgba(96, 214, 126, 0.95); - } - box.status-light-idle { - background: rgba(214, 81, 81, 0.92); - } - box.status-light-warning { - background: rgba(242, 143, 54, 0.95); - } - box.status-light-caution { - background: rgba(227, 201, 73, 0.95); - } - label.status-chip-label { - font-size: 0.78rem; - opacity: 0.72; - } - label.status-chip-value { - font-size: 0.93rem; - font-weight: 700; - } - box.display-card { - background: rgba(255, 255, 255, 0.045); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 22px; - padding: 16px; - } - box.display-placeholder { - background: rgba(255, 255, 255, 0.03); - border: 1px dashed rgba(255, 255, 255, 0.18); - border-radius: 16px; - padding: 24px; - } - picture.camera-preview-frame { - background: rgba(0, 0, 0, 0.28); - border: 1px solid rgba(255, 255, 255, 0.10); - border-radius: 14px; - } - label.status-line { - opacity: 0.9; - } - label.eye-inline-status { - font-size: 0.86rem; - font-weight: 600; - background: rgba(91, 179, 162, 0.10); - border: 1px solid rgba(91, 179, 162, 0.22); - border-radius: 999px; - padding: 5px 8px; - opacity: 0.9; - } - textview.status-log, - label.status-log { - font-family: monospace; - background: rgba(0, 0, 0, 0.22); - border-radius: 14px; - padding: 10px; - } - progressbar.audio-check-meter trough { - min-width: 14px; - min-height: 10px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - } - progressbar.audio-check-meter.vertical trough { - min-height: 116px; - } - progressbar.audio-check-meter progress { - border-radius: 999px; - background: rgba(91, 179, 162, 0.88); - } - entry.server-entry { - min-height: 38px; - } - button.pill-toggle { - min-height: 36px; - padding: 0 14px; - } - button.pill-toggle-active { - background: rgba(91, 179, 162, 0.2); - border-color: rgba(91, 179, 162, 0.45); - font-weight: 700; - } - ", - ); - if let Some(display) = gtk::gdk::Display::default() { - gtk::style_context_add_provider_for_display( - &display, - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - } - window.add_css_class("lesavka"); -} - -pub fn install_window_icon(window: &impl IsA) { - if let Some(display) = gtk::gdk::Display::default() { - let theme = gtk::IconTheme::for_display(&display); - theme.add_search_path(LESAVKA_ICON_SEARCH_PATH); - } - gtk::Window::set_default_icon_name(LESAVKA_ICON_NAME); - window.as_ref().set_icon_name(Some(LESAVKA_ICON_NAME)); -} - -fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { - build_panel_with_action(title, None) -} - -fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) { - let panel = gtk::Box::new(gtk::Orientation::Vertical, 8); - panel.add_css_class("panel"); - - let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); - header.set_hexpand(true); - header.set_halign(gtk::Align::Fill); - let heading = gtk::Label::new(Some(title)); - heading.add_css_class("panel-title"); - heading.set_halign(gtk::Align::Start); - heading.set_hexpand(true); - header.append(&heading); - if let Some(action) = action { - header.append(action); - } - panel.append(&header); - - let body = gtk::Box::new(gtk::Orientation::Vertical, 8); - panel.append(&body); - (panel, body) -} - -fn build_subgroup(title: &str) -> gtk::Box { - let group = gtk::Box::new(gtk::Orientation::Vertical, 8); - group.add_css_class("subgroup"); - let heading = gtk::Label::new(Some(title)); - heading.add_css_class("subgroup-title"); - heading.set_halign(gtk::Align::Start); - group.append(&heading); - group -} - -fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { - let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); - chip.add_css_class("status-chip"); - chip.set_hexpand(false); - - let label_widget = gtk::Label::new(Some(label)); - label_widget.add_css_class("status-chip-label"); - label_widget.set_halign(gtk::Align::Start); - let value_widget = gtk::Label::new(Some(value)); - value_widget.add_css_class("status-chip-value"); - value_widget.set_halign(gtk::Align::Start); - chip.append(&label_widget); - chip.append(&value_widget); - (chip, value_widget) -} - -fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) { - let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); - chip.add_css_class("status-chip"); - chip.set_hexpand(false); - - let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); - meta.add_css_class("status-chip-meta"); - let light = gtk::Box::new(gtk::Orientation::Horizontal, 0); - light.add_css_class("status-light"); - light.add_css_class("status-light-idle"); - let label_widget = gtk::Label::new(Some(label)); - label_widget.add_css_class("status-chip-label"); - label_widget.set_halign(gtk::Align::Start); - meta.append(&light); - meta.append(&label_widget); - let value_widget = gtk::Label::new(Some(value)); - value_widget.add_css_class("status-chip-value"); - value_widget.set_halign(gtk::Align::Start); - chip.append(&meta); - chip.append(&value_widget); - (chip, light, value_widget) -} - -pub fn sync_feed_source_combo( - combo: >k::ComboBoxText, - options: Vec, - selected: FeedSourcePreset, -) { - combo.remove_all(); - for option in options { - combo.append(Some(option.preset.as_id()), option.label); - } - combo.set_active_id(Some(selected.as_id())); - combo.set_sensitive(true); -} - -pub fn sync_capture_resolution_combo( - combo: >k::ComboBoxText, - options: Vec, - selected: CaptureSizePreset, -) { - combo.remove_all(); - let option_count = options.len(); - for option in options { - let label = format!( - "{} • {}x{} @ {} fps (Device H.264)", - option.preset.label(), - option.width, - option.height, - option.fps, - ); - combo.append(Some(option.preset.as_id()), &label); - } - combo.set_active_id(Some(selected.as_id())); - combo.set_sensitive(option_count > 1); -} - -pub fn sync_capture_resolution_locked( - combo: >k::ComboBoxText, - options: Vec, - selected: CaptureSizePreset, -) { - sync_capture_resolution_combo(combo, options, selected); - combo.set_sensitive(false); -} - -pub fn sync_capture_resolution_disabled(combo: >k::ComboBoxText) { - combo.remove_all(); - combo.append(Some("off"), "Feed disabled"); - combo.set_active_id(Some("off")); - combo.set_sensitive(false); -} - -pub fn sync_breakout_size_combo( - combo: >k::ComboBoxText, - options: Vec, - selected: BreakoutSizePreset, -) { - combo.remove_all(); - for option in options { - let label = match option.preset { - BreakoutSizePreset::Source => { - format!( - "{} • {}x{} (Source Size)", - option.preset.label(), - option.width, - option.height - ) - } - BreakoutSizePreset::FillDisplay => { - format!( - "{} • {}x{} (Display Size)", - option.preset.label(), - option.width, - option.height - ) - } - _ => format!( - "{} • {}x{}", - option.preset.label(), - option.width, - option.height - ), - }; - combo.append(Some(option.preset.as_id()), &label); - } - combo.set_active_id(Some(selected.as_id())); -} - -pub fn sync_stage_device_combo( - combo: >k::ComboBoxText, - values: &[String], - selected: Option<&str>, -) { - combo.remove_all(); - for value in values { - append_stage_choice(combo, value); - } - set_stage_combo_active_text(combo, selected); -} - -pub fn sync_camera_quality_combo( - combo: >k::ComboBoxText, - options: &[CameraMode], - selected: Option, -) { - combo.remove_all(); - if options.is_empty() { - combo.append(Some("none"), "Quality"); - combo.set_active_id(Some("none")); - combo.set_sensitive(false); - return; - } - - for option in options { - combo.append(Some(&option.id()), &option.short_label()); - } - let active = selected - .filter(|mode| options.contains(mode)) - .or_else(|| options.first().copied()) - .map(CameraMode::id); - combo.set_active_id(active.as_deref()); -} - -pub fn sync_input_device_combo( - combo: >k::ComboBoxText, - values: &[String], - selected: Option<&str>, - all_label: &str, -) { - combo.remove_all(); - combo.append(Some("all"), all_label); - for value in values { - append_input_choice(combo, value); - } - super::ui_runtime::set_combo_active_text(combo, selected); -} - -fn attach_device_control_row( - grid: >k::Grid, - row: i32, - stream_toggle: >k::CheckButton, - selector: &impl IsA, - test_button: >k::Button, -) { - stream_toggle.set_halign(gtk::Align::Start); - selector.set_hexpand(true); - grid.attach(stream_toggle, 0, row, 1, 1); - grid.attach(selector, 1, row, 1, 1); - grid.attach(test_button, 2, row, 1, 1); -} - -fn build_inline_selector_row(label: &str, combo: >k::ComboBoxText) -> gtk::Box { - let block = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let label_widget = gtk::Label::new(Some(label)); - label_widget.set_halign(gtk::Align::Start); - label_widget.set_width_chars(9); - label_widget.set_xalign(0.0); - combo.set_hexpand(true); - combo.set_size_request(0, -1); - block.append(&label_widget); - block.append(combo); - block -} - -fn build_inline_combo_row( - label: &str, - combo: &impl IsA, - min_label_chars: i32, -) -> gtk::Box { - let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let label_widget = gtk::Label::new(Some(label)); - label_widget.add_css_class("dim-label"); - label_widget.set_width_chars(min_label_chars); - label_widget.set_xalign(0.0); - label_widget.set_halign(gtk::Align::Start); - row.append(&label_widget); - row.append(combo); - row -} - -fn append_input_choice(combo: >k::ComboBoxText, value: &str) { - let short = value.rsplit('/').next().unwrap_or(value); - let label = Device::open(value) - .ok() - .and_then(|device| device.name().map(|name| format!("{name} • {short}"))) - .unwrap_or_else(|| short.to_string()); - combo.append(Some(value), &label); -} - -fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { - combo.append(Some(value), &compact_stage_label(value)); -} - -fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) { - if selected - .filter(|value| !value.trim().is_empty()) - .is_some_and(|value| combo.set_active_id(Some(value))) - { - return; - } - combo.set_active(Some(0)); -} - -fn compact_stage_label(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - return "No device".to_string(); - } - let camera = trimmed - .strip_prefix("usb-") - .unwrap_or(trimmed) - .split("-video-index") - .next() - .unwrap_or(trimmed); - if camera != trimmed { - return shorten_label(camera); - } - if let Some(rest) = trimmed - .strip_prefix("alsa_input.") - .or_else(|| trimmed.strip_prefix("alsa_output.")) - { - return shorten_label(&human_audio_node_label(rest)); - } - if let Some(rest) = trimmed - .strip_prefix("bluez_input.") - .or_else(|| trimmed.strip_prefix("bluez_output.")) - { - return shorten_label(&format!( - "Bluetooth {}", - rest.split('.').next().unwrap_or(rest).replace('_', ":") - )); - } - if let Some(short) = trimmed.rsplit('/').next() - && short != trimmed - { - return shorten_label(short); - } - shorten_label(trimmed) -} - -fn human_audio_node_label(value: &str) -> String { - let compact = value - .trim() - .strip_prefix("usb-") - .unwrap_or(value.trim()) - .replace(".analog-stereo", " analog stereo") - .replace(".mono-fallback", " mono") - .replace(".stereo-fallback", " stereo") - .replace('-', " ") - .replace('_', " "); - if compact.starts_with("pci ") || compact.starts_with("pci-") { - if compact.contains("analog stereo") { - "Built-in analog stereo".to_string() - } else { - "Built-in audio".to_string() - } - } else { - compact - } -} - -fn shorten_label(value: &str) -> String { - const MAX: usize = 44; - let compact = value.replace('_', " "); - let mut chars = compact.chars(); - let preview: String = chars.by_ref().take(MAX).collect(); - if chars.next().is_some() { - format!("{preview}…") - } else { - preview - } -} - -fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { - let root = gtk::Box::new(gtk::Orientation::Vertical, 10); - root.add_css_class("display-card"); - root.set_hexpand(true); - root.set_vexpand(true); - - let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); - header.set_hexpand(true); - let title_label = gtk::Label::new(Some(title)); - title_label.add_css_class("title-4"); - title_label.set_halign(gtk::Align::Start); - title_label.set_hexpand(true); - let capture_label = gtk::Label::new(Some(capture_path)); - capture_label.add_css_class("dim-label"); - capture_label.set_halign(gtk::Align::End); - capture_label.set_ellipsize(pango::EllipsizeMode::Start); - header.append(&title_label); - header.append(&capture_label); - root.append(&header); - - let picture = gtk::Picture::new(); - picture.set_hexpand(true); - picture.set_vexpand(true); - picture.set_halign(gtk::Align::Fill); - picture.set_valign(gtk::Align::Fill); - picture.set_can_shrink(true); - picture.set_keep_aspect_ratio(true); - picture.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); - - let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); - preview_box.set_hexpand(true); - preview_box.set_vexpand(true); - preview_box.set_halign(gtk::Align::Fill); - preview_box.set_valign(gtk::Align::Fill); - preview_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); - let preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); - preview_frame.set_hexpand(true); - preview_frame.set_vexpand(true); - preview_frame.set_halign(gtk::Align::Fill); - preview_frame.set_valign(gtk::Align::Fill); - preview_frame.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); - preview_frame.set_child(Some(&picture)); - preview_box.append(&preview_frame); - - let placeholder = gtk::Label::new(Some( - "This feed is running in its own window.\nUse Return To Preview to dock it back here.", - )); - placeholder.set_wrap(true); - placeholder.set_justify(gtk::Justification::Center); - placeholder.set_halign(gtk::Align::Center); - placeholder.set_valign(gtk::Align::Center); - - let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - placeholder_box.add_css_class("display-placeholder"); - placeholder_box.set_hexpand(true); - placeholder_box.set_vexpand(true); - placeholder_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); - placeholder_box.append(&placeholder); - - let stack = gtk::Stack::new(); - stack.set_hexpand(true); - stack.set_vexpand(true); - stack.add_named(&preview_box, Some("preview")); - stack.add_named(&placeholder_box, Some("placeholder")); - stack.set_visible_child_name("preview"); - root.append(&stack); - - let feed_source_combo = gtk::ComboBoxText::new(); - feed_source_combo.set_tooltip_text(Some("Eye source for this pane.")); - feed_source_combo.set_hexpand(true); - feed_source_combo.set_size_request(0, -1); - let capture_resolution_combo = gtk::ComboBoxText::new(); - capture_resolution_combo.set_tooltip_text(Some("Eye capture mode.")); - capture_resolution_combo.set_size_request(0, -1); - capture_resolution_combo.set_hexpand(true); - let breakout_combo = gtk::ComboBoxText::new(); - breakout_combo.set_tooltip_text(Some("Breakout window size.")); - breakout_combo.set_size_request(0, -1); - breakout_combo.set_hexpand(true); - let action_button = gtk::Button::with_label("Break Out"); - stabilize_button(&action_button, 104); - action_button.set_halign(gtk::Align::End); - let stream_status = gtk::Label::new(Some("Preview pending")); - stream_status.add_css_class("status-line"); - stream_status.add_css_class("eye-inline-status"); - stream_status.set_halign(gtk::Align::Fill); - stream_status.set_valign(gtk::Align::Center); - stream_status.set_hexpand(true); - stream_status.set_ellipsize(pango::EllipsizeMode::End); - stream_status.set_single_line_mode(true); - stream_status.set_width_chars(12); - stream_status.set_max_width_chars(18); - stream_status.set_tooltip_text(Some("Eye stream status.")); - let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); - let controls_grid = gtk::Grid::new(); - controls_grid.set_column_spacing(8); - controls_grid.set_row_spacing(8); - controls_grid.set_hexpand(true); - let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7); - let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7); - let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7); - feed_row.set_hexpand(true); - capture_row.set_hexpand(true); - breakout_row.set_hexpand(true); - controls_grid.attach(&feed_row, 0, 0, 1, 1); - controls_grid.attach(&capture_row, 1, 0, 2, 1); - controls_grid.attach(&breakout_row, 0, 1, 1, 1); - controls_grid.attach(&stream_status, 1, 1, 1, 1); - controls_grid.attach(&action_button, 2, 1, 1, 1); - footer_shell.append(&controls_grid); - root.append(&footer_shell); - - DisplayPaneWidgets { - root, - stack, - preview_frame, - picture, - stream_status, - placeholder, - feed_source_combo, - capture_resolution_combo, - breakout_combo, - action_button, - preview_binding: Rc::new(RefCell::new(None)), - title: title.to_string(), - } -} - -fn stabilize_button(button: >k::Button, width: i32) { - button.set_size_request(width, 36); -} - -/// Resets one slider to its default value on double-click. -fn attach_scale_reset_gesture(scale: >k::Scale, default_value: f64) { - let gesture = gtk::GestureClick::new(); - gesture.set_button(1); - gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - let scale_for_click = scale.clone(); - gesture.connect_pressed(move |_, n_press, _, _| { - if should_reset_scale_on_double_click(n_press, scale_for_click.value(), default_value) { - scale_for_click.set_value(default_value); - } - }); - scale.add_controller(gesture); -} - -fn should_reset_scale_on_double_click( - n_press: i32, - current_value: f64, - default_value: f64, -) -> bool { - n_press == 2 && (current_value - default_value).abs() > f64::EPSILON + include!("ui_components/assemble_view.rs") } #[cfg(test)] -mod tests { - use super::should_reset_scale_on_double_click; - - #[test] - fn scale_reset_helper_requires_a_true_double_click_and_a_real_change() { - assert!(!should_reset_scale_on_double_click(1, 350.0, 200.0)); - assert!(!should_reset_scale_on_double_click(2, 200.0, 200.0)); - assert!(should_reset_scale_on_double_click(2, 350.0, 200.0)); - assert!(should_reset_scale_on_double_click(2, 75.0, 100.0)); - } -} +#[path = "tests/ui_components_scale.rs"] +mod tests; diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs new file mode 100644 index 0000000..7abb048 --- /dev/null +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -0,0 +1,180 @@ +{ + let left_pane = left_pane; + let right_pane = right_pane; + if let Some(preview) = preview.as_ref() { + *left_pane.preview_binding.borrow_mut() = + if state.feed_source_preset(0) == FeedSourcePreset::Off { + None + } else { + preview.install_on_picture( + 0, + PreviewSurface::Inline, + &left_pane.picture, + &left_pane.stream_status, + ) + }; + *right_pane.preview_binding.borrow_mut() = + if state.feed_source_preset(1) == FeedSourcePreset::Off { + None + } else { + preview.install_on_picture( + 1, + PreviewSurface::Inline, + &right_pane.picture, + &right_pane.stream_status, + ) + }; + } else { + left_pane.stream_status.set_text("Preview unavailable"); + right_pane.stream_status.set_text("Preview unavailable"); + } + sync_feed_source_combo( + &left_pane.feed_source_combo, + state.feed_source_options(0), + state.feed_source_preset(0), + ); + sync_feed_source_combo( + &right_pane.feed_source_combo, + state.feed_source_options(1), + state.feed_source_preset(1), + ); + if state.feed_source_preset(0) != FeedSourcePreset::Off { + let choice = state + .display_capture_size_choice(0) + .unwrap_or_else(|| state.capture_size_choice(0)); + if state.feed_source_preset(0) == FeedSourcePreset::ThisEye { + sync_capture_resolution_combo( + &left_pane.capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(0), + ); + } else { + sync_capture_resolution_locked( + &left_pane.capture_resolution_combo, + state.capture_size_options(), + choice.preset, + ); + } + } else { + sync_capture_resolution_disabled(&left_pane.capture_resolution_combo); + } + if state.feed_source_preset(1) != FeedSourcePreset::Off { + let choice = state + .display_capture_size_choice(1) + .unwrap_or_else(|| state.capture_size_choice(1)); + if state.feed_source_preset(1) == FeedSourcePreset::ThisEye { + sync_capture_resolution_combo( + &right_pane.capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(1), + ); + } else { + sync_capture_resolution_locked( + &right_pane.capture_resolution_combo, + state.capture_size_options(), + choice.preset, + ); + } + } else { + sync_capture_resolution_disabled(&right_pane.capture_resolution_combo); + } + sync_breakout_size_combo( + &left_pane.breakout_combo, + state.breakout_size_options(0), + state.breakout_size_preset(0), + ); + sync_breakout_size_combo( + &right_pane.breakout_combo, + state.breakout_size_options(1), + state.breakout_size_preset(1), + ); + let diagnostics_popout_label = Rc::new(RefCell::new(None)); + let diagnostics_popout_scroll = Rc::new(RefCell::new(None)); + + let widgets = LauncherWidgets { + status_label: status_label.clone(), + diagnostics_log: diagnostics_log.clone(), + diagnostics_label: diagnostics_label.clone(), + diagnostics_scroll: diagnostics_scroll.clone(), + diagnostics_popout_label: diagnostics_popout_label.clone(), + diagnostics_popout_scroll: diagnostics_popout_scroll.clone(), + diagnostics_rendered_text: Rc::new(RefCell::new(String::new())), + session_log_buffer: session_log_buffer.clone(), + session_log_view: session_log_view.clone(), + summary: SummaryWidgets { + relay_light, + relay_value, + routing_light, + routing_value, + gpio_light, + gpio_value, + shortcut_value, + }, + power_detail, + audio_check_detail, + audio_check_meter, + display_panes: [left_pane.clone(), right_pane.clone()], + server_entry: server_entry.clone(), + start_button: start_button.clone(), + camera_combo: camera_combo.clone(), + camera_quality_combo: camera_quality_combo.clone(), + microphone_combo: microphone_combo.clone(), + speaker_combo: speaker_combo.clone(), + keyboard_combo: keyboard_combo.clone(), + mouse_combo: mouse_combo.clone(), + camera_channel_toggle: camera_channel_toggle.clone(), + microphone_channel_toggle: microphone_channel_toggle.clone(), + audio_channel_toggle: audio_channel_toggle.clone(), + power_auto_button: power_auto_button.clone(), + power_on_button: power_on_button.clone(), + power_off_button: power_off_button.clone(), + audio_gain_scale: audio_gain_scale.clone(), + audio_gain_value: audio_gain_value.clone(), + mic_gain_scale: mic_gain_scale.clone(), + mic_gain_value: mic_gain_value.clone(), + input_toggle_button: input_toggle_button.clone(), + clipboard_button: clipboard_button.clone(), + probe_button: probe_button.clone(), + usb_recover_button: usb_recover_button.clone(), + device_refresh_button: device_refresh_button.clone(), + swap_key_button: swap_key_button.clone(), + camera_test_button: camera_test_button.clone(), + microphone_test_button: microphone_test_button.clone(), + microphone_replay_button: microphone_replay_button.clone(), + speaker_test_button: speaker_test_button.clone(), + diagnostics_copy_button: diagnostics_copy_button.clone(), + diagnostics_popout_button: diagnostics_popout_button.clone(), + console_copy_button: console_copy_button.clone(), + console_popout_button: console_popout_button.clone(), + console_level_combo: console_level_combo.clone(), + session_log_level: session_log_level.clone(), + _device_body_height_group: device_body_height_group, + }; + let popouts = Rc::new(RefCell::new([None, None])); + let diagnostics_popout = Rc::new(RefCell::new(None)); + let log_popout = Rc::new(RefCell::new(None)); + + super::ui_runtime::refresh_diagnostics_report(&widgets, state, false); + + window.set_child(Some(&root)); + + LauncherView { + window, + server_entry, + camera_combo, + camera_quality_combo, + microphone_combo, + speaker_combo, + keyboard_combo, + mouse_combo, + device_stage: DeviceStageWidgets { + camera_preview, + camera_status, + }, + widgets, + preview, + popouts, + diagnostics_popout, + log_popout, + } +} diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs new file mode 100644 index 0000000..3b76b5a --- /dev/null +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -0,0 +1,68 @@ +struct LauncherShellContext { + window: gtk::ApplicationWindow, + root: gtk::Box, + staging_row: gtk::Box, + operations: gtk::Box, + left_pane: DisplayPaneWidgets, + right_pane: DisplayPaneWidgets, + relay_light: gtk::Box, + relay_value: gtk::Label, + routing_light: gtk::Box, + routing_value: gtk::Label, + gpio_light: gtk::Box, + gpio_value: gtk::Label, + shortcut_value: gtk::Label, +} + +struct DeviceControlsContext { + device_refresh_button: gtk::Button, + camera_combo: gtk::ComboBoxText, + camera_quality_combo: gtk::ComboBoxText, + microphone_combo: gtk::ComboBoxText, + speaker_combo: gtk::ComboBoxText, + keyboard_combo: gtk::ComboBoxText, + mouse_combo: gtk::ComboBoxText, + camera_channel_toggle: gtk::CheckButton, + microphone_channel_toggle: gtk::CheckButton, + audio_channel_toggle: gtk::CheckButton, + audio_gain_scale: gtk::Scale, + audio_gain_value: gtk::Label, + mic_gain_scale: gtk::Scale, + mic_gain_value: gtk::Label, + audio_check_detail: gtk::Label, + audio_check_meter: gtk::ProgressBar, + device_body_height_group: gtk::SizeGroup, + camera_preview: gtk::Picture, + camera_status: gtk::Label, + camera_test_button: gtk::Button, + microphone_test_button: gtk::Button, + microphone_replay_button: gtk::Button, + speaker_test_button: gtk::Button, +} + +struct OperationsRailContext { + server_entry: gtk::Entry, + start_button: gtk::Button, + clipboard_button: gtk::Button, + probe_button: gtk::Button, + usb_recover_button: gtk::Button, + power_auto_button: gtk::Button, + power_on_button: gtk::Button, + power_off_button: gtk::Button, + power_detail: gtk::Label, + input_toggle_button: gtk::Button, + swap_key_button: gtk::Button, + diagnostics_copy_button: gtk::Button, + diagnostics_popout_button: gtk::Button, + diagnostics_log: Rc>, + diagnostics_label: gtk::Label, + diagnostics_scroll: gtk::ScrolledWindow, + console_copy_button: gtk::Button, + console_popout_button: gtk::Button, + console_level_combo: gtk::ComboBoxText, + session_log_level: Rc>, + status_label: gtk::Label, + session_log_buffer: gtk::TextBuffer, + session_log_view: gtk::TextView, + preview: Option>, +} diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs new file mode 100644 index 0000000..61acb05 --- /dev/null +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -0,0 +1,290 @@ +{ + let device_refresh_button = gtk::Button::with_label("Refresh Devices"); + stabilize_button(&device_refresh_button, 132); + device_refresh_button.set_tooltip_text(Some("Re-scan connected devices.")); + let (devices_panel, devices_body) = + build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref())); + devices_panel.set_hexpand(true); + devices_panel.set_vexpand(false); + devices_panel.set_valign(gtk::Align::Fill); + devices_body.set_spacing(8); + + let control_group = build_subgroup("Control Inputs"); + let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10); + control_group.append(&control_stack); + + let camera_combo = gtk::ComboBoxText::new(); + sync_stage_device_combo( + &camera_combo, + &catalog.cameras, + state.devices.camera.as_deref(), + ); + let camera_quality_combo = gtk::ComboBoxText::new(); + sync_camera_quality_combo( + &camera_quality_combo, + &state.camera_quality_options(catalog), + state.selected_camera_quality(catalog), + ); + camera_quality_combo.set_size_request(88, -1); + camera_quality_combo.set_tooltip_text(Some("Webcam uplink quality.")); + let camera_test_button = gtk::Button::with_label("Start Preview"); + stabilize_button(&camera_test_button, 118); + camera_test_button.set_tooltip_text(Some("Preview selected webcam locally.")); + + let speaker_combo = gtk::ComboBoxText::new(); + sync_stage_device_combo( + &speaker_combo, + &catalog.speakers, + state.devices.speaker.as_deref(), + ); + let speaker_test_button = gtk::Button::with_label("Play Tone"); + stabilize_button(&speaker_test_button, 118); + speaker_test_button.set_tooltip_text(Some("Play a local test tone.")); + + let keyboard_combo = gtk::ComboBoxText::new(); + keyboard_combo.append(Some("all"), "all keyboards"); + for keyboard in &catalog.keyboards { + append_input_choice(&keyboard_combo, keyboard); + } + super::ui_runtime::set_combo_active_text(&keyboard_combo, state.devices.keyboard.as_deref()); + keyboard_combo.set_tooltip_text(Some("Keyboard source for relay input.")); + let keyboard_row = build_inline_selector_row("Keyboard", &keyboard_combo); + control_stack.append(&keyboard_row); + + let mouse_combo = gtk::ComboBoxText::new(); + mouse_combo.append(Some("all"), "all mice"); + for mouse in &catalog.mice { + append_input_choice(&mouse_combo, mouse); + } + super::ui_runtime::set_combo_active_text(&mouse_combo, state.devices.mouse.as_deref()); + mouse_combo.set_tooltip_text(Some("Pointer source for relay input.")); + let mouse_row = build_inline_selector_row("Mouse", &mouse_combo); + control_stack.append(&mouse_row); + devices_body.append(&control_group); + + let media_group = build_subgroup("Media Controls"); + let media_grid = gtk::Grid::new(); + media_grid.set_row_spacing(10); + media_grid.set_column_spacing(8); + media_group.append(&media_grid); + let camera_channel_toggle = gtk::CheckButton::with_label("Camera"); + camera_channel_toggle.set_active(state.channels.camera); + camera_channel_toggle.set_tooltip_text(Some("Send webcam during relay.")); + let audio_channel_toggle = gtk::CheckButton::with_label("Speaker"); + audio_channel_toggle.set_active(state.channels.audio); + audio_channel_toggle.set_tooltip_text(Some("Play remote audio here.")); + let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); + microphone_channel_toggle.set_active(state.channels.microphone); + microphone_channel_toggle.set_tooltip_text(Some("Send mic during relay.")); + + let audio_gain_adjustment = gtk::Adjustment::new( + f64::from(state.audio_gain_percent), + 0.0, + f64::from(super::state::MAX_AUDIO_GAIN_PERCENT), + 25.0, + 100.0, + 0.0, + ); + let audio_gain_scale = + gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); + audio_gain_scale.set_draw_value(false); + audio_gain_scale.set_hexpand(false); + audio_gain_scale.set_size_request(96, -1); + audio_gain_scale.set_tooltip_text(Some("Speaker volume. Double-click resets to 200%.")); + attach_scale_reset_gesture( + &audio_gain_scale, + f64::from(super::state::DEFAULT_AUDIO_GAIN_PERCENT), + ); + let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); + audio_gain_value.set_visible(false); + + let mic_gain_adjustment = gtk::Adjustment::new( + f64::from(state.mic_gain_percent), + 0.0, + f64::from(super::state::MAX_MIC_GAIN_PERCENT), + 25.0, + 100.0, + 0.0, + ); + let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment)); + mic_gain_scale.set_draw_value(false); + mic_gain_scale.set_hexpand(false); + mic_gain_scale.set_size_request(96, -1); + mic_gain_scale.set_tooltip_text(Some("Mic gain. Double-click resets to 100%.")); + attach_scale_reset_gesture( + &mic_gain_scale, + f64::from(super::state::DEFAULT_MIC_GAIN_PERCENT), + ); + let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); + mic_gain_value.set_visible(false); + + camera_combo.set_size_request(0, -1); + let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + camera_combo.set_hexpand(true); + camera_quality_combo.set_hexpand(false); + camera_selectors.append(&camera_combo); + camera_selectors.append(&camera_quality_combo); + speaker_combo.set_size_request(0, -1); + attach_device_control_row( + &media_grid, + 0, + &camera_channel_toggle, + &camera_selectors, + &camera_test_button, + ); + let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + speaker_combo.set_hexpand(true); + speaker_selectors.append(&speaker_combo); + speaker_selectors.append(&audio_gain_scale); + attach_device_control_row( + &media_grid, + 1, + &audio_channel_toggle, + &speaker_selectors, + &speaker_test_button, + ); + + let microphone_combo = gtk::ComboBoxText::new(); + sync_stage_device_combo( + µphone_combo, + &catalog.microphones, + state.devices.microphone.as_deref(), + ); + let microphone_test_button = gtk::Button::with_label("Monitor Mic"); + stabilize_button(µphone_test_button, 118); + microphone_test_button.set_tooltip_text(Some("Monitor mic through speaker.")); + microphone_combo.set_size_request(0, -1); + let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + microphone_combo.set_hexpand(true); + microphone_selectors.append(µphone_combo); + microphone_selectors.append(&mic_gain_scale); + attach_device_control_row( + &media_grid, + 2, + µphone_channel_toggle, + µphone_selectors, + µphone_test_button, + ); + + let audio_check_detail = gtk::Label::new(Some("Idle")); + audio_check_detail.add_css_class("dim-label"); + audio_check_detail.set_wrap(false); + audio_check_detail.set_ellipsize(pango::EllipsizeMode::End); + audio_check_detail.set_xalign(0.0); + audio_check_detail.set_visible(false); + let audio_check_meter = gtk::ProgressBar::new(); + audio_check_meter.add_css_class("audio-check-meter"); + audio_check_meter.set_show_text(false); + devices_body.append(&media_group); + staging_row.append(&devices_panel); + + let (preview_panel, preview_body) = build_panel("Device Testing"); + preview_panel.set_hexpand(true); + preview_panel.set_vexpand(false); + preview_panel.set_valign(gtk::Align::Fill); + preview_body.set_vexpand(false); + preview_body.set_spacing(8); + let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + testing_row.set_hexpand(true); + testing_row.set_vexpand(true); + testing_row.set_valign(gtk::Align::Fill); + let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical); + device_body_height_group.add_widget(&devices_body); + device_body_height_group.add_widget(&testing_row); + let camera_preview = gtk::Picture::new(); + camera_preview.set_can_shrink(false); + camera_preview.set_hexpand(true); + camera_preview.set_vexpand(true); + camera_preview.set_halign(gtk::Align::Fill); + camera_preview.set_valign(gtk::Align::Fill); + camera_preview.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + camera_preview.set_keep_aspect_ratio(true); + camera_preview.add_css_class("camera-preview-frame"); + let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview.")); + camera_status.add_css_class("dim-label"); + camera_status.set_wrap(false); + camera_status.set_ellipsize(pango::EllipsizeMode::End); + camera_status.set_xalign(0.0); + camera_status.set_visible(false); + let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); + camera_preview_shell.set_hexpand(true); + camera_preview_shell.set_vexpand(true); + camera_preview_shell.set_halign(gtk::Align::Fill); + camera_preview_shell.set_valign(gtk::Align::Fill); + camera_preview_shell.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); + camera_preview_frame.set_hexpand(true); + camera_preview_frame.set_vexpand(true); + camera_preview_frame.set_halign(gtk::Align::Fill); + camera_preview_frame.set_valign(gtk::Align::Fill); + camera_preview_frame.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); + camera_preview_frame.set_child(Some(&camera_preview)); + camera_preview_shell.append(&camera_preview_frame); + let webcam_group = build_subgroup("Webcam Preview"); + webcam_group.set_hexpand(true); + webcam_group.set_vexpand(true); + webcam_group.set_valign(gtk::Align::Fill); + webcam_group.append(&camera_preview_shell); + testing_row.append(&webcam_group); + + let playback_group = build_subgroup("Mic Playback"); + playback_group.set_hexpand(false); + playback_group.set_vexpand(true); + playback_group.set_valign(gtk::Align::Fill); + playback_group.set_size_request(72, -1); + let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); + playback_body.set_halign(gtk::Align::Center); + playback_body.set_vexpand(true); + playback_body.set_valign(gtk::Align::Fill); + let microphone_replay_button = gtk::Button::with_label("Replay"); + stabilize_button(µphone_replay_button, 70); + audio_check_meter.set_orientation(gtk::Orientation::Vertical); + audio_check_meter.set_inverted(true); + audio_check_meter.set_hexpand(false); + audio_check_meter.set_vexpand(true); + audio_check_meter.set_halign(gtk::Align::Center); + audio_check_meter.set_size_request(20, 0); + audio_check_meter.set_show_text(false); + audio_check_meter.set_text(Some("Idle")); + playback_body.append(&audio_check_meter); + playback_body.append(µphone_replay_button); + playback_group.append(&playback_body); + testing_row.append(&playback_group); + preview_body.append(&testing_row); + staging_row.append(&preview_panel); + + DeviceControlsContext { + device_refresh_button, + camera_combo, + camera_quality_combo, + microphone_combo, + speaker_combo, + keyboard_combo, + mouse_combo, + camera_channel_toggle, + microphone_channel_toggle, + audio_channel_toggle, + audio_gain_scale, + audio_gain_value, + mic_gain_scale, + mic_gain_value, + audio_check_detail, + audio_check_meter, + device_body_height_group, + camera_preview, + camera_status, + camera_test_button, + microphone_test_button, + microphone_replay_button, + speaker_test_button, + } +} diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs new file mode 100644 index 0000000..2137536 --- /dev/null +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -0,0 +1,235 @@ +{ + let (connection_panel, connection_body) = build_panel("Relay Controls"); + let server_entry = gtk::Entry::new(); + server_entry.add_css_class("server-entry"); + server_entry.set_hexpand(true); + server_entry.set_width_chars(18); + server_entry.set_text(server_addr); + server_entry.set_tooltip_text(Some("Relay host address.")); + let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + relay_row.set_halign(gtk::Align::Fill); + relay_row.set_hexpand(true); + relay_row.append(&server_entry); + let start_button = gtk::Button::with_label("Connect"); + start_button.add_css_class("suggested-action"); + start_button.set_hexpand(false); + stabilize_button(&start_button, 108); + relay_row.append(&start_button); + connection_body.append(&relay_row); + + let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + live_actions_row.set_homogeneous(true); + let clipboard_button = gtk::Button::with_label("Send Clipboard"); + clipboard_button.set_hexpand(true); + stabilize_button(&clipboard_button, 108); + clipboard_button.set_tooltip_text(Some("Type clipboard remotely.")); + let probe_button = gtk::Button::with_label("Copy Gate Probe"); + probe_button.set_hexpand(true); + stabilize_button(&probe_button, 108); + probe_button.set_tooltip_text(Some("Copy quality probe.")); + let usb_recover_button = gtk::Button::with_label("Recover USB"); + usb_recover_button.set_hexpand(true); + stabilize_button(&usb_recover_button, 108); + usb_recover_button.set_tooltip_text(Some("Re-enumerate remote USB.")); + live_actions_row.append(&clipboard_button); + live_actions_row.append(&probe_button); + live_actions_row.append(&usb_recover_button); + connection_body.append(&live_actions_row); + + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); + let power_heading = gtk::Label::new(Some("GPIO Power")); + power_heading.add_css_class("subgroup-title"); + power_heading.set_halign(gtk::Align::Start); + + let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); + power_shell.set_halign(gtk::Align::Fill); + let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + power_row.set_hexpand(true); + power_heading.set_width_chars(10); + power_row.append(&power_heading); + let power_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + power_buttons.set_hexpand(true); + power_buttons.set_homogeneous(true); + let power_on_button = gtk::Button::with_label("On"); + power_on_button.set_hexpand(true); + stabilize_button(&power_on_button, 52); + power_on_button.add_css_class("pill-toggle"); + let power_auto_button = gtk::Button::with_label("Auto"); + power_auto_button.set_hexpand(true); + stabilize_button(&power_auto_button, 52); + power_auto_button.add_css_class("pill-toggle"); + let power_off_button = gtk::Button::with_label("Off"); + power_off_button.set_hexpand(true); + stabilize_button(&power_off_button, 52); + power_off_button.add_css_class("pill-toggle"); + let power_detail = gtk::Label::new(Some("Capture power status is loading...")); + power_detail.add_css_class("dim-label"); + power_detail.set_wrap(true); + power_detail.set_xalign(0.0); + power_buttons.append(&power_on_button); + power_buttons.append(&power_auto_button); + power_buttons.append(&power_off_button); + power_row.append(&power_buttons); + power_shell.append(&power_row); + connection_body.append(&power_shell); + let routing_heading = gtk::Label::new(Some("Inputs")); + routing_heading.add_css_class("subgroup-title"); + routing_heading.set_halign(gtk::Align::Start); + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); + + let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + routing_row.set_hexpand(true); + routing_heading.set_width_chars(10); + routing_row.append(&routing_heading); + let routing_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + routing_buttons.set_hexpand(true); + routing_buttons.set_homogeneous(true); + let input_toggle_button = gtk::Button::with_label("Route"); + input_toggle_button.set_hexpand(true); + stabilize_button(&input_toggle_button, 106); + input_toggle_button.set_tooltip_text(Some("Swap input ownership.")); + let swap_key_button = gtk::Button::with_label("Set Swap Key"); + swap_key_button.set_hexpand(true); + stabilize_button(&swap_key_button, 106); + routing_buttons.append(&input_toggle_button); + routing_buttons.append(&swap_key_button); + routing_row.append(&routing_buttons); + connection_body.append(&routing_row); + operations.append(&connection_panel); + + let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics"); + diagnostics_panel.set_vexpand(true); + diagnostics_panel.set_valign(gtk::Align::Fill); + diagnostics_body.set_vexpand(true); + let diagnostics_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); + diagnostics_toolbar.set_homogeneous(true); + let diagnostics_copy_button = gtk::Button::with_label("Copy Report"); + stabilize_button(&diagnostics_copy_button, 112); + let diagnostics_popout_button = gtk::Button::with_label("Break Out"); + stabilize_button(&diagnostics_popout_button, 112); + diagnostics_toolbar.append(&diagnostics_copy_button); + diagnostics_toolbar.append(&diagnostics_popout_button); + let diagnostics_log = Rc::new(RefCell::new(DiagnosticsLog::new(16))); + let diagnostics_label = gtk::Label::new(None); + diagnostics_label.add_css_class("status-log"); + diagnostics_label.set_selectable(true); + diagnostics_label.set_xalign(0.0); + diagnostics_label.set_yalign(0.0); + diagnostics_label.set_wrap(false); + diagnostics_label.set_halign(gtk::Align::Start); + diagnostics_label.set_valign(gtk::Align::Start); + diagnostics_label.set_hexpand(true); + let diagnostics_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); + diagnostics_shell.set_hexpand(true); + diagnostics_shell.set_vexpand(false); + diagnostics_shell.append(&diagnostics_label); + let diagnostics_scroll = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .min_content_height(SIDE_LOG_MIN_HEIGHT) + .child(&diagnostics_shell) + .build(); + diagnostics_body.append(&diagnostics_toolbar); + diagnostics_body.append(&diagnostics_scroll); + operations.append(&diagnostics_panel); + + let (console_panel, console_body) = build_panel("Session Console"); + console_panel.set_vexpand(true); + console_panel.set_valign(gtk::Align::Fill); + console_body.set_vexpand(true); + let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let session_log_level = Rc::new(RefCell::new(ConsoleLogLevel::default())); + let console_level_combo = gtk::ComboBoxText::new(); + for level in ConsoleLogLevel::ALL { + console_level_combo.append(Some(level.id()), level.label()); + } + console_level_combo.set_active_id(Some(ConsoleLogLevel::default().id())); + console_level_combo.set_size_request(78, 36); + console_level_combo.set_tooltip_text(Some("Show relay logs at this level or higher.")); + let console_copy_button = gtk::Button::with_label("Copy"); + console_copy_button.set_tooltip_text(Some("Copy visible log.")); + let console_popout_button = gtk::Button::with_label("Pop Out"); + console_popout_button.set_tooltip_text(Some("Open log window.")); + let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + console_buttons.set_hexpand(true); + console_buttons.set_homogeneous(true); + console_copy_button.set_hexpand(true); + console_popout_button.set_hexpand(true); + console_buttons.append(&console_copy_button); + console_buttons.append(&console_popout_button); + console_toolbar.append(&console_level_combo); + console_toolbar.append(&console_buttons); + let status_label = gtk::Label::new(Some("Session log ready.")); + status_label.add_css_class("status-line"); + status_label.set_halign(gtk::Align::Start); + status_label.set_wrap(true); + status_label.set_xalign(0.0); + let session_log_buffer = gtk::TextBuffer::new(None); + session_log_buffer.create_tag(Some("log-launcher"), &[("foreground", &"#8bd5ca")]); + session_log_buffer.create_tag(Some("log-relay"), &[("foreground", &"#89b4fa")]); + session_log_buffer.create_tag(Some("log-preview"), &[("foreground", &"#cba6f7")]); + session_log_buffer.create_tag(Some("log-stderr"), &[("foreground", &"#f9e2af")]); + session_log_buffer.create_tag(Some("log-warn"), &[("foreground", &"#fab387")]); + session_log_buffer.create_tag(Some("log-error"), &[("foreground", &"#f38ba8")]); + super::ui_runtime::append_session_log(&session_log_buffer, "[launcher] Session log ready."); + let session_log_view = gtk::TextView::with_buffer(&session_log_buffer); + session_log_view.add_css_class("status-log"); + session_log_view.set_editable(false); + session_log_view.set_cursor_visible(false); + session_log_view.set_monospace(true); + session_log_view.set_wrap_mode(gtk::WrapMode::WordChar); + let log_scroll = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .min_content_height(SIDE_LOG_MIN_HEIGHT) + .child(&session_log_view) + .build(); + console_body.append(&console_toolbar); + console_body.append(&log_scroll); + operations.append(&console_panel); + + { + let buffer = session_log_buffer.clone(); + let view = session_log_view.clone(); + status_label.connect_notify_local(Some("label"), move |label, _| { + super::ui_runtime::append_session_log(&buffer, &format!("[launcher] {}", label.text())); + let mut end = buffer.end_iter(); + view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); + }); + } + + let preview = match LauncherPreview::new(server_addr.to_string()) { + Ok(preview) => Some(Rc::new(preview)), + Err(err) => { + status_label.set_text(&format!("Preview unavailable: {err}")); + None + } + }; + + OperationsRailContext { + server_entry, + start_button, + clipboard_button, + probe_button, + usb_recover_button, + power_auto_button, + power_on_button, + power_off_button, + power_detail, + input_toggle_button, + swap_key_button, + diagnostics_copy_button, + diagnostics_popout_button, + diagnostics_log, + diagnostics_label, + diagnostics_scroll, + console_copy_button, + console_popout_button, + console_level_combo, + session_log_level, + status_label, + session_log_buffer, + session_log_view, + preview, + } +} diff --git a/client/src/launcher/ui_components/build_shell.rs b/client/src/launcher/ui_components/build_shell.rs new file mode 100644 index 0000000..281dd3a --- /dev/null +++ b/client/src/launcher/ui_components/build_shell.rs @@ -0,0 +1,110 @@ +{ + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Lesavka") + .default_width(LAUNCHER_DEFAULT_WIDTH) + .default_height(LAUNCHER_DEFAULT_HEIGHT) + .resizable(false) + .build(); + window.set_size_request(LAUNCHER_DEFAULT_WIDTH, LAUNCHER_DEFAULT_HEIGHT); + install_css(&window); + install_window_icon(&window); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + root.add_css_class("launcher-root"); + root.set_margin_start(10); + root.set_margin_end(10); + root.set_margin_top(10); + root.set_margin_bottom(10); + + let hero = gtk::Box::new(gtk::Orientation::Horizontal, 8); + hero.set_hexpand(true); + + let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + brand_box.set_valign(gtk::Align::Center); + let brand_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + brand_row.set_halign(gtk::Align::Start); + brand_row.set_valign(gtk::Align::Center); + let brand_icon = gtk::Image::from_icon_name(LESAVKA_ICON_NAME); + brand_icon.add_css_class("app-logo"); + brand_icon.set_pixel_size(44); + brand_icon.set_valign(gtk::Align::Center); + let heading = gtk::Label::new(Some("Lesavka")); + heading.add_css_class("title-2"); + heading.set_halign(gtk::Align::Start); + heading.set_valign(gtk::Align::Center); + let version_tag = gtk::Label::new(Some(&format!("v{}", crate::VERSION))); + version_tag.add_css_class("version-tag"); + version_tag.set_halign(gtk::Align::Start); + version_tag.set_valign(gtk::Align::Center); + brand_row.append(&brand_icon); + brand_row.append(&heading); + brand_row.append(&version_tag); + brand_box.append(&brand_row); + hero.append(&brand_box); + + let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6); + chips.set_halign(gtk::Align::End); + chips.set_hexpand(true); + let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", ""); + let (routing_chip, routing_light, routing_value) = + build_status_chip_with_light("Inputs", "Local"); + let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown"); + let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); + chips.append(&relay_chip); + chips.append(&routing_chip); + chips.append(&gpio_chip); + chips.append(&shortcut_chip); + hero.append(&chips); + root.append(&hero); + + let content = gtk::Box::new(gtk::Orientation::Horizontal, 8); + content.set_hexpand(true); + content.set_vexpand(true); + root.append(&content); + + let workspace = gtk::Box::new(gtk::Orientation::Vertical, 8); + workspace.set_hexpand(true); + workspace.set_vexpand(true); + content.append(&workspace); + + let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); + operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1); + operations.set_hexpand(false); + operations.set_vexpand(true); + operations.set_valign(gtk::Align::Fill); + content.append(&operations); + + let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + display_row.set_hexpand(true); + display_row.set_vexpand(true); + display_row.set_homogeneous(true); + let left_pane = build_display_pane("Left Eye", "/dev/lesavka_l_eye"); + let right_pane = build_display_pane("Right Eye", "/dev/lesavka_r_eye"); + display_row.append(&left_pane.root); + display_row.append(&right_pane.root); + workspace.append(&display_row); + + let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + staging_row.set_hexpand(true); + staging_row.set_vexpand(false); + staging_row.set_valign(gtk::Align::Start); + staging_row.set_homogeneous(true); + workspace.append(&staging_row); + + LauncherShellContext { + window, + root, + staging_row, + operations, + left_pane, + right_pane, + relay_light, + relay_value, + routing_light, + routing_value, + gpio_light, + gpio_value, + shortcut_value, + } +} diff --git a/client/src/launcher/ui_components/combo_helpers.rs b/client/src/launcher/ui_components/combo_helpers.rs new file mode 100644 index 0000000..1504985 --- /dev/null +++ b/client/src/launcher/ui_components/combo_helpers.rs @@ -0,0 +1,269 @@ +pub fn sync_feed_source_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: FeedSourcePreset, +) { + combo.remove_all(); + for option in options { + combo.append(Some(option.preset.as_id()), option.label); + } + combo.set_active_id(Some(selected.as_id())); + combo.set_sensitive(true); +} + +pub fn sync_capture_resolution_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: CaptureSizePreset, +) { + combo.remove_all(); + let option_count = options.len(); + for option in options { + let label = format!( + "{} • {}x{} @ {} fps (Device H.264)", + option.preset.label(), + option.width, + option.height, + option.fps, + ); + combo.append(Some(option.preset.as_id()), &label); + } + combo.set_active_id(Some(selected.as_id())); + combo.set_sensitive(option_count > 1); +} + +pub fn sync_capture_resolution_locked( + combo: >k::ComboBoxText, + options: Vec, + selected: CaptureSizePreset, +) { + sync_capture_resolution_combo(combo, options, selected); + combo.set_sensitive(false); +} + +pub fn sync_capture_resolution_disabled(combo: >k::ComboBoxText) { + combo.remove_all(); + combo.append(Some("off"), "Feed disabled"); + combo.set_active_id(Some("off")); + combo.set_sensitive(false); +} + +pub fn sync_breakout_size_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: BreakoutSizePreset, +) { + combo.remove_all(); + for option in options { + let label = match option.preset { + BreakoutSizePreset::Source => { + format!( + "{} • {}x{} (Source Size)", + option.preset.label(), + option.width, + option.height + ) + } + BreakoutSizePreset::FillDisplay => { + format!( + "{} • {}x{} (Display Size)", + option.preset.label(), + option.width, + option.height + ) + } + _ => format!( + "{} • {}x{}", + option.preset.label(), + option.width, + option.height + ), + }; + combo.append(Some(option.preset.as_id()), &label); + } + combo.set_active_id(Some(selected.as_id())); +} + +pub fn sync_stage_device_combo( + combo: >k::ComboBoxText, + values: &[String], + selected: Option<&str>, +) { + combo.remove_all(); + for value in values { + append_stage_choice(combo, value); + } + set_stage_combo_active_text(combo, selected); +} + +pub fn sync_camera_quality_combo( + combo: >k::ComboBoxText, + options: &[CameraMode], + selected: Option, +) { + combo.remove_all(); + if options.is_empty() { + combo.append(Some("none"), "Quality"); + combo.set_active_id(Some("none")); + combo.set_sensitive(false); + return; + } + + for option in options { + combo.append(Some(&option.id()), &option.short_label()); + } + let active = selected + .filter(|mode| options.contains(mode)) + .or_else(|| options.first().copied()) + .map(CameraMode::id); + combo.set_active_id(active.as_deref()); +} + +pub fn sync_input_device_combo( + combo: >k::ComboBoxText, + values: &[String], + selected: Option<&str>, + all_label: &str, +) { + combo.remove_all(); + combo.append(Some("all"), all_label); + for value in values { + append_input_choice(combo, value); + } + super::ui_runtime::set_combo_active_text(combo, selected); +} + +fn attach_device_control_row( + grid: >k::Grid, + row: i32, + stream_toggle: >k::CheckButton, + selector: &impl IsA, + test_button: >k::Button, +) { + stream_toggle.set_halign(gtk::Align::Start); + selector.set_hexpand(true); + grid.attach(stream_toggle, 0, row, 1, 1); + grid.attach(selector, 1, row, 1, 1); + grid.attach(test_button, 2, row, 1, 1); +} + +fn build_inline_selector_row(label: &str, combo: >k::ComboBoxText) -> gtk::Box { + let block = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let label_widget = gtk::Label::new(Some(label)); + label_widget.set_halign(gtk::Align::Start); + label_widget.set_width_chars(9); + label_widget.set_xalign(0.0); + combo.set_hexpand(true); + combo.set_size_request(0, -1); + block.append(&label_widget); + block.append(combo); + block +} + +fn build_inline_combo_row( + label: &str, + combo: &impl IsA, + min_label_chars: i32, +) -> gtk::Box { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let label_widget = gtk::Label::new(Some(label)); + label_widget.add_css_class("dim-label"); + label_widget.set_width_chars(min_label_chars); + label_widget.set_xalign(0.0); + label_widget.set_halign(gtk::Align::Start); + row.append(&label_widget); + row.append(combo); + row +} + +fn append_input_choice(combo: >k::ComboBoxText, value: &str) { + let short = value.rsplit('/').next().unwrap_or(value); + let label = Device::open(value) + .ok() + .and_then(|device| device.name().map(|name| format!("{name} • {short}"))) + .unwrap_or_else(|| short.to_string()); + combo.append(Some(value), &label); +} + +fn append_stage_choice(combo: >k::ComboBoxText, value: &str) { + combo.append(Some(value), &compact_stage_label(value)); +} + +fn set_stage_combo_active_text(combo: >k::ComboBoxText, selected: Option<&str>) { + if selected + .filter(|value| !value.trim().is_empty()) + .is_some_and(|value| combo.set_active_id(Some(value))) + { + return; + } + combo.set_active(Some(0)); +} + +fn compact_stage_label(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return "No device".to_string(); + } + let camera = trimmed + .strip_prefix("usb-") + .unwrap_or(trimmed) + .split("-video-index") + .next() + .unwrap_or(trimmed); + if camera != trimmed { + return shorten_label(camera); + } + if let Some(rest) = trimmed + .strip_prefix("alsa_input.") + .or_else(|| trimmed.strip_prefix("alsa_output.")) + { + return shorten_label(&human_audio_node_label(rest)); + } + if let Some(rest) = trimmed + .strip_prefix("bluez_input.") + .or_else(|| trimmed.strip_prefix("bluez_output.")) + { + return shorten_label(&format!( + "Bluetooth {}", + rest.split('.').next().unwrap_or(rest).replace('_', ":") + )); + } + if let Some(short) = trimmed.rsplit('/').next() + && short != trimmed + { + return shorten_label(short); + } + shorten_label(trimmed) +} + +fn human_audio_node_label(value: &str) -> String { + let compact = value + .trim() + .strip_prefix("usb-") + .unwrap_or(value.trim()) + .replace(".analog-stereo", " analog stereo") + .replace(".mono-fallback", " mono") + .replace(".stereo-fallback", " stereo") + .replace(['-', '_'], " "); + if compact.starts_with("pci ") || compact.starts_with("pci-") { + if compact.contains("analog stereo") { + "Built-in analog stereo".to_string() + } else { + "Built-in audio".to_string() + } + } else { + compact + } +} + +fn shorten_label(value: &str) -> String { + const MAX: usize = 44; + let compact = value.replace('_', " "); + let mut chars = compact.chars(); + let preview: String = chars.by_ref().take(MAX).collect(); + if chars.next().is_some() { + format!("{preview}…") + } else { + preview + } +} diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs new file mode 100644 index 0000000..aba55eb --- /dev/null +++ b/client/src/launcher/ui_components/display_pane.rs @@ -0,0 +1,131 @@ +fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { + let root = gtk::Box::new(gtk::Orientation::Vertical, 10); + root.add_css_class("display-card"); + root.set_hexpand(true); + root.set_vexpand(true); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header.set_hexpand(true); + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-4"); + title_label.set_halign(gtk::Align::Start); + title_label.set_hexpand(true); + let capture_label = gtk::Label::new(Some(capture_path)); + capture_label.add_css_class("dim-label"); + capture_label.set_halign(gtk::Align::End); + capture_label.set_ellipsize(pango::EllipsizeMode::Start); + header.append(&title_label); + header.append(&capture_label); + root.append(&header); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_halign(gtk::Align::Fill); + picture.set_valign(gtk::Align::Fill); + picture.set_can_shrink(true); + picture.set_keep_aspect_ratio(true); + picture.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); + + let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + preview_box.set_hexpand(true); + preview_box.set_vexpand(true); + preview_box.set_halign(gtk::Align::Fill); + preview_box.set_valign(gtk::Align::Fill); + preview_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); + let preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); + preview_frame.set_hexpand(true); + preview_frame.set_vexpand(true); + preview_frame.set_halign(gtk::Align::Fill); + preview_frame.set_valign(gtk::Align::Fill); + preview_frame.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); + preview_frame.set_child(Some(&picture)); + preview_box.append(&preview_frame); + + let placeholder = gtk::Label::new(Some( + "This feed is running in its own window.\nUse Return To Preview to dock it back here.", + )); + placeholder.set_wrap(true); + placeholder.set_justify(gtk::Justification::Center); + placeholder.set_halign(gtk::Align::Center); + placeholder.set_valign(gtk::Align::Center); + + let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + placeholder_box.add_css_class("display-placeholder"); + placeholder_box.set_hexpand(true); + placeholder_box.set_vexpand(true); + placeholder_box.set_size_request(EYE_PREVIEW_MIN_WIDTH, EYE_PREVIEW_MIN_HEIGHT); + placeholder_box.append(&placeholder); + + let stack = gtk::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + stack.add_named(&preview_box, Some("preview")); + stack.add_named(&placeholder_box, Some("placeholder")); + stack.set_visible_child_name("preview"); + root.append(&stack); + + let feed_source_combo = gtk::ComboBoxText::new(); + feed_source_combo.set_tooltip_text(Some("Eye source for this pane.")); + feed_source_combo.set_hexpand(true); + feed_source_combo.set_size_request(0, -1); + let capture_resolution_combo = gtk::ComboBoxText::new(); + capture_resolution_combo.set_tooltip_text(Some("Eye capture mode.")); + capture_resolution_combo.set_size_request(0, -1); + capture_resolution_combo.set_hexpand(true); + let breakout_combo = gtk::ComboBoxText::new(); + breakout_combo.set_tooltip_text(Some("Breakout window size.")); + breakout_combo.set_size_request(0, -1); + breakout_combo.set_hexpand(true); + let action_button = gtk::Button::with_label("Break Out"); + stabilize_button(&action_button, 104); + action_button.set_halign(gtk::Align::End); + let stream_status = gtk::Label::new(Some("Preview pending")); + stream_status.add_css_class("status-line"); + stream_status.add_css_class("eye-inline-status"); + stream_status.set_halign(gtk::Align::Fill); + stream_status.set_valign(gtk::Align::Center); + stream_status.set_hexpand(true); + stream_status.set_ellipsize(pango::EllipsizeMode::End); + stream_status.set_single_line_mode(true); + stream_status.set_width_chars(12); + stream_status.set_max_width_chars(18); + stream_status.set_tooltip_text(Some("Eye stream status.")); + let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); + let controls_grid = gtk::Grid::new(); + controls_grid.set_column_spacing(8); + controls_grid.set_row_spacing(8); + controls_grid.set_hexpand(true); + let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7); + let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7); + let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7); + feed_row.set_hexpand(true); + capture_row.set_hexpand(true); + breakout_row.set_hexpand(true); + controls_grid.attach(&feed_row, 0, 0, 1, 1); + controls_grid.attach(&capture_row, 1, 0, 2, 1); + controls_grid.attach(&breakout_row, 0, 1, 1, 1); + controls_grid.attach(&stream_status, 1, 1, 1, 1); + controls_grid.attach(&action_button, 2, 1, 1, 1); + footer_shell.append(&controls_grid); + root.append(&footer_shell); + + DisplayPaneWidgets { + root, + stack, + preview_frame, + picture, + stream_status, + placeholder, + feed_source_combo, + capture_resolution_combo, + breakout_combo, + action_button, + preview_binding: Rc::new(RefCell::new(None)), + title: title.to_string(), + } +} + +fn stabilize_button(button: >k::Button, width: i32) { + button.set_size_request(width, 36); +} diff --git a/client/src/launcher/ui_components/panel_chips.rs b/client/src/launcher/ui_components/panel_chips.rs new file mode 100644 index 0000000..292d63c --- /dev/null +++ b/client/src/launcher/ui_components/panel_chips.rs @@ -0,0 +1,74 @@ +fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { + build_panel_with_action(title, None) +} + +fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) { + let panel = gtk::Box::new(gtk::Orientation::Vertical, 8); + panel.add_css_class("panel"); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.set_halign(gtk::Align::Fill); + let heading = gtk::Label::new(Some(title)); + heading.add_css_class("panel-title"); + heading.set_halign(gtk::Align::Start); + heading.set_hexpand(true); + header.append(&heading); + if let Some(action) = action { + header.append(action); + } + panel.append(&header); + + let body = gtk::Box::new(gtk::Orientation::Vertical, 8); + panel.append(&body); + (panel, body) +} + +fn build_subgroup(title: &str) -> gtk::Box { + let group = gtk::Box::new(gtk::Orientation::Vertical, 8); + group.add_css_class("subgroup"); + let heading = gtk::Label::new(Some(title)); + heading.add_css_class("subgroup-title"); + heading.set_halign(gtk::Align::Start); + group.append(&heading); + group +} + +fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { + let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); + chip.add_css_class("status-chip"); + chip.set_hexpand(false); + + let label_widget = gtk::Label::new(Some(label)); + label_widget.add_css_class("status-chip-label"); + label_widget.set_halign(gtk::Align::Start); + let value_widget = gtk::Label::new(Some(value)); + value_widget.add_css_class("status-chip-value"); + value_widget.set_halign(gtk::Align::Start); + chip.append(&label_widget); + chip.append(&value_widget); + (chip, value_widget) +} + +fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) { + let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); + chip.add_css_class("status-chip"); + chip.set_hexpand(false); + + let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6); + meta.add_css_class("status-chip-meta"); + let light = gtk::Box::new(gtk::Orientation::Horizontal, 0); + light.add_css_class("status-light"); + light.add_css_class("status-light-idle"); + let label_widget = gtk::Label::new(Some(label)); + label_widget.add_css_class("status-chip-label"); + label_widget.set_halign(gtk::Align::Start); + meta.append(&light); + meta.append(&label_widget); + let value_widget = gtk::Label::new(Some(value)); + value_widget.add_css_class("status-chip-value"); + value_widget.set_halign(gtk::Align::Start); + chip.append(&meta); + chip.append(&value_widget); + (chip, light, value_widget) +} diff --git a/client/src/launcher/ui_components/scale_reset.rs b/client/src/launcher/ui_components/scale_reset.rs new file mode 100644 index 0000000..c630278 --- /dev/null +++ b/client/src/launcher/ui_components/scale_reset.rs @@ -0,0 +1,21 @@ +/// Resets one slider to its default value on double-click. +fn attach_scale_reset_gesture(scale: >k::Scale, default_value: f64) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(1); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + let scale_for_click = scale.clone(); + gesture.connect_pressed(move |_, n_press, _, _| { + if should_reset_scale_on_double_click(n_press, scale_for_click.value(), default_value) { + scale_for_click.set_value(default_value); + } + }); + scale.add_controller(gesture); +} + +fn should_reset_scale_on_double_click( + n_press: i32, + current_value: f64, + default_value: f64, +) -> bool { + n_press == 2 && (current_value - default_value).abs() > f64::EPSILON +} diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs new file mode 100644 index 0000000..8aaf10d --- /dev/null +++ b/client/src/launcher/ui_components/style.rs @@ -0,0 +1,152 @@ +pub fn install_css(window: >k::ApplicationWindow) { + let provider = gtk::CssProvider::new(); + provider.load_from_data( + r" + window.lesavka { + background: #101319; + color: #eef2f7; + } + box.launcher-root { + background: linear-gradient(180deg, #11161f 0%, #161d28 100%); + } + box.panel { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + padding: 10px; + } + box.subgroup { + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 8px; + } + label.panel-title { + font-weight: 700; + font-size: 1.05rem; + margin-bottom: 4px; + } + label.subgroup-title { + font-weight: 700; + opacity: 0.92; + } + label.version-tag { + font-size: 0.76rem; + opacity: 0.72; + } + image.app-logo { + opacity: 0.96; + } + box.status-chip { + background: rgba(91, 179, 162, 0.12); + border: 1px solid rgba(91, 179, 162, 0.25); + border-radius: 999px; + padding: 6px 9px; + } + box.status-light { + min-width: 10px; + min-height: 10px; + border-radius: 999px; + background: rgba(214, 81, 81, 0.92); + } + box.status-light-live { + background: rgba(96, 214, 126, 0.95); + } + box.status-light-idle { + background: rgba(214, 81, 81, 0.92); + } + box.status-light-warning { + background: rgba(242, 143, 54, 0.95); + } + box.status-light-caution { + background: rgba(227, 201, 73, 0.95); + } + label.status-chip-label { + font-size: 0.78rem; + opacity: 0.72; + } + label.status-chip-value { + font-size: 0.93rem; + font-weight: 700; + } + box.display-card { + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 22px; + padding: 16px; + } + box.display-placeholder { + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 16px; + padding: 24px; + } + picture.camera-preview-frame { + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 14px; + } + label.status-line { + opacity: 0.9; + } + label.eye-inline-status { + font-size: 0.86rem; + font-weight: 600; + background: rgba(91, 179, 162, 0.10); + border: 1px solid rgba(91, 179, 162, 0.22); + border-radius: 999px; + padding: 5px 8px; + opacity: 0.9; + } + textview.status-log, + label.status-log { + font-family: monospace; + background: rgba(0, 0, 0, 0.22); + border-radius: 14px; + padding: 10px; + } + progressbar.audio-check-meter trough { + min-width: 14px; + min-height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + } + progressbar.audio-check-meter.vertical trough { + min-height: 116px; + } + progressbar.audio-check-meter progress { + border-radius: 999px; + background: rgba(91, 179, 162, 0.88); + } + entry.server-entry { + min-height: 38px; + } + button.pill-toggle { + min-height: 36px; + padding: 0 14px; + } + button.pill-toggle-active { + background: rgba(91, 179, 162, 0.2); + border-color: rgba(91, 179, 162, 0.45); + font-weight: 700; + } + ", + ); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + window.add_css_class("lesavka"); +} + +pub fn install_window_icon(window: &impl IsA) { + if let Some(display) = gtk::gdk::Display::default() { + let theme = gtk::IconTheme::for_display(&display); + theme.add_search_path(LESAVKA_ICON_SEARCH_PATH); + } + gtk::Window::set_default_icon_name(LESAVKA_ICON_NAME); + window.as_ref().set_icon_name(Some(LESAVKA_ICON_NAME)); +} diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs new file mode 100644 index 0000000..928f4b5 --- /dev/null +++ b/client/src/launcher/ui_components/types.rs @@ -0,0 +1,191 @@ +#[derive(Clone)] +pub struct SummaryWidgets { + pub relay_light: gtk::Box, + pub relay_value: gtk::Label, + pub routing_light: gtk::Box, + pub routing_value: gtk::Label, + pub gpio_light: gtk::Box, + pub gpio_value: gtk::Label, + pub shortcut_value: gtk::Label, +} + +#[derive(Clone)] +pub struct DisplayPaneWidgets { + pub root: gtk::Box, + pub stack: gtk::Stack, + pub preview_frame: gtk::AspectFrame, + pub picture: gtk::Picture, + pub stream_status: gtk::Label, + pub placeholder: gtk::Label, + pub feed_source_combo: gtk::ComboBoxText, + pub capture_resolution_combo: gtk::ComboBoxText, + pub breakout_combo: gtk::ComboBoxText, + pub action_button: gtk::Button, + pub preview_binding: Rc>>, + pub title: String, +} + +pub struct PopoutWindowHandle { + pub window: gtk::ApplicationWindow, + pub frame: gtk::AspectFrame, + pub picture: gtk::Picture, + pub status_label: gtk::Label, + pub binding: PreviewBinding, +} + +/// Minimum severity for relay log lines shown in the session console. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ConsoleLogLevel { + Error, + #[default] + Warn, + Info, + Debug, + Trace, +} + +impl ConsoleLogLevel { + pub const ALL: [Self; 5] = [ + Self::Error, + Self::Warn, + Self::Info, + Self::Debug, + Self::Trace, + ]; + + /// Stable value stored on the GTK combo row. + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } + + /// Short label displayed in the launcher dropdown. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Error => "Error", + Self::Warn => "Warn", + Self::Info => "Info", + Self::Debug => "Debug", + Self::Trace => "Trace", + } + } + + /// Parses a GTK combo id back into a log level. + #[must_use] + pub fn from_id(raw: &str) -> Option { + match raw { + "error" => Some(Self::Error), + "warn" => Some(Self::Warn), + "info" => Some(Self::Info), + "debug" => Some(Self::Debug), + "trace" => Some(Self::Trace), + _ => None, + } + } + + /// Numeric ordering where lower values are more important. + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Error => 0, + Self::Warn => 1, + Self::Info => 2, + Self::Debug => 3, + Self::Trace => 4, + } + } +} + +#[derive(Clone)] +pub struct LauncherWidgets { + pub status_label: gtk::Label, + pub diagnostics_log: Rc>, + pub diagnostics_label: gtk::Label, + pub diagnostics_scroll: gtk::ScrolledWindow, + pub diagnostics_popout_label: Rc>>, + pub diagnostics_popout_scroll: Rc>>, + pub diagnostics_rendered_text: Rc>, + pub session_log_buffer: gtk::TextBuffer, + pub session_log_view: gtk::TextView, + pub summary: SummaryWidgets, + pub power_detail: gtk::Label, + pub audio_check_detail: gtk::Label, + pub audio_check_meter: gtk::ProgressBar, + pub display_panes: [DisplayPaneWidgets; 2], + pub server_entry: gtk::Entry, + pub start_button: gtk::Button, + pub camera_combo: gtk::ComboBoxText, + pub camera_quality_combo: gtk::ComboBoxText, + pub microphone_combo: gtk::ComboBoxText, + pub speaker_combo: gtk::ComboBoxText, + pub keyboard_combo: gtk::ComboBoxText, + pub mouse_combo: gtk::ComboBoxText, + pub camera_channel_toggle: gtk::CheckButton, + pub microphone_channel_toggle: gtk::CheckButton, + pub audio_channel_toggle: gtk::CheckButton, + pub power_auto_button: gtk::Button, + pub power_on_button: gtk::Button, + pub power_off_button: gtk::Button, + pub audio_gain_scale: gtk::Scale, + pub audio_gain_value: gtk::Label, + pub mic_gain_scale: gtk::Scale, + pub mic_gain_value: gtk::Label, + pub input_toggle_button: gtk::Button, + pub clipboard_button: gtk::Button, + pub probe_button: gtk::Button, + pub usb_recover_button: gtk::Button, + pub device_refresh_button: gtk::Button, + pub swap_key_button: gtk::Button, + pub camera_test_button: gtk::Button, + pub microphone_test_button: gtk::Button, + pub microphone_replay_button: gtk::Button, + pub speaker_test_button: gtk::Button, + pub diagnostics_copy_button: gtk::Button, + pub diagnostics_popout_button: gtk::Button, + pub console_copy_button: gtk::Button, + pub console_popout_button: gtk::Button, + pub console_level_combo: gtk::ComboBoxText, + pub session_log_level: Rc>, + pub _device_body_height_group: gtk::SizeGroup, +} + +#[derive(Clone)] +pub struct DeviceStageWidgets { + pub camera_preview: gtk::Picture, + pub camera_status: gtk::Label, +} + +pub struct LauncherView { + pub window: gtk::ApplicationWindow, + pub server_entry: gtk::Entry, + pub camera_combo: gtk::ComboBoxText, + pub camera_quality_combo: gtk::ComboBoxText, + pub microphone_combo: gtk::ComboBoxText, + pub speaker_combo: gtk::ComboBoxText, + pub keyboard_combo: gtk::ComboBoxText, + pub mouse_combo: gtk::ComboBoxText, + pub device_stage: DeviceStageWidgets, + pub widgets: LauncherWidgets, + pub preview: Option>, + pub popouts: Rc; 2]>>, + pub diagnostics_popout: Rc>>, + pub log_popout: Rc>>, +} + +pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; +const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons"); +const LAUNCHER_DEFAULT_WIDTH: i32 = 1360; +const LAUNCHER_DEFAULT_HEIGHT: i32 = 940; +const OPERATIONS_RAIL_WIDTH: i32 = 288; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; +const EYE_PREVIEW_MIN_HEIGHT: i32 = 320; +const EYE_PREVIEW_MIN_WIDTH: i32 = 568; +const SIDE_LOG_MIN_HEIGHT: i32 = 124; diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 54c0808..78d0eb3 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -1,1957 +1,12 @@ -use anyhow::Result; -use gtk::{gdk, glib, prelude::*}; -use std::{ - cell::RefCell, - io::{BufRead, BufReader}, - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - rc::Rc, - sync::mpsc::Sender, - time::{SystemTime, UNIX_EPOCH}, -}; - -use super::{ - LAUNCHER_CLIPBOARD_CONTROL_ENV, LAUNCHER_FOCUS_SIGNAL_ENV, - device_test::{DeviceTestController, DeviceTestKind}, - diagnostics::{SnapshotReport, quality_probe_command}, - launcher_clipboard_control_path, launcher_focus_signal_path, - preview::{LauncherPreview, PreviewSurface}, - runtime_env_vars, - state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, - ui_components::{ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, -}; - -pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; -pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; -pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"; -pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; -pub const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; -pub const UPLINK_CAMERA_PREVIEW_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW"; -pub const UPLINK_MIC_LEVEL_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL"; -pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; -pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; -pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control"; -pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control"; -pub const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control"; -pub const DEFAULT_UPLINK_CAMERA_PREVIEW_PATH: &str = "/tmp/lesavka-uplink-camera-preview.rgba"; -pub const DEFAULT_UPLINK_MIC_LEVEL_PATH: &str = "/tmp/lesavka-uplink-mic-level.value"; - -pub type RelayChild = Child; - -pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { - let relay_live = child_running || state.remote_active; - set_status_light( - &widgets.summary.relay_light, - server_light_state(state, relay_live), - ); - widgets - .summary - .relay_value - .set_text(&server_version_label(state)); - set_status_light( - &widgets.summary.routing_light, - StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)), - ); - widgets - .summary - .routing_value - .set_text(&capitalize(routing_name(state.routing))); - set_status_light( - &widgets.summary.gpio_light, - gpio_light_state(&state.capture_power), - ); - widgets - .summary - .gpio_value - .set_text(&gpio_power_label(&state.capture_power)); - widgets - .summary - .shortcut_value - .set_text(&toggle_key_label(&state.swap_key)); - - widgets - .power_detail - .set_text(&capture_power_detail(&state.capture_power)); - if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON { - widgets - .audio_gain_scale - .set_value(state.audio_gain_percent as f64); - } - widgets.audio_gain_value.set_text(&state.audio_gain_label()); - if (widgets.mic_gain_scale.value() - state.mic_gain_percent as f64).abs() > f64::EPSILON { - widgets - .mic_gain_scale - .set_value(state.mic_gain_percent as f64); - } - widgets.mic_gain_value.set_text(&state.mic_gain_label()); - if widgets.camera_channel_toggle.is_active() != state.channels.camera { - widgets - .camera_channel_toggle - .set_active(state.channels.camera); - } - if widgets.microphone_channel_toggle.is_active() != state.channels.microphone { - widgets - .microphone_channel_toggle - .set_active(state.channels.microphone); - } - if widgets.audio_channel_toggle.is_active() != state.channels.audio { - widgets - .audio_channel_toggle - .set_active(state.channels.audio); - } - widgets - .start_button - .set_label(if relay_live { "Disconnect" } else { "Connect" }); - widgets.start_button.set_sensitive(true); - widgets.server_entry.set_sensitive(!relay_live); - widgets.start_button.set_tooltip_text(Some(if relay_live { - "Disconnect the relay session." - } else { - "Start relay and previews." - })); - widgets.clipboard_button.set_sensitive(relay_live); - widgets.probe_button.set_sensitive(true); - widgets - .usb_recover_button - .set_sensitive(state.server_available); - widgets.device_refresh_button.set_sensitive(!relay_live); - widgets - .camera_combo - .set_sensitive(!relay_live && state.channels.camera); - widgets.camera_quality_combo.set_sensitive( - !relay_live - && state.channels.camera - && state.devices.camera.is_some() - && state.camera_quality.is_some(), - ); - widgets - .microphone_combo - .set_sensitive(!relay_live && state.channels.microphone); - widgets - .speaker_combo - .set_sensitive(!relay_live && state.channels.audio); - widgets - .audio_gain_scale - .set_sensitive(!relay_live && state.channels.audio); - widgets.keyboard_combo.set_sensitive(!relay_live); - widgets.mouse_combo.set_sensitive(!relay_live); - widgets.camera_channel_toggle.set_sensitive(!relay_live); - widgets.microphone_channel_toggle.set_sensitive(!relay_live); - widgets.audio_channel_toggle.set_sensitive(!relay_live); - widgets - .camera_test_button - .set_sensitive(!relay_live && state.channels.camera); - widgets - .microphone_test_button - .set_sensitive(!relay_live && state.channels.microphone); - widgets - .mic_gain_scale - .set_sensitive(!relay_live && state.channels.microphone); - widgets - .speaker_test_button - .set_sensitive(!relay_live && state.channels.audio); - widgets.input_toggle_button.set_label(match state.routing { - InputRouting::Remote => "Route Local", - InputRouting::Local => "Route Remote", - }); - widgets - .input_toggle_button - .set_tooltip_text(Some(match state.routing { - InputRouting::Remote => "Route inputs back local.", - InputRouting::Local => "Route inputs to remote.", - })); - widgets.swap_key_button.set_label("Set Swap Key"); - widgets - .swap_key_button - .set_tooltip_text(Some(if state.swap_key_binding { - "Press the new swap key." - } else { - "Set the swap shortcut." - })); - let power_available = state.capture_power.available; - widgets - .power_auto_button - .set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto")); - widgets.power_on_button.set_sensitive( - power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"), - ); - widgets.power_off_button.set_sensitive( - power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"), - ); - sync_power_mode_button_styles(widgets, state.capture_power.mode.as_str()); - - for monitor_id in 0..2 { - refresh_display_pane( - &widgets.display_panes[monitor_id], - state.display_surface(monitor_id), - ); - } - refresh_diagnostics_report(widgets, state, child_running); -} - -pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) { - let camera_running = tests.is_running(DeviceTestKind::Camera); - let microphone_running = tests.is_running(DeviceTestKind::Microphone); - let microphone_replay_running = tests.is_running(DeviceTestKind::MicrophoneReplay); - let speaker_running = tests.is_running(DeviceTestKind::Speaker); - - widgets.camera_test_button.set_label(if camera_running { - "Stop Preview" - } else { - "Start Preview" - }); - widgets - .microphone_test_button - .set_label(if microphone_running { - "Stop Monitor" - } else { - "Monitor Mic" - }); - widgets - .microphone_replay_button - .set_label(if microphone_replay_running { - "Stop" - } else { - "Replay" - }); - widgets - .microphone_replay_button - .set_sensitive(microphone_replay_running || tests.microphone_replay_ready()); - widgets.speaker_test_button.set_label(if speaker_running { - "Stop Tone" - } else { - "Play Tone" - }); - widgets.audio_check_detail.set_text(&local_test_detail( - camera_running, - microphone_running, - speaker_running, - microphone_replay_running, - )); - if microphone_running { - let level = tests.microphone_level_fraction().clamp(0.0, 1.0); - widgets.audio_check_meter.set_fraction(level); - widgets - .audio_check_meter - .set_text(Some(&format!("Mic {:>3}%", (level * 100.0).round() as u32))); - } else if speaker_running || microphone_replay_running { - widgets.audio_check_meter.set_text(Some("Playback")); - widgets.audio_check_meter.pulse(); - } else { - widgets.audio_check_meter.set_fraction(0.0); - widgets.audio_check_meter.set_text(Some("Idle")); - } -} - -pub fn update_test_action_result( - widgets: &LauncherWidgets, - tests: &mut DeviceTestController, - result: Result, - start_msg: &str, - stop_msg: &str, -) { - match result { - Ok(true) => widgets.status_label.set_text(start_msg), - Ok(false) => widgets.status_label.set_text(stop_msg), - Err(err) => widgets - .status_label - .set_text(&format!("Device test failed: {err}")), - } - refresh_test_buttons(widgets, tests); -} - -pub fn open_popout_window( - app: >k::Application, - preview: &LauncherPreview, - state: &Rc>, - child_proc: &Rc>>, - popouts: &Rc; 2]>>, - widgets: &LauncherWidgets, - monitor_id: usize, -) { - let already_open = { - let popouts = popouts.borrow(); - popouts[monitor_id].is_some() - }; - if already_open { - return; - } - - if let Some(binding) = widgets.display_panes[monitor_id] - .preview_binding - .borrow() - .as_ref() - { - binding.set_enabled(false); - } - - let (breakout_size, breakout_limit) = { - let state = state.borrow(); - ( - state.breakout_size_choice(monitor_id), - state.breakout_display_size(), - ) - }; - let window = gtk::ApplicationWindow::builder() - .application(app) - .title(format!( - "Lesavka {}", - widgets.display_panes[monitor_id].title - )) - .default_width(breakout_size.width) - .default_height(breakout_size.height) - .build(); - super::ui_components::install_css(&window); - super::ui_components::install_window_icon(&window); - window.set_decorated(false); - window.set_resizable(false); - - let picture = gtk::Picture::new(); - picture.set_hexpand(true); - picture.set_vexpand(true); - picture.set_halign(gtk::Align::Fill); - picture.set_valign(gtk::Align::Fill); - picture.set_can_shrink(true); - picture.set_keep_aspect_ratio(true); - picture.set_size_request(breakout_size.width, breakout_size.height); - let root = gtk::Box::new(gtk::Orientation::Vertical, 0); - root.set_hexpand(true); - root.set_vexpand(true); - root.set_halign(gtk::Align::Fill); - root.set_valign(gtk::Align::Fill); - root.set_size_request(breakout_size.width, breakout_size.height); - let frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); - frame.set_hexpand(true); - frame.set_vexpand(true); - frame.set_halign(gtk::Align::Fill); - frame.set_valign(gtk::Align::Fill); - frame.set_size_request(breakout_size.width, breakout_size.height); - frame.set_child(Some(&picture)); - root.append(&frame); - - let stream_status = gtk::Label::new(Some("")); - - let binding = preview - .install_on_picture(monitor_id, PreviewSurface::Window, &picture, &stream_status) - .expect("preview binding for popout"); - - window.set_child(Some(&root)); - install_popout_drag(&window, &picture); - apply_popout_window_geometry(&window, &root, &picture, breakout_size, breakout_limit); - - let state_handle = Rc::clone(state); - let child_proc_handle = Rc::clone(child_proc); - let popouts_handle = Rc::clone(popouts); - let widgets_handle = widgets.clone(); - let close_binding = binding.clone(); - window.connect_close_request(move |_| { - let handle = { - let mut popouts = popouts_handle.borrow_mut(); - popouts[monitor_id].take() - }; - if let Some(handle) = handle { - handle.binding.close(); - if let Some(preview_binding) = widgets_handle.display_panes[monitor_id] - .preview_binding - .borrow() - .as_ref() - { - preview_binding.set_enabled(true); - } - { - let mut state = state_handle.borrow_mut(); - state.set_display_surface(monitor_id, DisplaySurface::Preview); - } - let child_running = child_proc_handle.borrow().is_some(); - let state_snapshot = state_handle.borrow().clone(); - refresh_launcher_ui(&widgets_handle, &state_snapshot, child_running); - } else { - close_binding.close(); - } - glib::Propagation::Proceed - }); - - { - let mut state = state.borrow_mut(); - state.set_display_surface(monitor_id, DisplaySurface::Window); - } - { - let mut popouts = popouts.borrow_mut(); - popouts[monitor_id] = Some(PopoutWindowHandle { - window: window.clone(), - frame: frame.clone(), - picture: picture.clone(), - status_label: stream_status.clone(), - binding, - }); - } - let child_running = child_proc.borrow().is_some(); - let state_snapshot = state.borrow().clone(); - refresh_launcher_ui(widgets, &state_snapshot, child_running); - window.present(); - schedule_popout_window_geometry( - window.clone(), - root.clone(), - picture.clone(), - breakout_size, - breakout_limit, - ); -} - -pub fn apply_popout_window_size( - handle: &PopoutWindowHandle, - size: BreakoutSizeChoice, - display_limit: super::state::PreviewSourceSize, -) { - let Some(root) = handle - .picture - .parent() - .and_then(|widget| widget.downcast::().ok()) - else { - return; - }; - apply_popout_window_geometry(&handle.window, &root, &handle.picture, size, display_limit); - handle.window.present(); - schedule_popout_window_geometry( - handle.window.clone(), - root.clone(), - handle.picture.clone(), - size, - display_limit, - ); -} - -pub fn dock_display_to_preview( - state: &Rc>, - child_proc: &Rc>>, - popouts: &Rc; 2]>>, - widgets: &LauncherWidgets, - monitor_id: usize, -) { - let handle = { - let mut popouts = popouts.borrow_mut(); - popouts[monitor_id].take() - }; - if let Some(handle) = handle { - handle.binding.close(); - handle.window.close(); - } - if let Some(binding) = widgets.display_panes[monitor_id] - .preview_binding - .borrow() - .as_ref() - { - binding.set_enabled(true); - } - { - let mut state = state.borrow_mut(); - state.set_display_surface(monitor_id, DisplaySurface::Preview); - } - let child_running = child_proc.borrow().is_some(); - let state_snapshot = state.borrow().clone(); - refresh_launcher_ui(widgets, &state_snapshot, child_running); -} - -pub fn dock_all_displays_to_preview( - state: &Rc>, - child_proc: &Rc>>, - popouts: &Rc; 2]>>, - widgets: &LauncherWidgets, -) { - let mut handles = Vec::new(); - { - let mut popouts = popouts.borrow_mut(); - for monitor_id in 0..2 { - if let Some(handle) = popouts[monitor_id].take() { - handles.push(handle); - } - } - } - for handle in handles { - handle.binding.close(); - handle.window.close(); - } - - for monitor_id in 0..2 { - if let Some(binding) = widgets.display_panes[monitor_id] - .preview_binding - .borrow() - .as_ref() - { - binding.set_enabled(true); - } - } - - { - let mut state = state.borrow_mut(); - for monitor_id in 0..2 { - state.set_display_surface(monitor_id, DisplaySurface::Preview); - } - } - - let child_running = child_proc.borrow().is_some(); - let state_snapshot = state.borrow().clone(); - refresh_launcher_ui(widgets, &state_snapshot, child_running); -} - -pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { - if let Some(binding) = pane.preview_binding.borrow().as_ref() { - binding.set_enabled(matches!(surface, DisplaySurface::Preview)); - } - pane.action_button - .set_sensitive(pane.preview_binding.borrow().is_some()); - match surface { - DisplaySurface::Preview => { - pane.stack.set_visible_child_name("preview"); - pane.action_button.set_label("Break Out"); - pane.placeholder.set_text( - "This feed is running in its own window.\nUse Return To Preview to dock it back here.", - ); - if pane.preview_binding.borrow().is_none() { - pane.stream_status.set_text("Preview unavailable"); - } - } - DisplaySurface::Window => { - pane.stack.set_visible_child_name("placeholder"); - pane.action_button.set_label("Return To Preview"); - pane.placeholder.set_text(&format!( - "{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.", - pane.title - )); - pane.stream_status.set_text("Streaming in its own window"); - } - } -} - -pub fn gpio_power_label(power: &CapturePowerStatus) -> String { - if !power.available { - return "Unavailable".to_string(); - } - if !power.enabled { - return "Power Off".to_string(); - } - match power.detected_devices { - 0 => "No Eyes".to_string(), - 1 => "1 Eye".to_string(), - count => format!("{count} Eyes"), - } -} - -pub fn capture_power_detail(power: &CapturePowerStatus) -> String { - if !power.available { - return format!("{} is unavailable: {}", power.unit, power.detail); - } - let detected = if power.enabled { - format!(" • {}", gpio_detection_detail(power.detected_devices)) - } else { - String::new() - }; - match power.mode.as_str() { - "forced-on" => format!( - "{} • awake • {}{} • leases {}", - power.unit, power.detail, detected, power.active_leases - ), - "forced-off" => format!( - "{} • dark • {}{} • leases {}", - power.unit, power.detail, detected, power.active_leases - ), - _ => format!( - "{} • auto • {}{} • leases {}", - power.unit, power.detail, detected, power.active_leases - ), - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum StatusLightState { - Idle, - Live, - Warning, - Caution, -} - -impl StatusLightState { - fn from_active(active: bool) -> Self { - if active { Self::Live } else { Self::Idle } - } - - fn css_class(self) -> &'static str { - match self { - Self::Idle => "status-light-idle", - Self::Live => "status-light-live", - Self::Warning => "status-light-warning", - Self::Caution => "status-light-caution", - } - } -} - -fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState { - if relay_live { - StatusLightState::Live - } else if state.server_available { - StatusLightState::Caution - } else { - StatusLightState::Idle - } -} - -fn server_version_label(state: &LauncherState) -> String { - if !state.server_available { - return "-".to_string(); - } - let version = state - .server_version - .as_deref() - .map(str::trim) - .filter(|version| !version.is_empty()); - match version { - Some(version) if version.starts_with('v') => version.to_string(), - Some(version) => format!("v{version}"), - None => "-".to_string(), - } -} - -fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState { - if !power.available || !power.enabled { - return StatusLightState::Idle; - } - match power.detected_devices { - 0 => StatusLightState::Warning, - 1 => StatusLightState::Caution, - _ => StatusLightState::Live, - } -} - -fn gpio_detection_detail(detected_devices: u32) -> String { - match detected_devices { - 0 => "no eyes detected".to_string(), - 1 => "1 eye detected".to_string(), - count => format!("{count} eyes detected"), - } -} - -/// Highlights the currently active capture mode so it reads like a segmented control. -fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) { - for button in [ - &widgets.power_auto_button, - &widgets.power_on_button, - &widgets.power_off_button, - ] { - button.remove_css_class("pill-toggle-active"); - } - match mode { - "forced-on" => widgets.power_on_button.add_css_class("pill-toggle-active"), - "forced-off" => widgets.power_off_button.add_css_class("pill-toggle-active"), - _ => widgets - .power_auto_button - .add_css_class("pill-toggle-active"), - } -} - -/// Reports which local staging checks are active right now. -fn local_test_detail( - camera_running: bool, - microphone_running: bool, - speaker_running: bool, - microphone_replay_running: bool, -) -> String { - let mut active = Vec::new(); - if camera_running { - active.push("camera preview"); - } - if microphone_running { - active.push("mic monitor"); - } - if speaker_running { - active.push("speaker tone"); - } - if microphone_replay_running { - active.push("mic replay"); - } - - if active.is_empty() { - "Local checks are idle. Use Start Preview, Monitor Mic, Replay, or Play Tone before you launch." - .to_string() - } else { - format!( - "Local checks running: {}. Stop them whenever staging is complete.", - active.join(", ") - ) - } -} - -fn install_popout_drag(window: >k::ApplicationWindow, widget: &impl IsA) { - let drag = gtk::GestureClick::new(); - drag.set_button(0); - let native = window.clone(); - drag.connect_pressed(move |gesture, _press, x, y| { - let Some(device) = gesture.current_event_device() else { - return; - }; - let Some(surface) = native.surface() else { - return; - }; - let Some(toplevel) = surface.dynamic_cast_ref::() else { - return; - }; - let timestamp = gesture - .current_event() - .map(|event| event.time()) - .unwrap_or(0); - toplevel.begin_move(&device, 1, x, y, timestamp); - }); - widget.add_controller(drag); -} - -fn apply_popout_window_geometry( - window: >k::ApplicationWindow, - root: >k::Box, - picture: >k::Picture, - size: BreakoutSizeChoice, - display_limit: super::state::PreviewSourceSize, -) { - picture.set_size_request(size.width, size.height); - root.set_size_request(size.width, size.height); - window.set_default_size(size.width, size.height); - if should_cover_display(size, display_limit) { - fullscreen_on_largest_monitor(window); - } else { - window.unfullscreen(); - } -} - -fn schedule_popout_window_geometry( - window: gtk::ApplicationWindow, - root: gtk::Box, - picture: gtk::Picture, - size: BreakoutSizeChoice, - display_limit: super::state::PreviewSourceSize, -) { - for delay_ms in [0_u64, 25, 150] { - let window = window.clone(); - let root = root.clone(); - let picture = picture.clone(); - glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || { - apply_popout_window_geometry(&window, &root, &picture, size, display_limit); - window.present(); - }); - } -} - -fn fullscreen_on_largest_monitor(window: >k::ApplicationWindow) { - let Some(display) = gdk::Display::default() else { - window.fullscreen(); - return; - }; - let monitors = display.monitors(); - let monitor = (0..monitors.n_items()) - .filter_map(|idx| monitors.item(idx)) - .filter_map(|obj| obj.downcast::().ok()) - .max_by_key(|monitor| { - let geometry = monitor.geometry(); - let scale = monitor.scale_factor().max(1); - geometry.width().max(1) as i64 - * scale as i64 - * geometry.height().max(1) as i64 - * scale as i64 - }); - if let Some(monitor) = monitor.as_ref() { - window.fullscreen_on_monitor(monitor); - } else { - window.fullscreen(); - } -} - -fn should_cover_display( - size: BreakoutSizeChoice, - display_limit: super::state::PreviewSourceSize, -) -> bool { - matches!(size.preset, super::state::BreakoutSizePreset::FillDisplay) - || (size.width >= display_limit.width.max(1) as i32 - && size.height >= display_limit.height.max(1) as i32) -} - -pub fn present_popout_windows(popouts: &Rc; 2]>>) { - for handle in popouts.borrow().iter().flatten() { - handle.window.present(); - } -} +// Runtime helpers that keep launcher widgets synchronized with relay state. +include!("ui_runtime/status_refresh.rs"); +include!("ui_runtime/display_popouts.rs"); +include!("ui_runtime/status_details.rs"); +include!("ui_runtime/control_paths.rs"); +include!("ui_runtime/process_logs.rs"); +include!("ui_runtime/report_popouts.rs"); +include!("ui_runtime/log_filtering.rs"); #[cfg(test)] -/// Prefer the basename for `/dev/...` entries while keeping Pulse names intact. -fn compact_device_name(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - return "auto".to_string(); - } - trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() -} - -pub fn capitalize(value: &str) -> String { - let mut chars = value.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), - None => String::new(), - } -} - -pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option { - combo - .active_id() - .map(|value| value.to_string()) - .or_else(|| combo.active_text().map(|value| value.to_string())) - .and_then(|value| { - let value = value.to_string(); - let trimmed = value.trim(); - if trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("auto") - || trimmed.eq_ignore_ascii_case("all") - { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { - let current = entry.text(); - let trimmed = current.trim(); - if trimmed.is_empty() { - fallback.to_string() - } else { - trimmed.to_string() - } -} - -pub fn input_control_path() -> PathBuf { - std::env::var(INPUT_CONTROL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) -} - -pub fn input_state_path() -> PathBuf { - std::env::var(INPUT_STATE_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) -} - -pub fn input_toggle_control_path() -> PathBuf { - std::env::var(TOGGLE_KEY_CONTROL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH)) -} - -pub fn audio_gain_control_path() -> PathBuf { - std::env::var(AUDIO_GAIN_CONTROL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_AUDIO_GAIN_CONTROL_PATH)) -} - -pub fn mic_gain_control_path() -> PathBuf { - std::env::var(MIC_GAIN_CONTROL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_MIC_GAIN_CONTROL_PATH)) -} - -pub fn uplink_camera_preview_path() -> PathBuf { - std::env::var(UPLINK_CAMERA_PREVIEW_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_UPLINK_CAMERA_PREVIEW_PATH)) -} - -pub fn uplink_mic_level_path() -> PathBuf { - std::env::var(UPLINK_MIC_LEVEL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_UPLINK_MIC_LEVEL_PATH)) -} - -pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { - std::fs::write( - path, - format!("{} {}\n", routing_name(routing), control_request_nonce()), - )?; - Ok(()) -} - -pub fn write_audio_gain_request(path: &Path, gain_percent: u32) -> Result<()> { - let gain = gain_percent.min(super::state::MAX_AUDIO_GAIN_PERCENT) as f64 / 100.0; - std::fs::write(path, format!("{gain:.3} {}\n", control_request_nonce()))?; - Ok(()) -} - -pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> { - let gain = gain_percent.min(super::state::MAX_MIC_GAIN_PERCENT) as f64 / 100.0; - std::fs::write(path, format!("{gain:.3} {}\n", control_request_nonce()))?; - Ok(()) -} - -pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> { - std::fs::write( - path, - format!("{} {}\n", swap_key.trim(), control_request_nonce()), - )?; - Ok(()) -} - -pub fn read_input_routing_state(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - match raw - .split_ascii_whitespace() - .next()? - .to_ascii_lowercase() - .as_str() - { - "local" => Some(InputRouting::Local), - "remote" => Some(InputRouting::Remote), - _ => None, - } -} - -fn control_request_nonce() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default() -} - -pub fn routing_name(routing: InputRouting) -> &'static str { - match routing { - InputRouting::Local => "local", - InputRouting::Remote => "remote", - } -} - -pub fn path_marker(path: &Path) -> u128 { - std::fs::metadata(path) - .ok() - .and_then(|meta| meta.modified().ok()) - .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { - let wanted = wanted.unwrap_or("auto"); - if combo.set_active_id(Some(wanted)) { - return; - } - if combo.set_active_id(Some("auto")) { - return; - } - let _ = combo.set_active_id(Some("all")); -} - -pub fn toggle_key_label(raw: &str) -> String { - match raw.trim().to_ascii_lowercase().as_str() { - "" | "off" | "none" | "disabled" => "Disabled".to_string(), - "scrolllock" | "scroll_lock" | "scroll-lock" => "Scroll Lock".to_string(), - "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { - "SysRq / PrtSc".to_string() - } - "pause" | "pausebreak" | "pause_break" | "pause-break" => "Pause".to_string(), - "pageup" | "page_up" | "page-up" => "Page Up".to_string(), - "pagedown" | "page_down" | "page-down" => "Page Down".to_string(), - "capslock" | "caps_lock" | "caps-lock" => "Caps Lock".to_string(), - "backspace" | "back_space" | "back-space" => "Backspace".to_string(), - "space" | "spacebar" => "Space".to_string(), - "escape" | "esc" => "Escape".to_string(), - value - if value.starts_with('f') - && value.len() <= 3 - && value[1..].chars().all(|ch| ch.is_ascii_digit()) => - { - value.to_ascii_uppercase() - } - value if value.len() == 1 => value.to_ascii_uppercase(), - value => capitalize(&value.replace('_', " ").replace('-', " ")), - } -} - -pub fn capture_swap_key(key: gtk::gdk::Key) -> Option { - let normalized_name = key.name()?.to_string().to_ascii_lowercase(); - match normalized_name.as_str() { - "shift_l" | "shift_r" | "control_l" | "control_r" | "alt_l" | "alt_r" | "super_l" - | "super_r" | "meta_l" | "meta_r" | "hyper_l" | "hyper_r" | "iso_level3_shift" - | "multi_key" => None, - "scroll_lock" => Some("scrolllock".to_string()), - "sys_req" | "print" => Some("sysrq".to_string()), - "pause" | "break" => Some("pause".to_string()), - "page_up" => Some("pageup".to_string()), - "page_down" => Some("pagedown".to_string()), - "caps_lock" => Some("capslock".to_string()), - "backspace" => Some("backspace".to_string()), - "return" => Some("enter".to_string()), - "space" => Some("space".to_string()), - "escape" => Some("escape".to_string()), - "kp_0" => Some("0".to_string()), - "kp_1" => Some("1".to_string()), - "kp_2" => Some("2".to_string()), - "kp_3" => Some("3".to_string()), - "kp_4" => Some("4".to_string()), - "kp_5" => Some("5".to_string()), - "kp_6" => Some("6".to_string()), - "kp_7" => Some("7".to_string()), - "kp_8" => Some("8".to_string()), - "kp_9" => Some("9".to_string()), - other - if other.starts_with('f') - && other.len() <= 3 - && other[1..].chars().all(|ch| ch.is_ascii_digit()) => - { - Some(other.to_string()) - } - other if other.len() == 1 => { - let ch = other.chars().next()?; - if ch.is_ascii_alphanumeric() { - Some(ch.to_ascii_lowercase().to_string()) - } else { - None - } - } - "insert" | "delete" | "home" | "end" | "left" | "right" | "up" | "down" | "tab" => { - Some(normalized_name) - } - _ => None, - } -} - -pub fn spawn_client_process( - server_addr: &str, - state: &LauncherState, - input_toggle_key: &str, - input_control_path: &Path, - input_state_path: &Path, - input_toggle_control_path: &Path, -) -> Result { - let exe = std::env::current_exe()?; - let mut command = Command::new(exe); - command.arg("--no-launcher"); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - command.env("LESAVKA_LAUNCHER_CHILD", "1"); - command.env( - "LESAVKA_LAUNCHER_PARENT_PID", - std::process::id().to_string(), - ); - if let Some(start_ticks) = super::launcher_parent_start_ticks() { - command.env("LESAVKA_LAUNCHER_PARENT_START_TICKS", start_ticks); - } - command.env("LESAVKA_SERVER_ADDR", server_addr); - command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); - command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka"); - command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); - command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); - command.env( - LAUNCHER_CLIPBOARD_CONTROL_ENV, - launcher_clipboard_control_path(), - ); - command.env(INPUT_CONTROL_ENV, input_control_path); - command.env(INPUT_STATE_ENV, input_state_path); - command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path); - command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); - command.env("LESAVKA_CLIPBOARD_PASTE", "1"); - let audio_gain_path = audio_gain_control_path(); - let _ = write_audio_gain_request(&audio_gain_path, state.audio_gain_percent); - command.env(AUDIO_GAIN_CONTROL_ENV, audio_gain_path); - let mic_gain_path = mic_gain_control_path(); - let _ = write_mic_gain_request(&mic_gain_path, state.mic_gain_percent); - command.env(MIC_GAIN_CONTROL_ENV, mic_gain_path); - let camera_preview_path = uplink_camera_preview_path(); - let _ = std::fs::remove_file(&camera_preview_path); - command.env(UPLINK_CAMERA_PREVIEW_ENV, camera_preview_path); - let mic_level_path = uplink_mic_level_path(); - let _ = std::fs::remove_file(&mic_level_path); - command.env(UPLINK_MIC_LEVEL_ENV, mic_level_path); - for (key, value) in runtime_env_vars(state) { - command.env(key, value); - } - Ok(command.spawn()?) -} - -pub fn attach_child_log_streams(child: &mut RelayChild, tx: Sender) { - if let Some(stdout) = child.stdout.take() { - spawn_log_reader(stdout, "[relay] ", tx.clone()); - } - if let Some(stderr) = child.stderr.take() { - spawn_log_reader(stderr, "[relay:stderr] ", tx); - } -} - -fn spawn_log_reader(reader: R, prefix: &'static str, tx: Sender) -where - R: std::io::Read + Send + 'static, -{ - std::thread::spawn(move || { - for line in BufReader::new(reader) - .lines() - .map_while(std::result::Result::ok) - { - let trimmed = line.trim(); - if !trimmed.is_empty() { - let _ = tx.send(format!("{prefix}{trimmed}")); - } - } - }); -} - -pub fn append_session_log(buffer: >k::TextBuffer, message: &str) { - let cleaned = strip_ansi_sequences(message); - let trimmed = cleaned.trim(); - if trimmed.is_empty() { - return; - } - append_clean_session_log(buffer, trimmed); -} - -/// Appends a session log line only when it passes the selected severity filter. -pub fn append_session_log_for_level( - buffer: >k::TextBuffer, - message: &str, - level: ConsoleLogLevel, -) -> bool { - let cleaned = strip_ansi_sequences(message); - let trimmed = cleaned.trim(); - if trimmed.is_empty() || !should_show_clean_session_log_line(trimmed, level) { - return false; - } - append_clean_session_log(buffer, trimmed); - true -} - -#[cfg(test)] -fn should_show_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { - let cleaned = strip_ansi_sequences(message); - let trimmed = cleaned.trim(); - !trimmed.is_empty() && should_show_clean_session_log_line(trimmed, level) -} - -/// Writes a cleaned line with the GTK text tags that match its source/severity. -fn append_clean_session_log(buffer: >k::TextBuffer, trimmed: &str) { - let mut end = buffer.end_iter(); - let tags = classify_log_tags(trimmed); - if tags.is_empty() { - buffer.insert(&mut end, &format!("{trimmed}\n")); - } else { - buffer.insert_with_tags_by_name(&mut end, &format!("{trimmed}\n"), &tags); - } -} - -pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> { - let text = buffer - .text(&buffer.start_iter(), &buffer.end_iter(), false) - .to_string(); - copy_plain_text(&text) -} - -pub fn copy_plain_text(text: &str) -> Result<()> { - let display = gtk::gdk::Display::default() - .ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?; - display.clipboard().set_text(text); - Ok(()) -} - -pub fn refresh_diagnostics_report( - widgets: &LauncherWidgets, - state: &LauncherState, - child_running: bool, -) { - let mut snapshot = SnapshotReport::from_state( - state, - &widgets.diagnostics_log.borrow(), - quality_probe_command().to_string(), - ); - if child_running && !snapshot.remote_active { - snapshot.recommendations.insert( - 0, - "The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(), - ); - } - let rendered = snapshot.to_pretty_text(); - if *widgets.diagnostics_rendered_text.borrow() == rendered { - return; - } - - let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment(); - let previous_value = diagnostics_adjustment.value(); - let previous_max = - (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0); - let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); - let popout_adjustment = widgets - .diagnostics_popout_scroll - .borrow() - .as_ref() - .map(|scroll| scroll.vadjustment()); - let popout_state = popout_adjustment.as_ref().map(|adjustment| { - let previous_value = adjustment.value(); - let previous_max = (adjustment.upper() - adjustment.page_size()).max(0.0); - let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); - (adjustment.clone(), previous_value, was_at_bottom) - }); - let restore_adjustment = - |adjustment: >k::Adjustment, previous_value: f64, was_at_bottom: bool| { - let max = (adjustment.upper() - adjustment.page_size()).max(0.0); - let target = if was_at_bottom { - max - } else { - previous_value.min(max) - }; - if (adjustment.value() - target).abs() > 1.0 { - adjustment.set_value(target); - } - }; - *widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone(); - let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty(); - if update_docked { - widgets.diagnostics_label.set_text(&rendered); - restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); - } - let update_popout = popout_state - .as_ref() - .map(|(_, _, was_at_bottom)| *was_at_bottom) - .unwrap_or(false); - if let Some(label) = widgets.diagnostics_popout_label.borrow().as_ref() - && (update_popout || label.text().is_empty()) - { - label.set_text(&rendered); - } - if update_popout - && let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() - { - restore_adjustment(adjustment, *previous_value, *was_at_bottom); - } - glib::idle_add_local_once(move || { - if update_docked { - restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); - } - if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state { - restore_adjustment(&adjustment, previous_value, was_at_bottom); - } - }); -} - -pub fn open_session_log_popout( - app: >k::Application, - handle: &Rc>>, - buffer: >k::TextBuffer, -) { - open_text_buffer_popout( - app, - handle, - None, - buffer, - "Lesavka Log", - "Copy", - gtk::WrapMode::WordChar, - ); -} - -pub fn open_diagnostics_popout( - app: >k::Application, - handle: &Rc>>, - label_handle: &Rc>>, - scroll_handle: &Rc>>, - rendered_text: &Rc>, -) { - if let Some(window) = handle.borrow().as_ref() { - window.present(); - return; - } - - let window = gtk::ApplicationWindow::builder() - .application(app) - .title("Lesavka Diagnostics") - .default_width(980) - .default_height(680) - .build(); - super::ui_components::install_css(&window); - super::ui_components::install_window_icon(&window); - - let root = gtk::Box::new(gtk::Orientation::Vertical, 10); - root.set_margin_start(14); - root.set_margin_end(14); - root.set_margin_top(14); - root.set_margin_bottom(14); - - let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let copy_button = gtk::Button::with_label("Copy Report"); - toolbar.append(©_button); - root.append(&toolbar); - - let current_text = rendered_text.borrow().clone(); - let label = gtk::Label::new(Some(¤t_text)); - label.add_css_class("status-log"); - label.set_selectable(true); - label.set_xalign(0.0); - label.set_yalign(0.0); - label.set_wrap(false); - label.set_halign(gtk::Align::Start); - label.set_valign(gtk::Align::Start); - label.set_hexpand(true); - let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); - shell.set_hexpand(true); - shell.set_vexpand(false); - shell.append(&label); - let scroll = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .child(&shell) - .build(); - *label_handle.borrow_mut() = Some(label.clone()); - *scroll_handle.borrow_mut() = Some(scroll.clone()); - root.append(&scroll); - window.set_child(Some(&root)); - window.maximize(); - - { - let rendered_text = Rc::clone(rendered_text); - copy_button.connect_clicked(move |_| { - let current_text = rendered_text.borrow().clone(); - let _ = copy_plain_text(¤t_text); - }); - } - - { - let handle = Rc::clone(handle); - let label_handle = Rc::clone(label_handle); - let scroll_handle = Rc::clone(scroll_handle); - window.connect_close_request(move |_| { - handle.borrow_mut().take(); - label_handle.borrow_mut().take(); - scroll_handle.borrow_mut().take(); - glib::Propagation::Proceed - }); - } - - *handle.borrow_mut() = Some(window.clone()); - window.present(); -} - -fn open_text_buffer_popout( - app: >k::Application, - handle: &Rc>>, - scroll_handle: Option<&Rc>>>, - buffer: >k::TextBuffer, - title: &str, - copy_button_label: &str, - wrap_mode: gtk::WrapMode, -) { - if let Some(window) = handle.borrow().as_ref() { - window.present(); - return; - } - - let window = gtk::ApplicationWindow::builder() - .application(app) - .title(title) - .default_width(980) - .default_height(680) - .build(); - super::ui_components::install_css(&window); - super::ui_components::install_window_icon(&window); - - let root = gtk::Box::new(gtk::Orientation::Vertical, 10); - root.set_margin_start(14); - root.set_margin_end(14); - root.set_margin_top(14); - root.set_margin_bottom(14); - - let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let copy_button = gtk::Button::with_label(copy_button_label); - toolbar.append(©_button); - root.append(&toolbar); - - let view = gtk::TextView::with_buffer(buffer); - view.add_css_class("status-log"); - view.set_editable(false); - view.set_cursor_visible(false); - view.set_monospace(true); - view.set_wrap_mode(wrap_mode); - let scroll = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .child(&view) - .build(); - if let Some(scroll_handle) = scroll_handle { - *scroll_handle.borrow_mut() = Some(scroll.clone()); - } - root.append(&scroll); - window.set_child(Some(&root)); - window.maximize(); - - { - let buffer = buffer.clone(); - copy_button.connect_clicked(move |_| { - let _ = copy_session_log(&buffer); - }); - } - - { - let handle = Rc::clone(handle); - let scroll_handle = scroll_handle.cloned(); - window.connect_close_request(move |_| { - handle.borrow_mut().take(); - if let Some(scroll_handle) = &scroll_handle { - scroll_handle.borrow_mut().take(); - } - glib::Propagation::Proceed - }); - } - - *handle.borrow_mut() = Some(window.clone()); - window.present(); -} - -pub fn stop_child_process(child_proc: &Rc>>) { - if let Some(mut child) = child_proc.borrow_mut().take() { - let _ = child.kill(); - let _ = child.wait(); - } -} - -pub fn shutdown_launcher_runtime( - child_proc: &Rc>>, - tests: &Rc>, - preview: Option<&LauncherPreview>, - widgets: &LauncherWidgets, - popouts: &Rc; 2]>>, - diagnostics_popout: &Rc>>, - log_popout: &Rc>>, -) { - stop_child_process(child_proc); - tests.borrow_mut().stop_all(); - - if let Some(preview) = preview { - preview.set_session_active(false); - preview.shutdown_all(); - } - - for pane in &widgets.display_panes { - if let Some(binding) = pane.preview_binding.borrow_mut().take() { - binding.close(); - } - pane.picture.set_paintable(Option::<&gdk::Paintable>::None); - pane.stream_status.set_text(""); - } - - let mut detached_popouts = Vec::new(); - { - let mut slots = popouts.borrow_mut(); - for slot in slots.iter_mut() { - if let Some(handle) = slot.take() { - detached_popouts.push(handle); - } - } - } - for handle in detached_popouts { - handle.binding.close(); - handle - .picture - .set_paintable(Option::<&gdk::Paintable>::None); - handle.window.set_child(Option::<>k::Widget>::None); - handle.window.hide(); - } - - if let Some(window) = diagnostics_popout.borrow_mut().take() { - widgets.diagnostics_popout_label.borrow_mut().take(); - widgets.diagnostics_popout_scroll.borrow_mut().take(); - window.set_child(Option::<>k::Widget>::None); - window.hide(); - } - if let Some(window) = log_popout.borrow_mut().take() { - window.set_child(Option::<>k::Widget>::None); - window.hide(); - } -} - -pub fn reap_exited_child(child_proc: &Rc>>) -> bool { - let mut slot = child_proc.borrow_mut(); - match slot.as_mut() { - Some(child) => match child.try_wait() { - Ok(Some(_)) => { - *slot = None; - false - } - Ok(None) | Err(_) => true, - }, - None => false, - } -} - -pub fn next_input_routing(routing: InputRouting) -> InputRouting { - match routing { - InputRouting::Remote => InputRouting::Local, - InputRouting::Local => InputRouting::Remote, - } -} - -fn set_status_light(light: >k::Box, state: StatusLightState) { - light.remove_css_class("status-light-live"); - light.remove_css_class("status-light-idle"); - light.remove_css_class("status-light-warning"); - light.remove_css_class("status-light-caution"); - light.add_css_class(state.css_class()); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SessionLogSeverity { - Error, - Warn, - Info, - Debug, - Trace, - Other, -} - -impl SessionLogSeverity { - const fn rank(self) -> Option { - match self { - Self::Error => Some(0), - Self::Warn => Some(1), - Self::Info => Some(2), - Self::Debug => Some(3), - Self::Trace => Some(4), - Self::Other => None, - } - } -} - -/// Returns whether a cleaned log line should be visible at the selected threshold. -fn should_show_clean_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { - let severity = session_log_severity(message); - if severity == SessionLogSeverity::Error { - return true; - } - if message.starts_with("[launcher]") || message.starts_with("[preview:") { - return true; - } - match severity.rank() { - Some(rank) => rank <= level.rank(), - None => true, - } -} - -/// Infers severity from the structured relay prefix and human-readable text. -fn session_log_severity(message: &str) -> SessionLogSeverity { - let uppercase = message.to_ascii_uppercase(); - if message.starts_with("[relay:stderr]") - || message.contains('❌') - || uppercase.contains(" ERROR ") - || uppercase.contains("FAILED") - || uppercase.contains("PANIC") - || uppercase.contains(" RPC FAILED") - { - SessionLogSeverity::Error - } else if uppercase.contains(" WARN ") - || uppercase.contains("UNAVAILABLE") - || uppercase.contains("WAITING FOR CAPTURE PIPELINE") - { - SessionLogSeverity::Warn - } else if uppercase.contains(" INFO ") { - SessionLogSeverity::Info - } else if uppercase.contains(" DEBUG ") { - SessionLogSeverity::Debug - } else if uppercase.contains(" TRACE ") { - SessionLogSeverity::Trace - } else { - SessionLogSeverity::Other - } -} - -fn classify_log_tags(message: &str) -> Vec<&'static str> { - let mut tags = Vec::new(); - if message.starts_with("[launcher]") { - tags.push("log-launcher"); - } else if message.starts_with("[relay:stderr]") { - tags.push("log-stderr"); - } else if message.starts_with("[relay]") { - tags.push("log-relay"); - } else if message.starts_with("[preview:") { - tags.push("log-preview"); - } else { - tags.push("log-launcher"); - } - - let uppercase = message.to_ascii_uppercase(); - if message.starts_with("[relay:stderr]") - || message.contains('❌') - || uppercase.contains(" ERROR ") - || uppercase.contains("FAILED") - || uppercase.contains("PANIC") - || uppercase.contains(" RPC FAILED") - { - tags.push("log-error"); - } else if uppercase.contains(" WARN ") - || uppercase.contains("UNAVAILABLE") - || uppercase.contains("WAITING FOR CAPTURE PIPELINE") - { - tags.push("log-warn"); - } - tags -} - -fn strip_ansi_sequences(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - match chars.peek().copied() { - Some('[') => { - let _ = chars.next(); - while let Some(next) = chars.next() { - if ('@'..='~').contains(&next) { - break; - } - } - } - Some(']') => { - let _ = chars.next(); - while let Some(next) = chars.next() { - if next == '\u{7}' { - break; - } - if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) { - let _ = chars.next(); - break; - } - } - } - _ => {} - } - continue; - } - output.push(ch); - } - output -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::launcher::{ - devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState, - ui_components::build_launcher_view, - }; - use serial_test::serial; - use std::{cell::RefCell, rc::Rc}; - - #[test] - fn local_test_detail_mentions_idle_and_running_modes() { - assert!(local_test_detail(false, false, false, false).contains("idle")); - let running = local_test_detail(true, true, false, false); - assert!(running.contains("camera preview")); - assert!(running.contains("mic monitor")); - } - - #[test] - fn gpio_power_label_tracks_detected_devices() { - let mut power = CapturePowerStatus::default(); - assert_eq!(gpio_power_label(&power), "Unavailable"); - - power.available = true; - assert_eq!(gpio_power_label(&power), "Power Off"); - - power.enabled = true; - assert_eq!(gpio_power_label(&power), "No Eyes"); - - power.detected_devices = 1; - assert_eq!(gpio_power_label(&power), "1 Eye"); - - power.detected_devices = 2; - assert_eq!(gpio_power_label(&power), "2 Eyes"); - } - - #[test] - fn server_chip_state_tracks_connection_not_just_reachability() { - let mut state = LauncherState::new(); - assert_eq!(server_light_state(&state, false), StatusLightState::Idle); - assert_eq!(server_version_label(&state), "-"); - - state.set_server_available(true); - state.set_server_version(Some("0.12.3".to_string())); - assert_eq!(server_light_state(&state, false), StatusLightState::Caution); - assert_eq!(server_version_label(&state), "v0.12.3"); - - assert_eq!(server_light_state(&state, true), StatusLightState::Live); - - state.set_server_version(Some("v0.12.4".to_string())); - assert_eq!(server_version_label(&state), "v0.12.4"); - - state.set_server_version(Some(" ".to_string())); - assert_eq!(server_version_label(&state), "-"); - } - - #[test] - fn capture_power_detail_mentions_detected_eyes_when_powered() { - let mut power = CapturePowerStatus::default(); - power.available = true; - power.enabled = true; - power.detail = "active/running".to_string(); - power.detected_devices = 1; - - assert!(capture_power_detail(&power).contains("1 eye detected")); - } - - #[test] - fn compact_device_name_prefers_basename_when_available() { - assert_eq!(compact_device_name("/dev/video0"), "video0"); - assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb"); - } - - #[test] - fn strip_ansi_sequences_removes_terminal_codes() { - let raw = "\u{1b}[32mINFO\u{1b}[0m hello"; - assert_eq!(strip_ansi_sequences(raw), "INFO hello"); - } - - #[test] - fn classify_log_tags_assigns_prefix_and_severity_colors() { - let tags = classify_log_tags("[relay] WARN pipeline failed"); - assert!(tags.contains(&"log-relay")); - assert!(tags.contains(&"log-error") || tags.contains(&"log-warn")); - } - - #[test] - #[doc = "Verifies the default console filter hides relay INFO noise."] - fn session_log_filter_hides_noisy_info_by_default_but_keeps_errors() { - assert!(!should_show_session_log_line( - "[relay] 2026-04-22T23:20:17Z INFO ThreadId(01) audio packet received packet=3000", - ConsoleLogLevel::Warn - )); - assert!(!should_show_session_log_line( - "[relay] 2026-04-22T23:20:17Z INFO ThreadId(04) decoded audio level rms=-32", - ConsoleLogLevel::Warn - )); - assert!(should_show_session_log_line( - "[relay] 2026-04-22T23:20:17Z WARN pipeline is recovering", - ConsoleLogLevel::Warn - )); - assert!(should_show_session_log_line( - "[relay] 2026-04-22T23:20:17Z INFO ❌ connect failed", - ConsoleLogLevel::Error - )); - assert!(should_show_session_log_line( - "[launcher] Relay connected with inputs routed to remote.", - ConsoleLogLevel::Error - )); - assert!(should_show_session_log_line( - "[relay] 2026-04-22T23:20:17Z INFO audio packet received", - ConsoleLogLevel::Info - )); - } - - #[test] - fn write_audio_gain_request_formats_live_control_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("gain.control"); - write_audio_gain_request(&path, 425).expect("write gain"); - let raw = std::fs::read_to_string(path).expect("read gain"); - assert!(raw.starts_with("4.250 "), "{raw}"); - } - - #[test] - fn write_mic_gain_request_formats_live_control_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("mic-gain.control"); - write_mic_gain_request(&path, 325).expect("write gain"); - let raw = std::fs::read_to_string(path).expect("read gain"); - assert!(raw.starts_with("3.250 "), "{raw}"); - } - - #[gtk::test] - #[serial] - fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { - if gtk::gdk::Display::default().is_none() { - return; - } - - let app = gtk::Application::builder() - .application_id("dev.lesavka.test-dock") - .build(); - let _ = app.register(None::<>k::gio::Cancellable>); - - let state = Rc::new(RefCell::new(LauncherState::new())); - state - .borrow_mut() - .set_display_surface(0, DisplaySurface::Window); - state - .borrow_mut() - .set_display_surface(1, DisplaySurface::Window); - let state_snapshot = state.borrow().clone(); - let view = build_launcher_view( - &app, - "http://127.0.0.1:50051", - &DeviceCatalog::default(), - &state_snapshot, - ); - let child_proc = Rc::new(RefCell::new(None::)); - - let left_binding = PreviewBinding::test_stub(); - let right_binding = PreviewBinding::test_stub(); - { - let mut popouts = view.popouts.borrow_mut(); - popouts[0] = Some(PopoutWindowHandle { - window: gtk::ApplicationWindow::builder() - .application(&app) - .title("Left") - .build(), - frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), - picture: gtk::Picture::new(), - status_label: gtk::Label::new(None), - binding: left_binding, - }); - popouts[1] = Some(PopoutWindowHandle { - window: gtk::ApplicationWindow::builder() - .application(&app) - .title("Right") - .build(), - frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), - picture: gtk::Picture::new(), - status_label: gtk::Label::new(None), - binding: right_binding, - }); - } - - dock_all_displays_to_preview(&state, &child_proc, &view.popouts, &view.widgets); - - assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); - assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); - assert_eq!(state.borrow().display_surface(1), DisplaySurface::Preview); - } - - #[gtk::test] - #[serial] - fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() { - if gtk::gdk::Display::default().is_none() { - return; - } - - let app = gtk::Application::builder() - .application_id("dev.lesavka.test-reentrant-dock") - .build(); - let _ = app.register(None::<>k::gio::Cancellable>); - - let state = Rc::new(RefCell::new(LauncherState::new())); - state - .borrow_mut() - .set_display_surface(0, DisplaySurface::Window); - let state_snapshot = state.borrow().clone(); - let view = build_launcher_view( - &app, - "http://127.0.0.1:50051", - &DeviceCatalog::default(), - &state_snapshot, - ); - let child_proc = Rc::new(RefCell::new(None::)); - - let popouts = Rc::clone(&view.popouts); - let window = gtk::ApplicationWindow::builder() - .application(&app) - .title("Reentrant") - .build(); - { - let popouts = Rc::clone(&popouts); - window.connect_close_request(move |_| { - let _ = popouts.borrow_mut()[0].take(); - glib::Propagation::Proceed - }); - } - { - let mut slot = popouts.borrow_mut(); - slot[0] = Some(PopoutWindowHandle { - window, - frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), - picture: gtk::Picture::new(), - status_label: gtk::Label::new(None), - binding: PreviewBinding::test_stub(), - }); - } - - dock_all_displays_to_preview(&state, &child_proc, &popouts, &view.widgets); - - assert!(popouts.borrow().iter().all(|handle| handle.is_none())); - assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); - } - - #[gtk::test] - #[serial] - fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() { - if gtk::gdk::Display::default().is_none() { - return; - } - - let app = gtk::Application::builder() - .application_id("dev.lesavka.test-shutdown") - .build(); - let _ = app.register(None::<>k::gio::Cancellable>); - - let state = Rc::new(RefCell::new(LauncherState::new())); - let state_snapshot = state.borrow().clone(); - let view = build_launcher_view( - &app, - "http://127.0.0.1:50051", - &DeviceCatalog::default(), - &state_snapshot, - ); - let child_proc = Rc::new(RefCell::new(None::)); - let tests = Rc::new(RefCell::new(DeviceTestController::new())); - - let left_binding = PreviewBinding::test_stub(); - let right_binding = PreviewBinding::test_stub(); - *view.widgets.display_panes[0].preview_binding.borrow_mut() = Some(left_binding.clone()); - *view.widgets.display_panes[1].preview_binding.borrow_mut() = Some(right_binding.clone()); - - { - let mut popouts = view.popouts.borrow_mut(); - popouts[0] = Some(PopoutWindowHandle { - window: gtk::ApplicationWindow::builder() - .application(&app) - .title("Left") - .build(), - frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), - picture: gtk::Picture::new(), - status_label: gtk::Label::new(None), - binding: PreviewBinding::test_stub(), - }); - } - - *view.diagnostics_popout.borrow_mut() = Some( - gtk::ApplicationWindow::builder() - .application(&app) - .title("Diagnostics") - .build(), - ); - *view.log_popout.borrow_mut() = Some( - gtk::ApplicationWindow::builder() - .application(&app) - .title("Log") - .build(), - ); - - shutdown_launcher_runtime( - &child_proc, - &tests, - None, - &view.widgets, - &view.popouts, - &view.diagnostics_popout, - &view.log_popout, - ); - - assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); - assert!( - view.widgets.display_panes[0] - .preview_binding - .borrow() - .is_none() - ); - assert!( - view.widgets.display_panes[1] - .preview_binding - .borrow() - .is_none() - ); - assert!(view.diagnostics_popout.borrow().is_none()); - assert!(view.log_popout.borrow().is_none()); - } -} +#[path = "tests/ui_runtime.rs"] +mod tests; diff --git a/client/src/launcher/ui_runtime/control_paths.rs b/client/src/launcher/ui_runtime/control_paths.rs new file mode 100644 index 0000000..7c14f0a --- /dev/null +++ b/client/src/launcher/ui_runtime/control_paths.rs @@ -0,0 +1,238 @@ +#[cfg(test)] +/// Prefer the basename for `/dev/...` entries while keeping Pulse names intact. +fn compact_device_name(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return "auto".to_string(); + } + trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() +} + +pub fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), + None => String::new(), + } +} + +pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option { + combo + .active_id() + .map(|value| value.to_string()) + .or_else(|| combo.active_text().map(|value| value.to_string())) + .and_then(|value| { + let value = value.to_string(); + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("auto") + || trimmed.eq_ignore_ascii_case("all") + { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { + let current = entry.text(); + let trimmed = current.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +pub fn input_control_path() -> PathBuf { + std::env::var(INPUT_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) +} + +pub fn input_state_path() -> PathBuf { + std::env::var(INPUT_STATE_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) +} + +pub fn input_toggle_control_path() -> PathBuf { + std::env::var(TOGGLE_KEY_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH)) +} + +pub fn audio_gain_control_path() -> PathBuf { + std::env::var(AUDIO_GAIN_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_AUDIO_GAIN_CONTROL_PATH)) +} + +pub fn mic_gain_control_path() -> PathBuf { + std::env::var(MIC_GAIN_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_MIC_GAIN_CONTROL_PATH)) +} + +pub fn uplink_camera_preview_path() -> PathBuf { + std::env::var(UPLINK_CAMERA_PREVIEW_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_UPLINK_CAMERA_PREVIEW_PATH)) +} + +pub fn uplink_mic_level_path() -> PathBuf { + std::env::var(UPLINK_MIC_LEVEL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_UPLINK_MIC_LEVEL_PATH)) +} + +pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { + std::fs::write( + path, + format!("{} {}\n", routing_name(routing), control_request_nonce()), + )?; + Ok(()) +} + +pub fn write_audio_gain_request(path: &Path, gain_percent: u32) -> Result<()> { + let gain = gain_percent.min(super::state::MAX_AUDIO_GAIN_PERCENT) as f64 / 100.0; + std::fs::write(path, format!("{gain:.3} {}\n", control_request_nonce()))?; + Ok(()) +} + +pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> { + let gain = gain_percent.min(super::state::MAX_MIC_GAIN_PERCENT) as f64 / 100.0; + std::fs::write(path, format!("{gain:.3} {}\n", control_request_nonce()))?; + Ok(()) +} + +pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> { + std::fs::write( + path, + format!("{} {}\n", swap_key.trim(), control_request_nonce()), + )?; + Ok(()) +} + +pub fn read_input_routing_state(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + match raw + .split_ascii_whitespace() + .next()? + .to_ascii_lowercase() + .as_str() + { + "local" => Some(InputRouting::Local), + "remote" => Some(InputRouting::Remote), + _ => None, + } +} + +fn control_request_nonce() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} + +pub fn routing_name(routing: InputRouting) -> &'static str { + match routing { + InputRouting::Local => "local", + InputRouting::Remote => "remote", + } +} + +pub fn path_marker(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { + let wanted = wanted.unwrap_or("auto"); + if combo.set_active_id(Some(wanted)) { + return; + } + if combo.set_active_id(Some("auto")) { + return; + } + let _ = combo.set_active_id(Some("all")); +} + +pub fn toggle_key_label(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "off" | "none" | "disabled" => "Disabled".to_string(), + "scrolllock" | "scroll_lock" | "scroll-lock" => "Scroll Lock".to_string(), + "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { + "SysRq / PrtSc".to_string() + } + "pause" | "pausebreak" | "pause_break" | "pause-break" => "Pause".to_string(), + "pageup" | "page_up" | "page-up" => "Page Up".to_string(), + "pagedown" | "page_down" | "page-down" => "Page Down".to_string(), + "capslock" | "caps_lock" | "caps-lock" => "Caps Lock".to_string(), + "backspace" | "back_space" | "back-space" => "Backspace".to_string(), + "space" | "spacebar" => "Space".to_string(), + "escape" | "esc" => "Escape".to_string(), + value + if value.starts_with('f') + && value.len() <= 3 + && value[1..].chars().all(|ch| ch.is_ascii_digit()) => + { + value.to_ascii_uppercase() + } + value if value.len() == 1 => value.to_ascii_uppercase(), + value => capitalize(&value.replace(['_', '-'], " ")), + } +} + +pub fn capture_swap_key(key: gtk::gdk::Key) -> Option { + let normalized_name = key.name()?.to_string().to_ascii_lowercase(); + match normalized_name.as_str() { + "shift_l" | "shift_r" | "control_l" | "control_r" | "alt_l" | "alt_r" | "super_l" + | "super_r" | "meta_l" | "meta_r" | "hyper_l" | "hyper_r" | "iso_level3_shift" + | "multi_key" => None, + "scroll_lock" => Some("scrolllock".to_string()), + "sys_req" | "print" => Some("sysrq".to_string()), + "pause" | "break" => Some("pause".to_string()), + "page_up" => Some("pageup".to_string()), + "page_down" => Some("pagedown".to_string()), + "caps_lock" => Some("capslock".to_string()), + "backspace" => Some("backspace".to_string()), + "return" => Some("enter".to_string()), + "space" => Some("space".to_string()), + "escape" => Some("escape".to_string()), + "kp_0" => Some("0".to_string()), + "kp_1" => Some("1".to_string()), + "kp_2" => Some("2".to_string()), + "kp_3" => Some("3".to_string()), + "kp_4" => Some("4".to_string()), + "kp_5" => Some("5".to_string()), + "kp_6" => Some("6".to_string()), + "kp_7" => Some("7".to_string()), + "kp_8" => Some("8".to_string()), + "kp_9" => Some("9".to_string()), + other + if other.starts_with('f') + && other.len() <= 3 + && other[1..].chars().all(|ch| ch.is_ascii_digit()) => + { + Some(other.to_string()) + } + other if other.len() == 1 => { + let ch = other.chars().next()?; + if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase().to_string()) + } else { + None + } + } + "insert" | "delete" | "home" | "end" | "left" | "right" | "up" | "down" | "tab" => { + Some(normalized_name) + } + _ => None, + } +} diff --git a/client/src/launcher/ui_runtime/display_popouts.rs b/client/src/launcher/ui_runtime/display_popouts.rs new file mode 100644 index 0000000..7ebd9e7 --- /dev/null +++ b/client/src/launcher/ui_runtime/display_popouts.rs @@ -0,0 +1,262 @@ +pub fn open_popout_window( + app: >k::Application, + preview: &LauncherPreview, + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + let already_open = { + let popouts = popouts.borrow(); + popouts[monitor_id].is_some() + }; + if already_open { + return; + } + + if let Some(binding) = widgets.display_panes[monitor_id] + .preview_binding + .borrow() + .as_ref() + { + binding.set_enabled(false); + } + + let (breakout_size, breakout_limit) = { + let state = state.borrow(); + ( + state.breakout_size_choice(monitor_id), + state.breakout_display_size(), + ) + }; + let window = gtk::ApplicationWindow::builder() + .application(app) + .title(format!( + "Lesavka {}", + widgets.display_panes[monitor_id].title + )) + .default_width(breakout_size.width) + .default_height(breakout_size.height) + .build(); + super::ui_components::install_css(&window); + super::ui_components::install_window_icon(&window); + window.set_decorated(false); + window.set_resizable(false); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_halign(gtk::Align::Fill); + picture.set_valign(gtk::Align::Fill); + picture.set_can_shrink(true); + picture.set_keep_aspect_ratio(true); + picture.set_size_request(breakout_size.width, breakout_size.height); + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + root.set_hexpand(true); + root.set_vexpand(true); + root.set_halign(gtk::Align::Fill); + root.set_valign(gtk::Align::Fill); + root.set_size_request(breakout_size.width, breakout_size.height); + let frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); + frame.set_hexpand(true); + frame.set_vexpand(true); + frame.set_halign(gtk::Align::Fill); + frame.set_valign(gtk::Align::Fill); + frame.set_size_request(breakout_size.width, breakout_size.height); + frame.set_child(Some(&picture)); + root.append(&frame); + + let stream_status = gtk::Label::new(Some("")); + + let binding = preview + .install_on_picture(monitor_id, PreviewSurface::Window, &picture, &stream_status) + .expect("preview binding for popout"); + + window.set_child(Some(&root)); + install_popout_drag(&window, &picture); + apply_popout_window_geometry(&window, &root, &picture, breakout_size, breakout_limit); + + let state_handle = Rc::clone(state); + let child_proc_handle = Rc::clone(child_proc); + let popouts_handle = Rc::clone(popouts); + let widgets_handle = widgets.clone(); + let close_binding = binding.clone(); + window.connect_close_request(move |_| { + let handle = { + let mut popouts = popouts_handle.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + if let Some(preview_binding) = widgets_handle.display_panes[monitor_id] + .preview_binding + .borrow() + .as_ref() + { + preview_binding.set_enabled(true); + } + { + let mut state = state_handle.borrow_mut(); + state.set_display_surface(monitor_id, DisplaySurface::Preview); + } + let child_running = child_proc_handle.borrow().is_some(); + let state_snapshot = state_handle.borrow().clone(); + refresh_launcher_ui(&widgets_handle, &state_snapshot, child_running); + } else { + close_binding.close(); + } + glib::Propagation::Proceed + }); + + { + let mut state = state.borrow_mut(); + state.set_display_surface(monitor_id, DisplaySurface::Window); + } + { + let mut popouts = popouts.borrow_mut(); + popouts[monitor_id] = Some(PopoutWindowHandle { + window: window.clone(), + frame: frame.clone(), + picture: picture.clone(), + status_label: stream_status.clone(), + binding, + }); + } + let child_running = child_proc.borrow().is_some(); + let state_snapshot = state.borrow().clone(); + refresh_launcher_ui(widgets, &state_snapshot, child_running); + window.present(); + schedule_popout_window_geometry( + window.clone(), + root.clone(), + picture.clone(), + breakout_size, + breakout_limit, + ); +} + +pub fn apply_popout_window_size( + handle: &PopoutWindowHandle, + size: BreakoutSizeChoice, + display_limit: super::state::PreviewSourceSize, +) { + let Some(root) = handle + .picture + .parent() + .and_then(|widget| widget.downcast::().ok()) + else { + return; + }; + apply_popout_window_geometry(&handle.window, &root, &handle.picture, size, display_limit); + handle.window.present(); + schedule_popout_window_geometry( + handle.window.clone(), + root.clone(), + handle.picture.clone(), + size, + display_limit, + ); +} + +pub fn dock_display_to_preview( + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + let handle = { + let mut popouts = popouts.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + handle.window.close(); + } + if let Some(binding) = widgets.display_panes[monitor_id] + .preview_binding + .borrow() + .as_ref() + { + binding.set_enabled(true); + } + { + let mut state = state.borrow_mut(); + state.set_display_surface(monitor_id, DisplaySurface::Preview); + } + let child_running = child_proc.borrow().is_some(); + let state_snapshot = state.borrow().clone(); + refresh_launcher_ui(widgets, &state_snapshot, child_running); +} + +pub fn dock_all_displays_to_preview( + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, +) { + let mut handles = Vec::new(); + { + let mut popouts = popouts.borrow_mut(); + for monitor_id in 0..2 { + if let Some(handle) = popouts[monitor_id].take() { + handles.push(handle); + } + } + } + for handle in handles { + handle.binding.close(); + handle.window.close(); + } + + for monitor_id in 0..2 { + if let Some(binding) = widgets.display_panes[monitor_id] + .preview_binding + .borrow() + .as_ref() + { + binding.set_enabled(true); + } + } + + { + let mut state = state.borrow_mut(); + for monitor_id in 0..2 { + state.set_display_surface(monitor_id, DisplaySurface::Preview); + } + } + + let child_running = child_proc.borrow().is_some(); + let state_snapshot = state.borrow().clone(); + refresh_launcher_ui(widgets, &state_snapshot, child_running); +} + +pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { + if let Some(binding) = pane.preview_binding.borrow().as_ref() { + binding.set_enabled(matches!(surface, DisplaySurface::Preview)); + } + pane.action_button + .set_sensitive(pane.preview_binding.borrow().is_some()); + match surface { + DisplaySurface::Preview => { + pane.stack.set_visible_child_name("preview"); + pane.action_button.set_label("Break Out"); + pane.placeholder.set_text( + "This feed is running in its own window.\nUse Return To Preview to dock it back here.", + ); + if pane.preview_binding.borrow().is_none() { + pane.stream_status.set_text("Preview unavailable"); + } + } + DisplaySurface::Window => { + pane.stack.set_visible_child_name("placeholder"); + pane.action_button.set_label("Return To Preview"); + pane.placeholder.set_text(&format!( + "{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.", + pane.title + )); + pane.stream_status.set_text("Streaming in its own window"); + } + } +} diff --git a/client/src/launcher/ui_runtime/log_filtering.rs b/client/src/launcher/ui_runtime/log_filtering.rs new file mode 100644 index 0000000..f001cdb --- /dev/null +++ b/client/src/launcher/ui_runtime/log_filtering.rs @@ -0,0 +1,139 @@ +fn set_status_light(light: >k::Box, state: StatusLightState) { + light.remove_css_class("status-light-live"); + light.remove_css_class("status-light-idle"); + light.remove_css_class("status-light-warning"); + light.remove_css_class("status-light-caution"); + light.add_css_class(state.css_class()); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionLogSeverity { + Error, + Warn, + Info, + Debug, + Trace, + Other, +} + +impl SessionLogSeverity { + const fn rank(self) -> Option { + match self { + Self::Error => Some(0), + Self::Warn => Some(1), + Self::Info => Some(2), + Self::Debug => Some(3), + Self::Trace => Some(4), + Self::Other => None, + } + } +} + +/// Returns whether a cleaned log line should be visible at the selected threshold. +fn should_show_clean_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { + let severity = session_log_severity(message); + if severity == SessionLogSeverity::Error { + return true; + } + if message.starts_with("[launcher]") || message.starts_with("[preview:") { + return true; + } + match severity.rank() { + Some(rank) => rank <= level.rank(), + None => true, + } +} + +/// Infers severity from the structured relay prefix and human-readable text. +fn session_log_severity(message: &str) -> SessionLogSeverity { + let uppercase = message.to_ascii_uppercase(); + if message.starts_with("[relay:stderr]") + || message.contains('❌') + || uppercase.contains(" ERROR ") + || uppercase.contains("FAILED") + || uppercase.contains("PANIC") + || uppercase.contains(" RPC FAILED") + { + SessionLogSeverity::Error + } else if uppercase.contains(" WARN ") + || uppercase.contains("UNAVAILABLE") + || uppercase.contains("WAITING FOR CAPTURE PIPELINE") + { + SessionLogSeverity::Warn + } else if uppercase.contains(" INFO ") { + SessionLogSeverity::Info + } else if uppercase.contains(" DEBUG ") { + SessionLogSeverity::Debug + } else if uppercase.contains(" TRACE ") { + SessionLogSeverity::Trace + } else { + SessionLogSeverity::Other + } +} + +fn classify_log_tags(message: &str) -> Vec<&'static str> { + let mut tags = Vec::new(); + if message.starts_with("[launcher]") { + tags.push("log-launcher"); + } else if message.starts_with("[relay:stderr]") { + tags.push("log-stderr"); + } else if message.starts_with("[relay]") { + tags.push("log-relay"); + } else if message.starts_with("[preview:") { + tags.push("log-preview"); + } else { + tags.push("log-launcher"); + } + + let uppercase = message.to_ascii_uppercase(); + if message.starts_with("[relay:stderr]") + || message.contains('❌') + || uppercase.contains(" ERROR ") + || uppercase.contains("FAILED") + || uppercase.contains("PANIC") + || uppercase.contains(" RPC FAILED") + { + tags.push("log-error"); + } else if uppercase.contains(" WARN ") + || uppercase.contains("UNAVAILABLE") + || uppercase.contains("WAITING FOR CAPTURE PIPELINE") + { + tags.push("log-warn"); + } + tags +} + +fn strip_ansi_sequences(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + match chars.peek().copied() { + Some('[') => { + let _ = chars.next(); + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + Some(']') => { + let _ = chars.next(); + while let Some(next) = chars.next() { + if next == '\u{7}' { + break; + } + if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) { + let _ = chars.next(); + break; + } + } + } + _ => {} + } + continue; + } + output.push(ch); + } + output +} diff --git a/client/src/launcher/ui_runtime/process_logs.rs b/client/src/launcher/ui_runtime/process_logs.rs new file mode 100644 index 0000000..3c7155f --- /dev/null +++ b/client/src/launcher/ui_runtime/process_logs.rs @@ -0,0 +1,213 @@ +pub fn spawn_client_process( + server_addr: &str, + state: &LauncherState, + input_toggle_key: &str, + input_control_path: &Path, + input_state_path: &Path, + input_toggle_control_path: &Path, +) -> Result { + let exe = std::env::current_exe()?; + let mut command = Command::new(exe); + command.arg("--no-launcher"); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + command.env("LESAVKA_LAUNCHER_CHILD", "1"); + command.env( + "LESAVKA_LAUNCHER_PARENT_PID", + std::process::id().to_string(), + ); + if let Some(start_ticks) = super::launcher_parent_start_ticks() { + command.env("LESAVKA_LAUNCHER_PARENT_START_TICKS", start_ticks); + } + command.env("LESAVKA_SERVER_ADDR", server_addr); + command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); + command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka"); + command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); + command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); + command.env( + LAUNCHER_CLIPBOARD_CONTROL_ENV, + launcher_clipboard_control_path(), + ); + command.env(INPUT_CONTROL_ENV, input_control_path); + command.env(INPUT_STATE_ENV, input_state_path); + command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path); + command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); + command.env("LESAVKA_CLIPBOARD_PASTE", "1"); + let audio_gain_path = audio_gain_control_path(); + let _ = write_audio_gain_request(&audio_gain_path, state.audio_gain_percent); + command.env(AUDIO_GAIN_CONTROL_ENV, audio_gain_path); + let mic_gain_path = mic_gain_control_path(); + let _ = write_mic_gain_request(&mic_gain_path, state.mic_gain_percent); + command.env(MIC_GAIN_CONTROL_ENV, mic_gain_path); + let camera_preview_path = uplink_camera_preview_path(); + let _ = std::fs::remove_file(&camera_preview_path); + command.env(UPLINK_CAMERA_PREVIEW_ENV, camera_preview_path); + let mic_level_path = uplink_mic_level_path(); + let _ = std::fs::remove_file(&mic_level_path); + command.env(UPLINK_MIC_LEVEL_ENV, mic_level_path); + for (key, value) in runtime_env_vars(state) { + command.env(key, value); + } + Ok(command.spawn()?) +} + +pub fn attach_child_log_streams(child: &mut RelayChild, tx: Sender) { + if let Some(stdout) = child.stdout.take() { + spawn_log_reader(stdout, "[relay] ", tx.clone()); + } + if let Some(stderr) = child.stderr.take() { + spawn_log_reader(stderr, "[relay:stderr] ", tx); + } +} + +fn spawn_log_reader(reader: R, prefix: &'static str, tx: Sender) +where + R: std::io::Read + Send + 'static, +{ + std::thread::spawn(move || { + for line in BufReader::new(reader) + .lines() + .map_while(std::result::Result::ok) + { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let _ = tx.send(format!("{prefix}{trimmed}")); + } + } + }); +} + +pub fn append_session_log(buffer: >k::TextBuffer, message: &str) { + let cleaned = strip_ansi_sequences(message); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + return; + } + append_clean_session_log(buffer, trimmed); +} + +/// Appends a session log line only when it passes the selected severity filter. +pub fn append_session_log_for_level( + buffer: >k::TextBuffer, + message: &str, + level: ConsoleLogLevel, +) -> bool { + let cleaned = strip_ansi_sequences(message); + let trimmed = cleaned.trim(); + if trimmed.is_empty() || !should_show_clean_session_log_line(trimmed, level) { + return false; + } + append_clean_session_log(buffer, trimmed); + true +} + +#[cfg(test)] +fn should_show_session_log_line(message: &str, level: ConsoleLogLevel) -> bool { + let cleaned = strip_ansi_sequences(message); + let trimmed = cleaned.trim(); + !trimmed.is_empty() && should_show_clean_session_log_line(trimmed, level) +} + +/// Writes a cleaned line with the GTK text tags that match its source/severity. +fn append_clean_session_log(buffer: >k::TextBuffer, trimmed: &str) { + let mut end = buffer.end_iter(); + let tags = classify_log_tags(trimmed); + if tags.is_empty() { + buffer.insert(&mut end, &format!("{trimmed}\n")); + } else { + buffer.insert_with_tags_by_name(&mut end, &format!("{trimmed}\n"), &tags); + } +} + +pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> { + let text = buffer + .text(&buffer.start_iter(), &buffer.end_iter(), false) + .to_string(); + copy_plain_text(&text) +} + +pub fn copy_plain_text(text: &str) -> Result<()> { + let display = gtk::gdk::Display::default() + .ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?; + display.clipboard().set_text(text); + Ok(()) +} + +pub fn refresh_diagnostics_report( + widgets: &LauncherWidgets, + state: &LauncherState, + child_running: bool, +) { + let mut snapshot = SnapshotReport::from_state( + state, + &widgets.diagnostics_log.borrow(), + quality_probe_command().to_string(), + ); + if child_running && !snapshot.remote_active { + snapshot.recommendations.insert( + 0, + "The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(), + ); + } + let rendered = snapshot.to_pretty_text(); + if *widgets.diagnostics_rendered_text.borrow() == rendered { + return; + } + + let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment(); + let previous_value = diagnostics_adjustment.value(); + let previous_max = + (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0); + let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); + let popout_adjustment = widgets + .diagnostics_popout_scroll + .borrow() + .as_ref() + .map(|scroll| scroll.vadjustment()); + let popout_state = popout_adjustment.as_ref().map(|adjustment| { + let previous_value = adjustment.value(); + let previous_max = (adjustment.upper() - adjustment.page_size()).max(0.0); + let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); + (adjustment.clone(), previous_value, was_at_bottom) + }); + let restore_adjustment = + |adjustment: >k::Adjustment, previous_value: f64, was_at_bottom: bool| { + let max = (adjustment.upper() - adjustment.page_size()).max(0.0); + let target = if was_at_bottom { + max + } else { + previous_value.min(max) + }; + if (adjustment.value() - target).abs() > 1.0 { + adjustment.set_value(target); + } + }; + *widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone(); + let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty(); + if update_docked { + widgets.diagnostics_label.set_text(&rendered); + restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); + } + let update_popout = popout_state + .as_ref() + .map(|(_, _, was_at_bottom)| *was_at_bottom) + .unwrap_or(false); + if let Some(label) = widgets.diagnostics_popout_label.borrow().as_ref() + && (update_popout || label.text().is_empty()) + { + label.set_text(&rendered); + } + if update_popout + && let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() + { + restore_adjustment(adjustment, *previous_value, *was_at_bottom); + } + glib::idle_add_local_once(move || { + if update_docked { + restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); + } + if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state { + restore_adjustment(&adjustment, previous_value, was_at_bottom); + } + }); +} diff --git a/client/src/launcher/ui_runtime/report_popouts.rs b/client/src/launcher/ui_runtime/report_popouts.rs new file mode 100644 index 0000000..bccfac5 --- /dev/null +++ b/client/src/launcher/ui_runtime/report_popouts.rs @@ -0,0 +1,254 @@ +pub fn open_session_log_popout( + app: >k::Application, + handle: &Rc>>, + buffer: >k::TextBuffer, +) { + open_text_buffer_popout( + app, + handle, + None, + buffer, + "Lesavka Log", + "Copy", + gtk::WrapMode::WordChar, + ); +} + +pub fn open_diagnostics_popout( + app: >k::Application, + handle: &Rc>>, + label_handle: &Rc>>, + scroll_handle: &Rc>>, + rendered_text: &Rc>, +) { + if let Some(window) = handle.borrow().as_ref() { + window.present(); + return; + } + + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Lesavka Diagnostics") + .default_width(980) + .default_height(680) + .build(); + super::ui_components::install_css(&window); + super::ui_components::install_window_icon(&window); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 10); + root.set_margin_start(14); + root.set_margin_end(14); + root.set_margin_top(14); + root.set_margin_bottom(14); + + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let copy_button = gtk::Button::with_label("Copy Report"); + toolbar.append(©_button); + root.append(&toolbar); + + let current_text = rendered_text.borrow().clone(); + let label = gtk::Label::new(Some(¤t_text)); + label.add_css_class("status-log"); + label.set_selectable(true); + label.set_xalign(0.0); + label.set_yalign(0.0); + label.set_wrap(false); + label.set_halign(gtk::Align::Start); + label.set_valign(gtk::Align::Start); + label.set_hexpand(true); + let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); + shell.set_hexpand(true); + shell.set_vexpand(false); + shell.append(&label); + let scroll = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&shell) + .build(); + *label_handle.borrow_mut() = Some(label.clone()); + *scroll_handle.borrow_mut() = Some(scroll.clone()); + root.append(&scroll); + window.set_child(Some(&root)); + window.maximize(); + + { + let rendered_text = Rc::clone(rendered_text); + copy_button.connect_clicked(move |_| { + let current_text = rendered_text.borrow().clone(); + let _ = copy_plain_text(¤t_text); + }); + } + + { + let handle = Rc::clone(handle); + let label_handle = Rc::clone(label_handle); + let scroll_handle = Rc::clone(scroll_handle); + window.connect_close_request(move |_| { + handle.borrow_mut().take(); + label_handle.borrow_mut().take(); + scroll_handle.borrow_mut().take(); + glib::Propagation::Proceed + }); + } + + *handle.borrow_mut() = Some(window.clone()); + window.present(); +} + +fn open_text_buffer_popout( + app: >k::Application, + handle: &Rc>>, + scroll_handle: Option<&Rc>>>, + buffer: >k::TextBuffer, + title: &str, + copy_button_label: &str, + wrap_mode: gtk::WrapMode, +) { + if let Some(window) = handle.borrow().as_ref() { + window.present(); + return; + } + + let window = gtk::ApplicationWindow::builder() + .application(app) + .title(title) + .default_width(980) + .default_height(680) + .build(); + super::ui_components::install_css(&window); + super::ui_components::install_window_icon(&window); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 10); + root.set_margin_start(14); + root.set_margin_end(14); + root.set_margin_top(14); + root.set_margin_bottom(14); + + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let copy_button = gtk::Button::with_label(copy_button_label); + toolbar.append(©_button); + root.append(&toolbar); + + let view = gtk::TextView::with_buffer(buffer); + view.add_css_class("status-log"); + view.set_editable(false); + view.set_cursor_visible(false); + view.set_monospace(true); + view.set_wrap_mode(wrap_mode); + let scroll = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&view) + .build(); + if let Some(scroll_handle) = scroll_handle { + *scroll_handle.borrow_mut() = Some(scroll.clone()); + } + root.append(&scroll); + window.set_child(Some(&root)); + window.maximize(); + + { + let buffer = buffer.clone(); + copy_button.connect_clicked(move |_| { + let _ = copy_session_log(&buffer); + }); + } + + { + let handle = Rc::clone(handle); + let scroll_handle = scroll_handle.cloned(); + window.connect_close_request(move |_| { + handle.borrow_mut().take(); + if let Some(scroll_handle) = &scroll_handle { + scroll_handle.borrow_mut().take(); + } + glib::Propagation::Proceed + }); + } + + *handle.borrow_mut() = Some(window.clone()); + window.present(); +} + +pub fn stop_child_process(child_proc: &Rc>>) { + if let Some(mut child) = child_proc.borrow_mut().take() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +pub fn shutdown_launcher_runtime( + child_proc: &Rc>>, + tests: &Rc>, + preview: Option<&LauncherPreview>, + widgets: &LauncherWidgets, + popouts: &Rc; 2]>>, + diagnostics_popout: &Rc>>, + log_popout: &Rc>>, +) { + stop_child_process(child_proc); + tests.borrow_mut().stop_all(); + + if let Some(preview) = preview { + preview.set_session_active(false); + preview.shutdown_all(); + } + + for pane in &widgets.display_panes { + if let Some(binding) = pane.preview_binding.borrow_mut().take() { + binding.close(); + } + pane.picture.set_paintable(Option::<&gdk::Paintable>::None); + pane.stream_status.set_text(""); + } + + let mut detached_popouts = Vec::new(); + { + let mut slots = popouts.borrow_mut(); + for slot in slots.iter_mut() { + if let Some(handle) = slot.take() { + detached_popouts.push(handle); + } + } + } + for handle in detached_popouts { + handle.binding.close(); + handle + .picture + .set_paintable(Option::<&gdk::Paintable>::None); + handle.window.set_child(Option::<>k::Widget>::None); + handle.window.hide(); + } + + if let Some(window) = diagnostics_popout.borrow_mut().take() { + widgets.diagnostics_popout_label.borrow_mut().take(); + widgets.diagnostics_popout_scroll.borrow_mut().take(); + window.set_child(Option::<>k::Widget>::None); + window.hide(); + } + if let Some(window) = log_popout.borrow_mut().take() { + window.set_child(Option::<>k::Widget>::None); + window.hide(); + } +} + +pub fn reap_exited_child(child_proc: &Rc>>) -> bool { + let mut slot = child_proc.borrow_mut(); + match slot.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + *slot = None; + false + } + Ok(None) | Err(_) => true, + }, + None => false, + } +} + +pub fn next_input_routing(routing: InputRouting) -> InputRouting { + match routing { + InputRouting::Remote => InputRouting::Local, + InputRouting::Local => InputRouting::Remote, + } +} diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs new file mode 100644 index 0000000..3dd4179 --- /dev/null +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -0,0 +1,253 @@ +pub fn gpio_power_label(power: &CapturePowerStatus) -> String { + if !power.available { + return "Unavailable".to_string(); + } + if !power.enabled { + return "Power Off".to_string(); + } + match power.detected_devices { + 0 => "No Eyes".to_string(), + 1 => "1 Eye".to_string(), + count => format!("{count} Eyes"), + } +} + +pub fn capture_power_detail(power: &CapturePowerStatus) -> String { + if !power.available { + return format!("{} is unavailable: {}", power.unit, power.detail); + } + let detected = if power.enabled { + format!(" • {}", gpio_detection_detail(power.detected_devices)) + } else { + String::new() + }; + match power.mode.as_str() { + "forced-on" => format!( + "{} • awake • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases + ), + "forced-off" => format!( + "{} • dark • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases + ), + _ => format!( + "{} • auto • {}{} • leases {}", + power.unit, power.detail, detected, power.active_leases + ), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatusLightState { + Idle, + Live, + Warning, + Caution, +} + +impl StatusLightState { + fn from_active(active: bool) -> Self { + if active { Self::Live } else { Self::Idle } + } + + fn css_class(self) -> &'static str { + match self { + Self::Idle => "status-light-idle", + Self::Live => "status-light-live", + Self::Warning => "status-light-warning", + Self::Caution => "status-light-caution", + } + } +} + +fn server_light_state(state: &LauncherState, relay_live: bool) -> StatusLightState { + if relay_live { + StatusLightState::Live + } else if state.server_available { + StatusLightState::Caution + } else { + StatusLightState::Idle + } +} + +fn server_version_label(state: &LauncherState) -> String { + if !state.server_available { + return "-".to_string(); + } + let version = state + .server_version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()); + match version { + Some(version) if version.starts_with('v') => version.to_string(), + Some(version) => format!("v{version}"), + None => "-".to_string(), + } +} + +fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState { + if !power.available || !power.enabled { + return StatusLightState::Idle; + } + match power.detected_devices { + 0 => StatusLightState::Warning, + 1 => StatusLightState::Caution, + _ => StatusLightState::Live, + } +} + +fn gpio_detection_detail(detected_devices: u32) -> String { + match detected_devices { + 0 => "no eyes detected".to_string(), + 1 => "1 eye detected".to_string(), + count => format!("{count} eyes detected"), + } +} + +/// Highlights the currently active capture mode so it reads like a segmented control. +fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) { + for button in [ + &widgets.power_auto_button, + &widgets.power_on_button, + &widgets.power_off_button, + ] { + button.remove_css_class("pill-toggle-active"); + } + match mode { + "forced-on" => widgets.power_on_button.add_css_class("pill-toggle-active"), + "forced-off" => widgets.power_off_button.add_css_class("pill-toggle-active"), + _ => widgets + .power_auto_button + .add_css_class("pill-toggle-active"), + } +} + +/// Reports which local staging checks are active right now. +fn local_test_detail( + camera_running: bool, + microphone_running: bool, + speaker_running: bool, + microphone_replay_running: bool, +) -> String { + let mut active = Vec::new(); + if camera_running { + active.push("camera preview"); + } + if microphone_running { + active.push("mic monitor"); + } + if speaker_running { + active.push("speaker tone"); + } + if microphone_replay_running { + active.push("mic replay"); + } + + if active.is_empty() { + "Local checks are idle. Use Start Preview, Monitor Mic, Replay, or Play Tone before you launch." + .to_string() + } else { + format!( + "Local checks running: {}. Stop them whenever staging is complete.", + active.join(", ") + ) + } +} + +fn install_popout_drag(window: >k::ApplicationWindow, widget: &impl IsA) { + let drag = gtk::GestureClick::new(); + drag.set_button(0); + let native = window.clone(); + drag.connect_pressed(move |gesture, _press, x, y| { + let Some(device) = gesture.current_event_device() else { + return; + }; + let Some(surface) = native.surface() else { + return; + }; + let Some(toplevel) = surface.dynamic_cast_ref::() else { + return; + }; + let timestamp = gesture + .current_event() + .map(|event| event.time()) + .unwrap_or(0); + toplevel.begin_move(&device, 1, x, y, timestamp); + }); + widget.add_controller(drag); +} + +fn apply_popout_window_geometry( + window: >k::ApplicationWindow, + root: >k::Box, + picture: >k::Picture, + size: BreakoutSizeChoice, + display_limit: super::state::PreviewSourceSize, +) { + picture.set_size_request(size.width, size.height); + root.set_size_request(size.width, size.height); + window.set_default_size(size.width, size.height); + if should_cover_display(size, display_limit) { + fullscreen_on_largest_monitor(window); + } else { + window.unfullscreen(); + } +} + +fn schedule_popout_window_geometry( + window: gtk::ApplicationWindow, + root: gtk::Box, + picture: gtk::Picture, + size: BreakoutSizeChoice, + display_limit: super::state::PreviewSourceSize, +) { + for delay_ms in [0_u64, 25, 150] { + let window = window.clone(); + let root = root.clone(); + let picture = picture.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || { + apply_popout_window_geometry(&window, &root, &picture, size, display_limit); + window.present(); + }); + } +} + +fn fullscreen_on_largest_monitor(window: >k::ApplicationWindow) { + let Some(display) = gdk::Display::default() else { + window.fullscreen(); + return; + }; + let monitors = display.monitors(); + let monitor = (0..monitors.n_items()) + .filter_map(|idx| monitors.item(idx)) + .filter_map(|obj| obj.downcast::().ok()) + .max_by_key(|monitor| { + let geometry = monitor.geometry(); + let scale = monitor.scale_factor().max(1); + geometry.width().max(1) as i64 + * scale as i64 + * geometry.height().max(1) as i64 + * scale as i64 + }); + if let Some(monitor) = monitor.as_ref() { + window.fullscreen_on_monitor(monitor); + } else { + window.fullscreen(); + } +} + +fn should_cover_display( + size: BreakoutSizeChoice, + display_limit: super::state::PreviewSourceSize, +) -> bool { + matches!(size.preset, super::state::BreakoutSizePreset::FillDisplay) + || (size.width >= display_limit.width.max(1) as i32 + && size.height >= display_limit.height.max(1) as i32) +} + +pub fn present_popout_windows(popouts: &Rc; 2]>>) { + for handle in popouts.borrow().iter().flatten() { + handle.window.present(); + } +} diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs new file mode 100644 index 0000000..d3e3a01 --- /dev/null +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -0,0 +1,261 @@ +use anyhow::Result; +use gtk::{gdk, glib, prelude::*}; +use std::{ + cell::RefCell, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + rc::Rc, + sync::mpsc::Sender, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::{ + LAUNCHER_CLIPBOARD_CONTROL_ENV, LAUNCHER_FOCUS_SIGNAL_ENV, + device_test::{DeviceTestController, DeviceTestKind}, + diagnostics::{SnapshotReport, quality_probe_command}, + launcher_clipboard_control_path, launcher_focus_signal_path, + preview::{LauncherPreview, PreviewSurface}, + runtime_env_vars, + state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, + ui_components::{ConsoleLogLevel, DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, +}; + +pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; +pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; +pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"; +pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; +pub const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; +pub const UPLINK_CAMERA_PREVIEW_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW"; +pub const UPLINK_MIC_LEVEL_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL"; +pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; +pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; +pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control"; +pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control"; +pub const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control"; +pub const DEFAULT_UPLINK_CAMERA_PREVIEW_PATH: &str = "/tmp/lesavka-uplink-camera-preview.rgba"; +pub const DEFAULT_UPLINK_MIC_LEVEL_PATH: &str = "/tmp/lesavka-uplink-mic-level.value"; + +pub type RelayChild = Child; + +pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { + let relay_live = child_running || state.remote_active; + set_status_light( + &widgets.summary.relay_light, + server_light_state(state, relay_live), + ); + widgets + .summary + .relay_value + .set_text(&server_version_label(state)); + set_status_light( + &widgets.summary.routing_light, + StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)), + ); + widgets + .summary + .routing_value + .set_text(&capitalize(routing_name(state.routing))); + set_status_light( + &widgets.summary.gpio_light, + gpio_light_state(&state.capture_power), + ); + widgets + .summary + .gpio_value + .set_text(&gpio_power_label(&state.capture_power)); + widgets + .summary + .shortcut_value + .set_text(&toggle_key_label(&state.swap_key)); + + widgets + .power_detail + .set_text(&capture_power_detail(&state.capture_power)); + if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON { + widgets + .audio_gain_scale + .set_value(state.audio_gain_percent as f64); + } + widgets.audio_gain_value.set_text(&state.audio_gain_label()); + if (widgets.mic_gain_scale.value() - state.mic_gain_percent as f64).abs() > f64::EPSILON { + widgets + .mic_gain_scale + .set_value(state.mic_gain_percent as f64); + } + widgets.mic_gain_value.set_text(&state.mic_gain_label()); + if widgets.camera_channel_toggle.is_active() != state.channels.camera { + widgets + .camera_channel_toggle + .set_active(state.channels.camera); + } + if widgets.microphone_channel_toggle.is_active() != state.channels.microphone { + widgets + .microphone_channel_toggle + .set_active(state.channels.microphone); + } + if widgets.audio_channel_toggle.is_active() != state.channels.audio { + widgets + .audio_channel_toggle + .set_active(state.channels.audio); + } + widgets + .start_button + .set_label(if relay_live { "Disconnect" } else { "Connect" }); + widgets.start_button.set_sensitive(true); + widgets.server_entry.set_sensitive(!relay_live); + widgets.start_button.set_tooltip_text(Some(if relay_live { + "Disconnect the relay session." + } else { + "Start relay and previews." + })); + widgets.clipboard_button.set_sensitive(relay_live); + widgets.probe_button.set_sensitive(true); + widgets + .usb_recover_button + .set_sensitive(state.server_available); + widgets.device_refresh_button.set_sensitive(!relay_live); + widgets + .camera_combo + .set_sensitive(!relay_live && state.channels.camera); + widgets.camera_quality_combo.set_sensitive( + !relay_live + && state.channels.camera + && state.devices.camera.is_some() + && state.camera_quality.is_some(), + ); + widgets + .microphone_combo + .set_sensitive(!relay_live && state.channels.microphone); + widgets + .speaker_combo + .set_sensitive(!relay_live && state.channels.audio); + widgets + .audio_gain_scale + .set_sensitive(!relay_live && state.channels.audio); + widgets.keyboard_combo.set_sensitive(!relay_live); + widgets.mouse_combo.set_sensitive(!relay_live); + widgets.camera_channel_toggle.set_sensitive(!relay_live); + widgets.microphone_channel_toggle.set_sensitive(!relay_live); + widgets.audio_channel_toggle.set_sensitive(!relay_live); + widgets + .camera_test_button + .set_sensitive(!relay_live && state.channels.camera); + widgets + .microphone_test_button + .set_sensitive(!relay_live && state.channels.microphone); + widgets + .mic_gain_scale + .set_sensitive(!relay_live && state.channels.microphone); + widgets + .speaker_test_button + .set_sensitive(!relay_live && state.channels.audio); + widgets.input_toggle_button.set_label(match state.routing { + InputRouting::Remote => "Route Local", + InputRouting::Local => "Route Remote", + }); + widgets + .input_toggle_button + .set_tooltip_text(Some(match state.routing { + InputRouting::Remote => "Route inputs back local.", + InputRouting::Local => "Route inputs to remote.", + })); + widgets.swap_key_button.set_label("Set Swap Key"); + widgets + .swap_key_button + .set_tooltip_text(Some(if state.swap_key_binding { + "Press the new swap key." + } else { + "Set the swap shortcut." + })); + let power_available = state.capture_power.available; + widgets + .power_auto_button + .set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto")); + widgets.power_on_button.set_sensitive( + power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"), + ); + widgets.power_off_button.set_sensitive( + power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"), + ); + sync_power_mode_button_styles(widgets, state.capture_power.mode.as_str()); + + for monitor_id in 0..2 { + refresh_display_pane( + &widgets.display_panes[monitor_id], + state.display_surface(monitor_id), + ); + } + refresh_diagnostics_report(widgets, state, child_running); +} + +pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) { + let camera_running = tests.is_running(DeviceTestKind::Camera); + let microphone_running = tests.is_running(DeviceTestKind::Microphone); + let microphone_replay_running = tests.is_running(DeviceTestKind::MicrophoneReplay); + let speaker_running = tests.is_running(DeviceTestKind::Speaker); + + widgets.camera_test_button.set_label(if camera_running { + "Stop Preview" + } else { + "Start Preview" + }); + widgets + .microphone_test_button + .set_label(if microphone_running { + "Stop Monitor" + } else { + "Monitor Mic" + }); + widgets + .microphone_replay_button + .set_label(if microphone_replay_running { + "Stop" + } else { + "Replay" + }); + widgets + .microphone_replay_button + .set_sensitive(microphone_replay_running || tests.microphone_replay_ready()); + widgets.speaker_test_button.set_label(if speaker_running { + "Stop Tone" + } else { + "Play Tone" + }); + widgets.audio_check_detail.set_text(&local_test_detail( + camera_running, + microphone_running, + speaker_running, + microphone_replay_running, + )); + if microphone_running { + let level = tests.microphone_level_fraction().clamp(0.0, 1.0); + widgets.audio_check_meter.set_fraction(level); + widgets + .audio_check_meter + .set_text(Some(&format!("Mic {:>3}%", (level * 100.0).round() as u32))); + } else if speaker_running || microphone_replay_running { + widgets.audio_check_meter.set_text(Some("Playback")); + widgets.audio_check_meter.pulse(); + } else { + widgets.audio_check_meter.set_fraction(0.0); + widgets.audio_check_meter.set_text(Some("Idle")); + } +} + +pub fn update_test_action_result( + widgets: &LauncherWidgets, + tests: &mut DeviceTestController, + result: Result, + start_msg: &str, + stop_msg: &str, +) { + match result { + Ok(true) => widgets.status_label.set_text(start_msg), + Ok(false) => widgets.status_label.set_text(stop_msg), + Err(err) => widgets + .status_label + .set_text(&format!("Device test failed: {err}")), + } + refresh_test_buttons(widgets, tests); +} diff --git a/client/src/main.rs b/client/src/main.rs index fcd8721..b194ce2 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -52,6 +52,7 @@ async fn main() -> Result<()> { let file = OpenOptions::new() .create(true) .write(true) + .truncate(true) .open(&log_path)?; let (file_writer, guard) = non_blocking(file); _guard = Some(guard); diff --git a/client/src/output/video.rs b/client/src/output/video.rs index 600d883..22348a9 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -1,585 +1,3 @@ -// client/src/output/video.rs -use crate::output::{display, layout}; -use anyhow::Context; -use gstreamer as gst; -use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt}; -use gstreamer_app as gst_app; -use gstreamer_video::VideoOverlay; -use gstreamer_video::prelude::VideoOverlayExt; -use lesavka_common::lesavka::VideoPacket; -use std::process::Command; -use tracing::{debug, error, info, warn}; - -fn pick_h264_decoder() -> String { - if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") { - let name = raw.trim(); - if name.eq_ignore_ascii_case("decodebin") { - return "decodebin".to_string(); - } - if !name.is_empty() && buildable_decoder(name) { - return name.to_string(); - } - } - - for name in [ - "avdec_h264", - "openh264dec", - "nvh264dec", - "nvh264sldec", - "vah264dec", - "vaapih264dec", - "v4l2h264dec", - "v4l2slh264dec", - ] { - if buildable_decoder(name) { - return name.to_string(); - } - } - - "decodebin".to_string() -} - -fn buildable_decoder(name: &str) -> bool { - gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok() -} - -pub struct MonitorWindow { - _pipeline: gst::Pipeline, - src: gst_app::AppSrc, -} -pub struct UnifiedMonitorWindow { - pipeline: gst::Pipeline, - left_src: gst_app::AppSrc, - right_src: gst_app::AppSrc, -} - -#[cfg(not(coverage))] -fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) { - let x = rect.x; - let y = rect.y; - let w = rect.w; - let h = rect.h; - std::thread::spawn(move || { - for attempt in 1..=12 { - std::thread::sleep(std::time::Duration::from_millis(300)); - let Some(window_id) = nth_lesavka_window_id(id as usize) else { - tracing::debug!("⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})"); - continue; - }; - let _ = Command::new("wmctrl") - .args([ - "-i", - "-r", - &window_id, - "-b", - "remove,maximized_vert,maximized_horz", - ]) - .status(); - let status = Command::new("wmctrl") - .args(["-i", "-r", &window_id, "-e", &format!("0,{x},{y},{w},{h}")]) - .status(); - match status { - Ok(st) if st.success() => { - tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})"); - break; - } - _ => tracing::debug!( - "⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})" - ), - } - } - }); -} - -#[cfg(not(coverage))] -fn nth_lesavka_window_id(index: usize) -> Option { - let out = Command::new("wmctrl").args(["-lp"]).output().ok()?; - if !out.status.success() { - return None; - } - let mut windows = String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| { - let mut parts = line.split_whitespace(); - let id = parts.next()?.to_string(); - let _desktop = parts.next()?; - let _pid = parts.next()?; - let _host = parts.next()?; - let title = parts.collect::>().join(" "); - if title == "lesavka-client" || title.starts_with("Lesavka-eye-") { - Some(id) - } else { - None - } - }) - .collect::>(); - windows.sort(); - windows.dedup(); - if windows.len() < 2 { - return None; - } - windows.get(index).cloned() -} -#[allow(clippy::all)] -impl MonitorWindow { - #[cfg(coverage)] - pub fn new(_id: u32) -> anyhow::Result { - gst::init().context("initialising GStreamer")?; - let pipeline = gst::Pipeline::new(); - let src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") - .build() - .context("make appsrc")? - .downcast::() - .expect("appsrc"); - src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - src.set_format(gst::Format::Time); - - let sink = gst::ElementFactory::make("fakesink") - .build() - .context("make fakesink")?; - pipeline.add(src.upcast_ref::())?; - pipeline.add(&sink)?; - gst::Element::link_many(&[src.upcast_ref(), &sink])?; - pipeline.set_state(gst::State::Playing)?; - - Ok(Self { - _pipeline: pipeline, - src, - }) - } - - #[cfg(not(coverage))] - pub fn new(id: u32) -> anyhow::Result { - gst::init().context("initialising GStreamer")?; - - // --- Build pipeline --------------------------------------------------- - let decoder_name = pick_h264_decoder(); - let sink = if std::env::var("GDK_BACKEND") - .map(|v| v.contains("x11")) - .unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some()) - { - "ximagesink name=sink sync=false" - } else { - "glimagesink name=sink sync=false" - }; - - let desc = format!( - "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ - h264parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! {sink}" - ); - - let pipeline: gst::Pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("not a pipeline"); - - /* -------- placement maths -------------------------------------- */ - let monitors = display::enumerate_monitors(); - let stream_defs = &[("eye-0", 1920, 1080), ("eye-1", 1920, 1080)]; - let rects = layout::assign_rectangles(&monitors, stream_defs); - - // --- AppSrc------------------------------------------------------------ - let src: gst_app::AppSrc = pipeline - .by_name("src") - .unwrap() - .downcast::() - .unwrap(); - - src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - src.set_format(gst::Format::Time); - - /* -------- move/resize overlay ---------------------------------- */ - if let Some(sink_elem) = pipeline.by_name("sink") { - if sink_elem.find_property("window-title").is_some() { - let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}")); - } - if let Some(r) = rects.get(id as usize) { - if let Ok(overlay) = sink_elem.dynamic_cast::() { - // 1. Tell glimagesink how to crop the texture in its own window - let _ = overlay.set_render_rectangle(0, 0, r.w, r.h); - debug!( - "🔲 eye-{id} → render_rectangle({}, {}, {}, {})", - 0, 0, r.w, r.h - ); - } - - // 2. **Compositor-level** placement (Wayland only) - if std::env::var_os("WAYLAND_DISPLAY").is_some() { - use std::process::{Command, ExitStatus}; - use std::sync::Arc; - use std::thread; - use std::time::Duration; - - // A small helper struct so the two branches return the same type - struct Placer { - name: &'static str, - run: Arc std::io::Result + Send + Sync>, - } - - let placer = if Command::new("swaymsg") - .arg("-t") - .arg("get_tree") - .output() - .map(|out| out.status.success()) - .unwrap_or(false) - { - Some(Placer { - name: "swaymsg", - run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()), - }) - } else if Command::new("hyprctl") - .arg("version") - .output() - .map(|out| out.status.success()) - .unwrap_or(false) - { - Some(Placer { - name: "hyprctl", - run: Arc::new(|cmd| { - Command::new("hyprctl") - .args(["dispatch", "exec", cmd]) - .status() - }), - }) - } else if std::env::var_os("DISPLAY").is_some() - && Command::new("wmctrl").arg("-m").output().is_ok() - { - spawn_wmctrl_placement(id, *r); - None - } else { - None - }; - - if let Some(placer) = placer { - let cmd = match placer.name { - // Criteria string that works for i3-/sway-compatible IPC - "swaymsg" | "hyprctl" => format!( - r#"[title="^Lesavka-eye-{id}$"] \ - resize set {w} {h}; \ - move absolute position {x} {y}"#, - w = r.w, - h = r.h, - x = r.x, - y = r.y, - ), - _ => String::new(), - }; - - // Retry in a detached thread - avoids blocking GStreamer - let placename = placer.name; - let runner = placer.run.clone(); - thread::spawn(move || { - for attempt in 1..=10 { - thread::sleep(Duration::from_millis(300)); - match runner(&cmd) { - Ok(st) if st.success() => { - tracing::info!( - "✅ {placename}: placed eye-{id} (attempt {attempt})" - ); - break; - } - _ => tracing::debug!( - "⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})" - ), - } - } - }); - } - } - // 3. X11 / Xwayland placement via wmctrl - else if std::env::var_os("DISPLAY").is_some() { - spawn_wmctrl_placement(id, *r); - } - } - } - - { - let id = id; // move into thread - let bus = pipeline.bus().expect("no bus"); - std::thread::spawn(move || { - use gst::MessageView::*; - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - StateChanged(s) if s.current() == gst::State::Playing => { - if msg.src().map(|s| s.is::()).unwrap_or(false) { - info!("🎞️ video{id} pipeline ▶️ (sink='glimagesink')"); - info!("🎞️ video{id} decoder → {decoder_name}"); - } - } - Error(e) => error!( - "💥 gst video{id}: {} ({})", - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => warn!( - "⚠️ gst video{id}: {} ({})", - w.error(), - w.debug().unwrap_or_default() - ), - _ => {} - } - } - }); - } - - pipeline.set_state(gst::State::Playing)?; - - Ok(Self { - _pipeline: pipeline, - src, - }) - } - - /// Feed one access-unit to the decoder. - pub fn push_packet(&self, pkt: VideoPacket) { - static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n % 150 == 0 || n < 10 { - debug!( - eye = pkt.id, - bytes = pkt.data.len(), - pts = pkt.pts, - "⬇️ received video AU" - ); - } - let mut buf = gst::Buffer::from_slice(pkt.data); - buf.get_mut() - .unwrap() - .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); - let _ = self.src.push_buffer(buf); // ignore Eos/flushing - } -} - -#[allow(clippy::all)] -impl Drop for MonitorWindow { - fn drop(&mut self) { - let _ = self._pipeline.set_state(gst::State::Null); - } -} - -#[allow(clippy::all)] -impl UnifiedMonitorWindow { - #[cfg(coverage)] - /// Build the unified renderer in coverage mode with deterministic fakesinks. - pub fn new() -> anyhow::Result { - gst::init().context("initialising GStreamer")?; - let pipeline = gst::Pipeline::new(); - let left_src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") - .build() - .context("make left appsrc")? - .downcast::() - .expect("left appsrc"); - let right_src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") - .build() - .context("make right appsrc")? - .downcast::() - .expect("right appsrc"); - - left_src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - right_src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - left_src.set_format(gst::Format::Time); - right_src.set_format(gst::Format::Time); - let left_sink = gst::ElementFactory::make("fakesink") - .build() - .context("make left fakesink")?; - let right_sink = gst::ElementFactory::make("fakesink") - .build() - .context("make right fakesink")?; - - pipeline.add(left_src.upcast_ref::())?; - pipeline.add(right_src.upcast_ref::())?; - pipeline.add(&left_sink)?; - pipeline.add(&right_sink)?; - gst::Element::link_many(&[left_src.upcast_ref(), &left_sink])?; - gst::Element::link_many(&[right_src.upcast_ref(), &right_sink])?; - pipeline.set_state(gst::State::Playing)?; - Ok(Self { - pipeline, - left_src, - right_src, - }) - } - - #[cfg(not(coverage))] - /// Build the unified renderer that composites both eyes in a single window. - pub fn new() -> anyhow::Result { - gst::init().context("initialising GStreamer")?; - - let decoder_name = pick_h264_decoder(); - let sink = if std::env::var("GDK_BACKEND") - .map(|v| v.contains("x11")) - .unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some()) - { - "ximagesink name=sink sync=false" - } else { - "glimagesink name=sink sync=false" - }; - - let desc = format!( - "compositor name=mix background=black ! videoconvert ! {sink} \ - appsrc name=src0 is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ - h264parse disable-passthrough=true ! {decoder_name} name=decoder0 ! videoconvert ! videoscale ! mix. \ - appsrc name=src1 is-live=true format=time do-timestamp=true block=false ! \ - queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ - h264parse disable-passthrough=true ! {decoder_name} name=decoder1 ! videoconvert ! videoscale ! mix." - ); - - let pipeline: gst::Pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("not a pipeline"); - - let monitors = display::enumerate_monitors(); - let root_rect = layout::assign_rectangles(&monitors, &[("unified", 1920, 1080)]) - .first() - .copied() - .unwrap_or(layout::Rect { - x: 0, - y: 0, - w: 1920, - h: 1080, - }); - let pane_w = (root_rect.w / 2).max(320); - let pane_h = root_rect.h.max(240); - - if let Some(mix) = pipeline.by_name("mix") { - if let Some(left_pad) = mix.static_pad("sink_0") { - left_pad.set_property("xpos", 0_i32); - left_pad.set_property("ypos", 0_i32); - left_pad.set_property("width", pane_w); - left_pad.set_property("height", pane_h); - } - if let Some(right_pad) = mix.static_pad("sink_1") { - right_pad.set_property("xpos", pane_w); - right_pad.set_property("ypos", 0_i32); - right_pad.set_property("width", pane_w); - right_pad.set_property("height", pane_h); - } - } - - if let Some(sink_elem) = pipeline.by_name("sink") { - if sink_elem.find_property("window-title").is_some() { - let _ = sink_elem.set_property("window-title", &"Lesavka-unified"); - } - if let Ok(overlay) = sink_elem.dynamic_cast::() { - let _ = overlay.set_render_rectangle(0, 0, pane_w * 2, pane_h); - } - } - - let left_src: gst_app::AppSrc = pipeline - .by_name("src0") - .context("missing src0")? - .downcast::() - .expect("src0 appsrc"); - let right_src: gst_app::AppSrc = pipeline - .by_name("src1") - .context("missing src1")? - .downcast::() - .expect("src1 appsrc"); - - left_src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - right_src.set_caps(Some( - &gst::Caps::builder("video/x-h264") - .field("stream-format", &"byte-stream") - .field("alignment", &"au") - .build(), - )); - left_src.set_format(gst::Format::Time); - right_src.set_format(gst::Format::Time); - - { - let bus = pipeline.bus().expect("no bus"); - std::thread::spawn(move || { - use gst::MessageView::*; - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - StateChanged(s) if s.current() == gst::State::Playing => { - if msg.src().map(|s| s.is::()).unwrap_or(false) { - info!("🎞️ unified video pipeline ▶️"); - info!("🎞️ unified decoder → {decoder_name}"); - } - } - Error(e) => error!( - "💥 gst unified-video: {} ({})", - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => warn!( - "⚠️ gst unified-video: {} ({})", - w.error(), - w.debug().unwrap_or_default() - ), - _ => {} - } - } - }); - } - - pipeline.set_state(gst::State::Playing)?; - - Ok(Self { - pipeline, - left_src, - right_src, - }) - } - - /// Feed one access-unit into the unified decoder wall. - pub fn push_packet(&self, pkt: VideoPacket) { - static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n % 150 == 0 || n < 10 { - debug!( - eye = pkt.id, - bytes = pkt.data.len(), - pts = pkt.pts, - "⬇️ received unified video AU" - ); - } - let src = if pkt.id == 0 { - &self.left_src - } else { - &self.right_src - }; - let mut buf = gst::Buffer::from_slice(pkt.data); - buf.get_mut() - .unwrap() - .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); - let _ = src.push_buffer(buf); - } -} - -#[allow(clippy::all)] -impl Drop for UnifiedMonitorWindow { - fn drop(&mut self) { - let _ = self.pipeline.set_state(gst::State::Null); - } -} +// Client-side eye video output windows and unified monitor rendering. +include!("video/monitor_window.rs"); +include!("video/unified_monitor.rs"); diff --git a/client/src/output/video/monitor_window.rs b/client/src/output/video/monitor_window.rs new file mode 100644 index 0000000..f36a24c --- /dev/null +++ b/client/src/output/video/monitor_window.rs @@ -0,0 +1,378 @@ +// client/src/output/video.rs +use crate::output::{display, layout}; +use anyhow::Context; +use gstreamer as gst; +use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt}; +use gstreamer_app as gst_app; +use gstreamer_video::VideoOverlay; +use gstreamer_video::prelude::VideoOverlayExt; +use lesavka_common::lesavka::VideoPacket; +use std::process::Command; +use tracing::{debug, error, info, warn}; + +/// Pick the first H.264 decoder that can be built on this client. +fn pick_h264_decoder() -> String { + if let Ok(raw) = std::env::var("LESAVKA_H264_DECODER") { + let name = raw.trim(); + if name.eq_ignore_ascii_case("decodebin") { + return "decodebin".to_string(); + } + if !name.is_empty() && buildable_decoder(name) { + return name.to_string(); + } + } + + for name in [ + "avdec_h264", + "openh264dec", + "nvh264dec", + "nvh264sldec", + "vah264dec", + "vaapih264dec", + "v4l2h264dec", + "v4l2slh264dec", + ] { + if buildable_decoder(name) { + return name.to_string(); + } + } + + "decodebin".to_string() +} + +#[cfg(coverage)] +/// Probe decoder availability, with a coverage hook for fallback behavior. +fn buildable_decoder(name: &str) -> bool { + if std::env::var("LESAVKA_TEST_DISABLE_H264_DECODERS").is_ok() { + return false; + } + gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok() +} + +#[cfg(not(coverage))] +/// Probe decoder availability through the local GStreamer registry. +fn buildable_decoder(name: &str) -> bool { + gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok() +} + +pub struct MonitorWindow { + _pipeline: gst::Pipeline, + src: gst_app::AppSrc, +} +pub struct UnifiedMonitorWindow { + pipeline: gst::Pipeline, + left_src: gst_app::AppSrc, + right_src: gst_app::AppSrc, +} + +#[cfg(not(coverage))] +/// Place an eye window with wmctrl once the compositor maps it. +fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) { + let x = rect.x; + let y = rect.y; + let w = rect.w; + let h = rect.h; + std::thread::spawn(move || { + for attempt in 1..=12 { + std::thread::sleep(std::time::Duration::from_millis(300)); + let Some(window_id) = nth_lesavka_window_id(id as usize) else { + tracing::debug!("⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})"); + continue; + }; + let _ = Command::new("wmctrl") + .args([ + "-i", + "-r", + &window_id, + "-b", + "remove,maximized_vert,maximized_horz", + ]) + .status(); + let status = Command::new("wmctrl") + .args(["-i", "-r", &window_id, "-e", &format!("0,{x},{y},{w},{h}")]) + .status(); + match status { + Ok(st) if st.success() => { + tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})"); + break; + } + _ => tracing::debug!( + "⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})" + ), + } + } + }); +} + +#[cfg(not(coverage))] +/// Return the nth Lesavka video window ID from wmctrl output. +fn nth_lesavka_window_id(index: usize) -> Option { + let out = Command::new("wmctrl").args(["-lp"]).output().ok()?; + if !out.status.success() { + return None; + } + let mut windows = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let id = parts.next()?.to_string(); + let _desktop = parts.next()?; + let _pid = parts.next()?; + let _host = parts.next()?; + let title = parts.collect::>().join(" "); + if title == "lesavka-client" || title.starts_with("Lesavka-eye-") { + Some(id) + } else { + None + } + }) + .collect::>(); + windows.sort(); + windows.dedup(); + if windows.len() < 2 { + return None; + } + windows.get(index).cloned() +} +#[allow(clippy::all)] +impl MonitorWindow { + #[cfg(coverage)] + /// Build a deterministic fakesink monitor for coverage tests. + pub fn new(_id: u32) -> anyhow::Result { + gst::init().context("initialising GStreamer")?; + let pipeline = gst::Pipeline::new(); + let src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") + .build() + .context("make appsrc")? + .downcast::() + .expect("appsrc"); + src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + src.set_format(gst::Format::Time); + + let sink = gst::ElementFactory::make("fakesink") + .build() + .context("make fakesink")?; + pipeline.add(src.upcast_ref::())?; + pipeline.add(&sink)?; + gst::Element::link_many(&[src.upcast_ref(), &sink])?; + pipeline.set_state(gst::State::Playing)?; + + Ok(Self { + _pipeline: pipeline, + src, + }) + } + + #[cfg(not(coverage))] + /// Build a live monitor window for one remote eye stream. + pub fn new(id: u32) -> anyhow::Result { + gst::init().context("initialising GStreamer")?; + + // --- Build pipeline --------------------------------------------------- + let decoder_name = pick_h264_decoder(); + let sink = if std::env::var("GDK_BACKEND") + .map(|v| v.contains("x11")) + .unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some()) + { + "ximagesink name=sink sync=false" + } else { + "glimagesink name=sink sync=false" + }; + + let desc = format!( + "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ + queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ + h264parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! {sink}" + ); + + let pipeline: gst::Pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("not a pipeline"); + + /* -------- placement maths -------------------------------------- */ + let monitors = display::enumerate_monitors(); + let stream_defs = &[("eye-0", 1920, 1080), ("eye-1", 1920, 1080)]; + let rects = layout::assign_rectangles(&monitors, stream_defs); + + // --- AppSrc------------------------------------------------------------ + let src: gst_app::AppSrc = pipeline + .by_name("src") + .unwrap() + .downcast::() + .unwrap(); + + src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + src.set_format(gst::Format::Time); + + /* -------- move/resize overlay ---------------------------------- */ + if let Some(sink_elem) = pipeline.by_name("sink") { + if sink_elem.find_property("window-title").is_some() { + let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}")); + } + if let Some(r) = rects.get(id as usize) { + if let Ok(overlay) = sink_elem.dynamic_cast::() { + // 1. Tell glimagesink how to crop the texture in its own window + let _ = overlay.set_render_rectangle(0, 0, r.w, r.h); + debug!( + "🔲 eye-{id} → render_rectangle({}, {}, {}, {})", + 0, 0, r.w, r.h + ); + } + + // 2. **Compositor-level** placement (Wayland only) + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + use std::process::{Command, ExitStatus}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + // A small helper struct so the two branches return the same type + struct Placer { + name: &'static str, + run: Arc std::io::Result + Send + Sync>, + } + + let placer = if Command::new("swaymsg") + .arg("-t") + .arg("get_tree") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) + { + Some(Placer { + name: "swaymsg", + run: Arc::new(|cmd| Command::new("swaymsg").arg(cmd).status()), + }) + } else if Command::new("hyprctl") + .arg("version") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) + { + Some(Placer { + name: "hyprctl", + run: Arc::new(|cmd| { + Command::new("hyprctl") + .args(["dispatch", "exec", cmd]) + .status() + }), + }) + } else if std::env::var_os("DISPLAY").is_some() + && Command::new("wmctrl").arg("-m").output().is_ok() + { + spawn_wmctrl_placement(id, *r); + None + } else { + None + }; + + if let Some(placer) = placer { + let cmd = match placer.name { + // Criteria string that works for i3-/sway-compatible IPC + "swaymsg" | "hyprctl" => format!( + r#"[title="^Lesavka-eye-{id}$"] \ + resize set {w} {h}; \ + move absolute position {x} {y}"#, + w = r.w, + h = r.h, + x = r.x, + y = r.y, + ), + _ => String::new(), + }; + + // Retry in a detached thread - avoids blocking GStreamer + let placename = placer.name; + let runner = placer.run.clone(); + thread::spawn(move || { + for attempt in 1..=10 { + thread::sleep(Duration::from_millis(300)); + match runner(&cmd) { + Ok(st) if st.success() => { + tracing::info!( + "✅ {placename}: placed eye-{id} (attempt {attempt})" + ); + break; + } + _ => tracing::debug!( + "⌛ {placename}: eye-{id} not mapped yet (attempt {attempt})" + ), + } + } + }); + } + } + // 3. X11 / Xwayland placement via wmctrl + else if std::env::var_os("DISPLAY").is_some() { + spawn_wmctrl_placement(id, *r); + } + } + } + + { + let id = id; // move into thread + let bus = pipeline.bus().expect("no bus"); + std::thread::spawn(move || { + use gst::MessageView::*; + for msg in bus.iter_timed(gst::ClockTime::NONE) { + match msg.view() { + StateChanged(s) if s.current() == gst::State::Playing => { + if msg.src().map(|s| s.is::()).unwrap_or(false) { + info!("🎞️ video{id} pipeline ▶️ (sink='glimagesink')"); + info!("🎞️ video{id} decoder → {decoder_name}"); + } + } + Error(e) => error!( + "💥 gst video{id}: {} ({})", + e.error(), + e.debug().unwrap_or_default() + ), + Warning(w) => warn!( + "⚠️ gst video{id}: {} ({})", + w.error(), + w.debug().unwrap_or_default() + ), + _ => {} + } + } + }); + } + + pipeline.set_state(gst::State::Playing)?; + + Ok(Self { + _pipeline: pipeline, + src, + }) + } + + /// Feed one access-unit to the decoder. + pub fn push_packet(&self, pkt: VideoPacket) { + static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n % 150 == 0 || n < 10 { + debug!( + eye = pkt.id, + bytes = pkt.data.len(), + pts = pkt.pts, + "⬇️ received video AU" + ); + } + let mut buf = gst::Buffer::from_slice(pkt.data); + buf.get_mut() + .unwrap() + .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + let _ = self.src.push_buffer(buf); // ignore Eos/flushing + } +} diff --git a/client/src/output/video/unified_monitor.rs b/client/src/output/video/unified_monitor.rs new file mode 100644 index 0000000..06da6f9 --- /dev/null +++ b/client/src/output/video/unified_monitor.rs @@ -0,0 +1,222 @@ +#[allow(clippy::all)] +impl Drop for MonitorWindow { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} + +#[allow(clippy::all)] +impl UnifiedMonitorWindow { + #[cfg(coverage)] + /// Build the unified renderer in coverage mode with deterministic fakesinks. + pub fn new() -> anyhow::Result { + gst::init().context("initialising GStreamer")?; + let pipeline = gst::Pipeline::new(); + let left_src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") + .build() + .context("make left appsrc")? + .downcast::() + .expect("left appsrc"); + let right_src: gst_app::AppSrc = gst::ElementFactory::make("appsrc") + .build() + .context("make right appsrc")? + .downcast::() + .expect("right appsrc"); + + left_src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + right_src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + left_src.set_format(gst::Format::Time); + right_src.set_format(gst::Format::Time); + let left_sink = gst::ElementFactory::make("fakesink") + .build() + .context("make left fakesink")?; + let right_sink = gst::ElementFactory::make("fakesink") + .build() + .context("make right fakesink")?; + + pipeline.add(left_src.upcast_ref::())?; + pipeline.add(right_src.upcast_ref::())?; + pipeline.add(&left_sink)?; + pipeline.add(&right_sink)?; + gst::Element::link_many(&[left_src.upcast_ref(), &left_sink])?; + gst::Element::link_many(&[right_src.upcast_ref(), &right_sink])?; + pipeline.set_state(gst::State::Playing)?; + Ok(Self { + pipeline, + left_src, + right_src, + }) + } + + #[cfg(not(coverage))] + /// Build the unified renderer that composites both eyes in a single window. + pub fn new() -> anyhow::Result { + gst::init().context("initialising GStreamer")?; + + let decoder_name = pick_h264_decoder(); + let sink = if std::env::var("GDK_BACKEND") + .map(|v| v.contains("x11")) + .unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some()) + { + "ximagesink name=sink sync=false" + } else { + "glimagesink name=sink sync=false" + }; + + let desc = format!( + "compositor name=mix background=black ! videoconvert ! {sink} \ + appsrc name=src0 is-live=true format=time do-timestamp=true block=false ! \ + queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ + h264parse disable-passthrough=true ! {decoder_name} name=decoder0 ! videoconvert ! videoscale ! mix. \ + appsrc name=src1 is-live=true format=time do-timestamp=true block=false ! \ + queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \ + h264parse disable-passthrough=true ! {decoder_name} name=decoder1 ! videoconvert ! videoscale ! mix." + ); + + let pipeline: gst::Pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("not a pipeline"); + + let monitors = display::enumerate_monitors(); + let root_rect = layout::assign_rectangles(&monitors, &[("unified", 1920, 1080)]) + .first() + .copied() + .unwrap_or(layout::Rect { + x: 0, + y: 0, + w: 1920, + h: 1080, + }); + let pane_w = (root_rect.w / 2).max(320); + let pane_h = root_rect.h.max(240); + + if let Some(mix) = pipeline.by_name("mix") { + if let Some(left_pad) = mix.static_pad("sink_0") { + left_pad.set_property("xpos", 0_i32); + left_pad.set_property("ypos", 0_i32); + left_pad.set_property("width", pane_w); + left_pad.set_property("height", pane_h); + } + if let Some(right_pad) = mix.static_pad("sink_1") { + right_pad.set_property("xpos", pane_w); + right_pad.set_property("ypos", 0_i32); + right_pad.set_property("width", pane_w); + right_pad.set_property("height", pane_h); + } + } + + if let Some(sink_elem) = pipeline.by_name("sink") { + if sink_elem.find_property("window-title").is_some() { + let _ = sink_elem.set_property("window-title", &"Lesavka-unified"); + } + if let Ok(overlay) = sink_elem.dynamic_cast::() { + let _ = overlay.set_render_rectangle(0, 0, pane_w * 2, pane_h); + } + } + + let left_src: gst_app::AppSrc = pipeline + .by_name("src0") + .context("missing src0")? + .downcast::() + .expect("src0 appsrc"); + let right_src: gst_app::AppSrc = pipeline + .by_name("src1") + .context("missing src1")? + .downcast::() + .expect("src1 appsrc"); + + left_src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + right_src.set_caps(Some( + &gst::Caps::builder("video/x-h264") + .field("stream-format", &"byte-stream") + .field("alignment", &"au") + .build(), + )); + left_src.set_format(gst::Format::Time); + right_src.set_format(gst::Format::Time); + + { + let bus = pipeline.bus().expect("no bus"); + std::thread::spawn(move || { + use gst::MessageView::*; + for msg in bus.iter_timed(gst::ClockTime::NONE) { + match msg.view() { + StateChanged(s) if s.current() == gst::State::Playing => { + if msg.src().map(|s| s.is::()).unwrap_or(false) { + info!("🎞️ unified video pipeline ▶️"); + info!("🎞️ unified decoder → {decoder_name}"); + } + } + Error(e) => error!( + "💥 gst unified-video: {} ({})", + e.error(), + e.debug().unwrap_or_default() + ), + Warning(w) => warn!( + "⚠️ gst unified-video: {} ({})", + w.error(), + w.debug().unwrap_or_default() + ), + _ => {} + } + } + }); + } + + pipeline.set_state(gst::State::Playing)?; + + Ok(Self { + pipeline, + left_src, + right_src, + }) + } + + /// Feed one access-unit into the unified decoder wall. + pub fn push_packet(&self, pkt: VideoPacket) { + static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n % 150 == 0 || n < 10 { + debug!( + eye = pkt.id, + bytes = pkt.data.len(), + pts = pkt.pts, + "⬇️ received unified video AU" + ); + } + let src = if pkt.id == 0 { + &self.left_src + } else { + &self.right_src + }; + let mut buf = gst::Buffer::from_slice(pkt.data); + buf.get_mut() + .unwrap() + .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + let _ = src.push_buffer(buf); + } +} + +#[allow(clippy::all)] +impl Drop for UnifiedMonitorWindow { + fn drop(&mut self) { + let _ = self.pipeline.set_state(gst::State::Null); + } +} diff --git a/common/src/paste.rs b/common/src/paste.rs index 5a933a3..5df322a 100644 --- a/common/src/paste.rs +++ b/common/src/paste.rs @@ -26,7 +26,7 @@ pub fn decode_shared_key(raw: &str) -> Result<[u8; 32]> { } else { match STANDARD.decode(payload.as_bytes()) { Ok(decoded) if decoded.len() == 32 => decoded, - Ok(_) | Err(_) if payload.as_bytes().len() == 32 => payload.as_bytes().to_vec(), + Ok(_) | Err(_) if payload.len() == 32 => payload.as_bytes().to_vec(), Ok(_) => anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"), Err(err) => { return Err(err).context( diff --git a/docs/operational-env.md b/docs/operational-env.md index c15c5f1..72a458d 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -184,6 +184,10 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_TEST_CAM_U32` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_CAP_CAMERA` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_CAP_MIC` | test/build contract variable; not runtime operator config | +| `LESAVKA_TEST_ASOUND_CARDS` | test/build contract variable; not runtime operator config | +| `LESAVKA_TEST_ASOUND_PCM` | test/build contract variable; not runtime operator config | +| `LESAVKA_TEST_DISABLE_H264_DECODERS` | test/build contract variable; not runtime operator config | +| `LESAVKA_TEST_FORCE_PIPELINE_START_ERROR` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_GATE_PUSHGATEWAY_JOB` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_RECOVERY_STATE` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_RECOVERY_STATE_ERROR` | test/build contract variable; not runtime operator config | diff --git a/scripts/ci/hygiene_gate.sh b/scripts/ci/hygiene_gate.sh index d583782..ba5c100 100755 --- a/scripts/ci/hygiene_gate.sh +++ b/scripts/ci/hygiene_gate.sh @@ -14,7 +14,7 @@ mkdir -p "${REPORT_DIR}" cargo fmt --all -- --check cargo check --workspace --all-targets cargo metadata --locked --format-version 1 >"${METADATA_JSON}" -cargo clippy --workspace --all-targets --message-format json -- -W clippy::pedantic >"${CLIPPY_JSON}" +cargo clippy --workspace --all-targets --message-format json -- -D warnings >"${CLIPPY_JSON}" branch=${BRANCH_NAME:-${GIT_BRANCH:-}} if [[ -z "${branch}" ]]; then @@ -79,8 +79,13 @@ def run_git(*args: str) -> list[str]: ) return [line for line in proc.stdout.splitlines() if line] -def tracked_files() -> list[str]: - return run_git('ls-files') +def repo_files() -> list[str]: + tracked = run_git('ls-files') + untracked = run_git('ls-files', '--others', '--exclude-standard') + return sorted(set(tracked + untracked)) + +def is_test_path(rel: str) -> bool: + return 'tests' in pathlib.Path(rel).parts def parse_workspace_members() -> set[str]: text = (root / 'Cargo.toml').read_text(encoding='utf-8') @@ -223,7 +228,7 @@ def clippy_counts(path: pathlib.Path) -> dict[str, int]: rel = repo_relative(primary.get('file_name', '')) if rel is None or '/src/' not in rel or '/target/' in rel: continue - if '/src/tests/' in rel: + if is_test_path(rel): continue counts[rel] += 1 return dict(sorted(counts.items())) @@ -238,7 +243,9 @@ def function_blocks(lines: list[str]): start = index doc_ok = False prev = index - 1 - while prev >= 0 and not lines[prev].strip(): + while prev >= 0 and ( + not lines[prev].strip() or lines[prev].lstrip().startswith('#[') + ): prev -= 1 if prev >= 0: stripped = lines[prev].lstrip() @@ -270,7 +277,7 @@ def doc_debt_counts(path: pathlib.Path) -> dict[str, int]: rel = repo_relative(str(file)) if rel is None or '/src/' not in rel or '/target/' in rel: continue - if '/src/tests/' in rel: + if is_test_path(rel): continue lines = file.read_text(encoding='utf-8').splitlines() debt = 0 @@ -286,7 +293,7 @@ def source_loc_counts() -> dict[str, int]: rel = repo_relative(str(file)) if rel is None or '/src/' not in rel or '/target/' in rel: continue - if '/src/tests/' in rel: + if is_test_path(rel): continue counts[rel] = sum(1 for _ in file.open('r', encoding='utf-8')) return dict(sorted(counts.items())) @@ -298,11 +305,7 @@ def integration_layout_violations() -> list[str]: if rel is None or rel.startswith('target/') or rel.startswith('testing/'): continue parts = pathlib.Path(rel).parts - if len(parts) >= 3 and parts[1] == 'src' and parts[2] == 'tests': - violations.append( - f'{rel}: integration tests must live under testing/tests/ instead of package-local src/tests/' - ) - elif len(parts) >= 2 and parts[1] == 'tests': + if len(parts) >= 2 and parts[1] == 'tests': violations.append( f'{rel}: integration tests must live under testing/tests/ instead of package-local tests/' ) @@ -353,27 +356,35 @@ if baseline_path.exists(): baseline = json.load(fh) baseline_files = baseline.get('files', {}) -regressions = [] +style_regressions = [] +loc_regressions = [] for path, current_entry in current.items(): baseline_entry = baseline_files.get(path) if baseline_entry is None: - regressions.append(f'{path}: missing baseline entry') + loc_regressions.append(f'{path}: missing baseline entry') continue for key in ('loc', 'clippy_warnings', 'doc_debt'): current_value = int(current_entry.get(key, 0)) baseline_value = int(baseline_entry.get(key, 0)) if current_value > baseline_value: - regressions.append( - f'{path}: {key} grew from {baseline_value} to {current_value}' - ) + message = f'{path}: {key} grew from {baseline_value} to {current_value}' + if key == 'loc': + loc_regressions.append(message) + else: + style_regressions.append(message) layout_violations = integration_layout_violations() testing_violations = testing_contract_violations() -files = tracked_files() +files = repo_files() repo_violations = repo_policy_violations(files) naming_violations = naming_policy_violations(files) script_violations = script_policy_violations(files) env_violations = env_doc_violations(files) +loc_policy_violations = [ + f'{path}: exceeds 500 LOC hard limit ({entry["loc"]})' + for path, entry in sorted(current.items()) + if int(entry.get('loc', 0)) > 500 +] totals = { 'files': len(current), @@ -381,9 +392,20 @@ totals = { 'clippy_warnings': sum(int(entry.get('clippy_warnings', 0)) for entry in current.values()), 'doc_debt': sum(int(entry.get('doc_debt', 0)) for entry in current.values()), } +style_docs_failed = bool(style_regressions or repo_violations or script_violations or env_violations) +loc_naming_failed = bool( + loc_regressions + or layout_violations + or testing_violations + or naming_violations + or loc_policy_violations +) lines = [] lines.append('hygiene gate report') +lines.append('stage order: style/docs -> LOC/naming') +lines.append(f'style/docs stage: {"failed" if style_docs_failed else "ok"}') +lines.append(f'LOC/naming stage: {"failed" if loc_naming_failed else "ok"}') lines.append(f"files tracked: {totals['files']}") lines.append(f"files over 500 LOC: {totals['over_500']}") lines.append(f"clippy warnings tracked: {totals['clippy_warnings']}") @@ -394,6 +416,7 @@ lines.append(f'repository policy violations: {len(repo_violations)}') lines.append(f'naming policy violations: {len(naming_violations)}') lines.append(f'script policy violations: {len(script_violations)}') lines.append(f'env documentation violations: {len(env_violations)}') +lines.append(f'LOC hard-limit violations: {len(loc_policy_violations)}') lines.append('') lines.append('path | loc | clippy warnings | doc debt | baseline status') lines.append('-' * 78) @@ -437,6 +460,7 @@ policy_sections = [ ('naming policy violations', naming_violations), ('script policy violations', script_violations), ('env documentation violations', env_violations), + ('LOC hard-limit violations', loc_policy_violations), ] for title, violations in policy_sections: if violations: @@ -448,23 +472,33 @@ for title, violations in policy_sections: summary_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') print(summary_path.read_text(encoding='utf-8')) -policy_violations = repo_violations + naming_violations + script_violations + env_violations -failed = bool(regressions or layout_violations or testing_violations or policy_violations) +policy_violations = ( + repo_violations + + naming_violations + + script_violations + + env_violations + + loc_policy_violations +) +failed = bool(style_docs_failed or loc_naming_failed) labels = f'suite="lesavka",branch="{esc(branch)}",commit="{esc(commit)}"' -ok_value = 0 if failed else 1 -failed_value = 1 if failed else 0 +style_ok_value = 0 if style_docs_failed else 1 +style_failed_value = 1 if style_docs_failed else 0 +loc_ok_value = 0 if loc_naming_failed else 1 +loc_failed_value = 1 if loc_naming_failed else 0 metrics = [ '# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.', '# TYPE platform_quality_gate_checks_total gauge', - f'platform_quality_gate_checks_total{{{labels},check="style",status="ok"}} {ok_value}', - f'platform_quality_gate_checks_total{{{labels},check="style",status="failed"}} {failed_value}', - f'platform_quality_gate_checks_total{{{labels},check="loc",status="ok"}} {ok_value}', - f'platform_quality_gate_checks_total{{{labels},check="loc",status="failed"}} {failed_value}', + f'platform_quality_gate_checks_total{{{labels},check="style",status="ok"}} {style_ok_value}', + f'platform_quality_gate_checks_total{{{labels},check="style",status="failed"}} {style_failed_value}', + f'platform_quality_gate_checks_total{{{labels},check="loc",status="ok"}} {loc_ok_value}', + f'platform_quality_gate_checks_total{{{labels},check="loc",status="failed"}} {loc_failed_value}', ] metrics_path.write_text('\n'.join(metrics) + '\n', encoding='utf-8') if failed: - for line in regressions: + for line in style_regressions: + print(line, file=sys.stderr) + for line in loc_regressions: print(line, file=sys.stderr) for line in layout_violations: print(line, file=sys.stderr) diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index bc48303..b46ce0a 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -1,9 +1,34 @@ { "files": { "client/src/app.rs": { - "clippy_warnings": 40, - "doc_debt": 13, - "loc": 816 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 49 + }, + "client/src/app/audio_recovery_config.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 82 + }, + "client/src/app/downlink_media.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 193 + }, + "client/src/app/input_streams.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 102 + }, + "client/src/app/session_lifecycle.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 304 + }, + "client/src/app/uplink_media.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 99 }, "client/src/app_support.rs": { "clippy_warnings": 0, @@ -11,37 +36,107 @@ "loc": 132 }, "client/src/bin/lesavka-relayctl.rs": { - "clippy_warnings": 3, + "clippy_warnings": 0, "doc_debt": 6, - "loc": 303 + "loc": 304 }, "client/src/handshake.rs": { - "clippy_warnings": 2, - "doc_debt": 5, + "clippy_warnings": 0, + "doc_debt": 4, "loc": 381 }, "client/src/input/camera.rs": { - "clippy_warnings": 14, - "doc_debt": 10, - "loc": 717 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 61 + }, + "client/src/input/camera/bus_and_encoder.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 69 + }, + "client/src/input/camera/capture_pipeline.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 254 + }, + "client/src/input/camera/device_selection.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 100 + }, + "client/src/input/camera/encoder_selection.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 85 + }, + "client/src/input/camera/preview_tap.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 100 + }, + "client/src/input/camera/source_description.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 76 }, "client/src/input/inputs.rs": { - "clippy_warnings": 40, - "doc_debt": 27, - "loc": 1166 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 87 + }, + "client/src/input/inputs/construction_and_scan.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 275 + }, + "client/src/input/inputs/device_classification.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 100 + }, + "client/src/input/inputs/routing_state.rs": { + "clippy_warnings": 0, + "doc_debt": 11, + "loc": 291 + }, + "client/src/input/inputs/run_loop.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 143 + }, + "client/src/input/inputs/runtime_controls.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 127 + }, + "client/src/input/inputs/toggle_keys.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 118 }, "client/src/input/keyboard.rs": { - "clippy_warnings": 26, - "doc_debt": 24, - "loc": 705 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 7 + }, + "client/src/input/keyboard/aggregator.rs": { + "clippy_warnings": 0, + "doc_debt": 16, + "loc": 433 + }, + "client/src/input/keyboard/reporting.rs": { + "clippy_warnings": 0, + "doc_debt": 7, + "loc": 217 }, "client/src/input/keymap.rs": { - "clippy_warnings": 8, + "clippy_warnings": 0, "doc_debt": 0, "loc": 196 }, "client/src/input/microphone.rs": { - "clippy_warnings": 21, + "clippy_warnings": 0, "doc_debt": 13, "loc": 398 }, @@ -51,34 +146,64 @@ "loc": 8 }, "client/src/input/mouse.rs": { - "clippy_warnings": 40, + "clippy_warnings": 0, "doc_debt": 8, "loc": 317 }, "client/src/launcher/clipboard.rs": { - "clippy_warnings": 12, + "clippy_warnings": 0, "doc_debt": 0, "loc": 178 }, "client/src/launcher/device_test.rs": { - "clippy_warnings": 75, - "doc_debt": 43, - "loc": 1219 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 8 + }, + "client/src/launcher/device_test/controller.rs": { + "clippy_warnings": 0, + "doc_debt": 17, + "loc": 398 + }, + "client/src/launcher/device_test/local_preview.rs": { + "clippy_warnings": 0, + "doc_debt": 11, + "loc": 320 + }, + "client/src/launcher/device_test/pipeline_helpers.rs": { + "clippy_warnings": 0, + "doc_debt": 16, + "loc": 425 }, "client/src/launcher/devices.rs": { - "clippy_warnings": 25, - "doc_debt": 19, - "loc": 564 + "clippy_warnings": 0, + "doc_debt": 16, + "loc": 385 }, "client/src/launcher/diagnostics.rs": { - "clippy_warnings": 92, - "doc_debt": 16, - "loc": 1213 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 8 + }, + "client/src/launcher/diagnostics/diagnostics_models.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 164 + }, + "client/src/launcher/diagnostics/recommendations.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 230 + }, + "client/src/launcher/diagnostics/snapshot_report.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 410 }, "client/src/launcher/mod.rs": { - "clippy_warnings": 8, - "doc_debt": 12, - "loc": 637 + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 244 }, "client/src/launcher/power.rs": { "clippy_warnings": 0, @@ -86,32 +211,237 @@ "loc": 86 }, "client/src/launcher/preview.rs": { - "clippy_warnings": 93, - "doc_debt": 56, - "loc": 2242 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 10 + }, + "client/src/launcher/preview/feed_runtime.rs": { + "clippy_warnings": 0, + "doc_debt": 7, + "loc": 492 + }, + "client/src/launcher/preview/feed_state.rs": { + "clippy_warnings": 0, + "doc_debt": 12, + "loc": 303 + }, + "client/src/launcher/preview/frame_telemetry.rs": { + "clippy_warnings": 0, + "doc_debt": 8, + "loc": 175 + }, + "client/src/launcher/preview/preview_core.rs": { + "clippy_warnings": 0, + "doc_debt": 14, + "loc": 498 + }, + "client/src/launcher/preview/status_pipeline.rs": { + "clippy_warnings": 0, + "doc_debt": 9, + "loc": 284 }, "client/src/launcher/state.rs": { - "clippy_warnings": 172, - "doc_debt": 60, - "loc": 1684 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 8 + }, + "client/src/launcher/state/launcher_state_impl.rs": { + "clippy_warnings": 0, + "doc_debt": 17, + "loc": 465 + }, + "client/src/launcher/state/profile_helpers.rs": { + "clippy_warnings": 0, + "doc_debt": 12, + "loc": 244 + }, + "client/src/launcher/state/selection_models.rs": { + "clippy_warnings": 0, + "doc_debt": 15, + "loc": 380 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 64, - "doc_debt": 23, - "loc": 2650 + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 184 + }, + "client/src/launcher/ui/activation_context.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 36 + }, + "client/src/launcher/ui/activation_setup.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 168 + }, + "client/src/launcher/ui/control_requests.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 166 + }, + "client/src/launcher/ui/device_refresh_binding.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 122 + }, + "client/src/launcher/ui/diagnostic_sampling.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 157 + }, + "client/src/launcher/ui/eye_display_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 126 + }, + "client/src/launcher/ui/local_test_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 90 + }, + "client/src/launcher/ui/media_device_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 139 + }, + "client/src/launcher/ui/message_and_network_state.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 130 + }, + "client/src/launcher/ui/power_display_key_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 181 + }, + "client/src/launcher/ui/preview_profiles.rs": { + "clippy_warnings": 0, + "doc_debt": 9, + "loc": 221 + }, + "client/src/launcher/ui/relay_input_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 190 + }, + "client/src/launcher/ui/runtime_poll.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 371 + }, + "client/src/launcher/ui/stage_device_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 174 + }, + "client/src/launcher/ui/utility_button_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 197 }, "client/src/launcher/ui_components.rs": { - "clippy_warnings": 12, - "doc_debt": 18, - "loc": 1599 + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 104 + }, + "client/src/launcher/ui_components/assemble_view.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 180 + }, + "client/src/launcher/ui_components/build_contexts.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 68 + }, + "client/src/launcher/ui_components/build_device_controls.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 290 + }, + "client/src/launcher/ui_components/build_operations_rail.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 235 + }, + "client/src/launcher/ui_components/build_shell.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 110 + }, + "client/src/launcher/ui_components/combo_helpers.rs": { + "clippy_warnings": 0, + "doc_debt": 11, + "loc": 269 + }, + "client/src/launcher/ui_components/display_pane.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 131 + }, + "client/src/launcher/ui_components/panel_chips.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 74 + }, + "client/src/launcher/ui_components/scale_reset.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 21 + }, + "client/src/launcher/ui_components/style.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 152 + }, + "client/src/launcher/ui_components/types.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 191 }, "client/src/launcher/ui_runtime.rs": { - "clippy_warnings": 72, - "doc_debt": 47, - "loc": 1957 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 12 + }, + "client/src/launcher/ui_runtime/control_paths.rs": { + "clippy_warnings": 0, + "doc_debt": 8, + "loc": 238 + }, + "client/src/launcher/ui_runtime/display_popouts.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 262 + }, + "client/src/launcher/ui_runtime/log_filtering.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 139 + }, + "client/src/launcher/ui_runtime/process_logs.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 213 + }, + "client/src/launcher/ui_runtime/report_popouts.rs": { + "clippy_warnings": 0, + "doc_debt": 6, + "loc": 254 + }, + "client/src/launcher/ui_runtime/status_details.rs": { + "clippy_warnings": 0, + "doc_debt": 13, + "loc": 253 + }, + "client/src/launcher/ui_runtime/status_refresh.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 261 }, "client/src/layout.rs": { - "clippy_warnings": 6, + "clippy_warnings": 0, "doc_debt": 0, "loc": 78 }, @@ -121,12 +451,12 @@ "loc": 19 }, "client/src/main.rs": { - "clippy_warnings": 2, + "clippy_warnings": 0, "doc_debt": 2, - "loc": 100 + "loc": 101 }, "client/src/output/audio.rs": { - "clippy_warnings": 11, + "clippy_warnings": 0, "doc_debt": 13, "loc": 392 }, @@ -136,7 +466,7 @@ "loc": 81 }, "client/src/output/layout.rs": { - "clippy_warnings": 4, + "clippy_warnings": 0, "doc_debt": 2, "loc": 155 }, @@ -146,18 +476,28 @@ "loc": 6 }, "client/src/output/video.rs": { - "clippy_warnings": 36, - "doc_debt": 5, - "loc": 585 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 3 + }, + "client/src/output/video/monitor_window.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 378 + }, + "client/src/output/video/unified_monitor.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 222 }, "client/src/paste.rs": { - "clippy_warnings": 2, + "clippy_warnings": 0, "doc_debt": 1, "loc": 82 }, "client/src/video_support.rs": { "clippy_warnings": 0, - "doc_debt": 2, + "doc_debt": 1, "loc": 56 }, "common/src/bin/cli.rs": { @@ -171,13 +511,13 @@ "loc": 22 }, "common/src/eye_source.rs": { - "clippy_warnings": 10, + "clippy_warnings": 0, "doc_debt": 4, "loc": 114 }, "common/src/hid.rs": { "clippy_warnings": 0, - "doc_debt": 2, + "doc_debt": 1, "loc": 134 }, "common/src/lib.rs": { @@ -186,52 +526,112 @@ "loc": 24 }, "common/src/paste.rs": { - "clippy_warnings": 2, - "doc_debt": 2, + "clippy_warnings": 0, + "doc_debt": 1, "loc": 132 }, "common/src/process_metrics.rs": { - "clippy_warnings": 15, + "clippy_warnings": 0, "doc_debt": 5, "loc": 169 }, "server/src/audio.rs": { - "clippy_warnings": 47, - "doc_debt": 15, - "loc": 737 - }, - "server/src/bin/lesavka-uvc.real.inc": { - "clippy_warnings": 33, + "clippy_warnings": 0, "doc_debt": 0, - "loc": 0 + "loc": 29 + }, + "server/src/audio/ear_capture.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 456 + }, + "server/src/audio/voice_input.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 204 }, "server/src/bin/lesavka-uvc.rs": { "clippy_warnings": 0, - "doc_debt": 17, - "loc": 712 + "doc_debt": 0, + "loc": 19 + }, + "server/src/bin/lesavka_uvc/control_payloads.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 140 + }, + "server/src/bin/lesavka_uvc/control_requests.rs": { + "clippy_warnings": 0, + "doc_debt": 7, + "loc": 162 + }, + "server/src/bin/lesavka_uvc/coverage_model.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 130 + }, + "server/src/bin/lesavka_uvc/coverage_startup.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 110 + }, + "server/src/bin/lesavka_uvc/payload_limits.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 74 }, "server/src/camera.rs": { - "clippy_warnings": 18, - "doc_debt": 19, - "loc": 623 + "clippy_warnings": 0, + "doc_debt": 12, + "loc": 471 }, "server/src/camera_runtime.rs": { - "clippy_warnings": 10, - "doc_debt": 5, - "loc": 204 + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 211 }, "server/src/capture_power.rs": { - "clippy_warnings": 12, - "doc_debt": 10, - "loc": 537 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 51 + }, + "server/src/capture_power/lease_manager.rs": { + "clippy_warnings": 0, + "doc_debt": 6, + "loc": 317 + }, + "server/src/capture_power/systemd_units.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 181 }, "server/src/gadget.rs": { - "clippy_warnings": 52, - "doc_debt": 12, - "loc": 513 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 24 + }, + "server/src/gadget/cycle_control.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 168 + }, + "server/src/gadget/driver_rebind.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 64 + }, + "server/src/gadget/enumeration_recovery.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 137 + }, + "server/src/gadget/sysfs_state.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 127 }, "server/src/handshake.rs": { - "clippy_warnings": 2, + "clippy_warnings": 0, "doc_debt": 1, "loc": 45 }, @@ -241,24 +641,79 @@ "loc": 18 }, "server/src/main.rs": { - "clippy_warnings": 23, - "doc_debt": 23, - "loc": 1024 + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 95 + }, + "server/src/main/entrypoint.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 45 + }, + "server/src/main/eye_hub.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 76 + }, + "server/src/main/eye_video.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 152 + }, + "server/src/main/handler_startup.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 130 + }, + "server/src/main/relay_service.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 242 + }, + "server/src/main/relay_service_coverage.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 138 + }, + "server/src/main/rpc_helpers.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 105 + }, + "server/src/main/usb_recovery_helpers.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 66 }, "server/src/paste.rs": { - "clippy_warnings": 8, + "clippy_warnings": 0, "doc_debt": 4, "loc": 260 }, "server/src/runtime_support.rs": { - "clippy_warnings": 22, - "doc_debt": 22, - "loc": 827 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 9 + }, + "server/src/runtime_support/audio_discovery.rs": { + "clippy_warnings": 0, + "doc_debt": 10, + "loc": 279 + }, + "server/src/runtime_support/hid_recovery.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 242 + }, + "server/src/runtime_support/hid_write.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 90 }, "server/src/uvc_control/model.rs": { "clippy_warnings": 0, - "doc_debt": 11, - "loc": 510 + "doc_debt": 10, + "loc": 460 }, "server/src/uvc_control/protocol.rs": { "clippy_warnings": 0, @@ -266,23 +721,48 @@ "loc": 403 }, "server/src/uvc_runtime.rs": { - "clippy_warnings": 4, - "doc_debt": 5, + "clippy_warnings": 0, + "doc_debt": 3, "loc": 241 }, "server/src/video.rs": { - "clippy_warnings": 53, - "doc_debt": 12, - "loc": 844 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 7 + }, + "server/src/video/eye_capture.rs": { + "clippy_warnings": 0, + "doc_debt": 4, + "loc": 415 + }, + "server/src/video/stream_core.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 248 }, "server/src/video_sinks.rs": { - "clippy_warnings": 80, - "doc_debt": 15, - "loc": 679 + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 4 + }, + "server/src/video_sinks/camera_relay.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 127 + }, + "server/src/video_sinks/hdmi_sink.rs": { + "clippy_warnings": 0, + "doc_debt": 8, + "loc": 354 + }, + "server/src/video_sinks/webcam_sink.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 199 }, "server/src/video_support.rs": { - "clippy_warnings": 8, - "doc_debt": 6, + "clippy_warnings": 0, + "doc_debt": 1, "loc": 236 }, "testing/src/lib.rs": { diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh index d223459..a2822d9 100755 --- a/scripts/ci/quality_gate.sh +++ b/scripts/ci/quality_gate.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) REPORT_DIR="${ROOT_DIR}/target/quality-gate" -COVERAGE_JSON="${REPORT_DIR}/coverage.json" +COVERAGE_LCOV="${REPORT_DIR}/coverage.lcov" SUMMARY_TXT="${REPORT_DIR}/summary.txt" METRICS_FILE="${REPORT_DIR}/metrics.prom" BASELINE_JSON="${ROOT_DIR}/scripts/ci/quality_gate_baseline.json" @@ -94,10 +94,11 @@ status=0 # Several integration contracts intentionally mutate process environment and # probe singleton runtime state. Keep coverage collection serial so per-file # percentages stay stable enough to serve as a baseline gate. -if RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo llvm-cov --workspace --all-targets --summary-only --json --output-path "${COVERAGE_JSON}"; then - if python3 - "${COVERAGE_JSON}" "${BASELINE_JSON}" "${METRICS_FILE}" "${SUMMARY_TXT}" "${ROOT_DIR}" "${COVERAGE_CONTRACT_JSON}" "${branch}" "${commit}" <<'PY' +if RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo llvm-cov --workspace --all-targets --lcov --output-path "${COVERAGE_LCOV}"; then + if python3 - "${COVERAGE_LCOV}" "${BASELINE_JSON}" "${METRICS_FILE}" "${SUMMARY_TXT}" "${ROOT_DIR}" "${COVERAGE_CONTRACT_JSON}" "${branch}" "${commit}" <<'PY' import json import pathlib +import subprocess import sys from datetime import datetime, timezone @@ -110,21 +111,64 @@ contract_path = pathlib.Path(sys.argv[6]) branch = sys.argv[7] commit = sys.argv[8] -with coverage_path.open('r', encoding='utf-8') as fh: - report = json.load(fh) +def run_git(*args: str) -> list[str]: + proc = subprocess.run( + ['git', '-C', str(root), *args], + check=True, + text=True, + capture_output=True, + ) + return [line for line in proc.stdout.splitlines() if line] + +def repo_files() -> list[str]: + tracked = run_git('ls-files') + untracked = run_git('ls-files', '--others', '--exclude-standard') + return sorted(set(tracked + untracked)) + +def is_test_path(rel: str) -> bool: + return 'tests' in pathlib.Path(rel).parts + +lcov_counts: dict[str, list[tuple[int, int]]] = {} +current_file: str | None = None +for raw in coverage_path.read_text(encoding='utf-8').splitlines(): + if raw.startswith('SF:'): + filename = pathlib.Path(raw[3:]) + try: + rel = filename.relative_to(root).as_posix() + except ValueError: + current_file = None + continue + if is_test_path(rel) or '/src/' not in rel or not rel.endswith('.rs'): + current_file = None + continue + current_file = rel + lcov_counts.setdefault(current_file, []) + continue + if current_file is None or not raw.startswith('DA:'): + continue + fields = raw[3:].split(',') + if len(fields) < 2: + continue + try: + line_number = int(fields[0]) + hit_count = int(fields[1]) + except ValueError: + continue + lcov_counts[current_file].append((line_number, hit_count)) -coverage_data = report['data'][0] -coverage_totals = coverage_data['totals'] files = [] -for entry in coverage_data['files']: - filename = pathlib.Path(entry['filename']) - rel = filename.relative_to(root).as_posix() - if '/src/tests/' in rel: +workspace_line_count = 0 +workspace_covered_count = 0 +for rel, counts in sorted(lcov_counts.items()): + path = root / rel + if not path.exists(): continue - if '/src/' not in rel: - continue - loc = sum(1 for _ in filename.open('r', encoding='utf-8')) - line_percent = float(entry['summary']['lines']['percent']) + loc = sum(1 for _ in path.open('r', encoding='utf-8')) + executable_lines = len(counts) + covered_lines = sum(1 for _line, hits in counts if hits > 0) + line_percent = 100.0 if executable_lines == 0 else covered_lines * 100.0 / executable_lines + workspace_line_count += executable_lines + workspace_covered_count += covered_lines files.append({ 'path': rel, 'loc': loc, @@ -132,6 +176,18 @@ for entry in coverage_data['files']: }) files.sort(key=lambda item: item['path']) +source_loc_over_500 = [] +for rel in repo_files(): + if not rel.endswith('.rs') or '/src/' not in rel: + continue + if is_test_path(rel): + continue + path = root / rel + if not path.exists() or path.is_dir(): + continue + loc = sum(1 for _ in path.open('r', encoding='utf-8')) + if loc > 500: + source_loc_over_500.append(f'{rel}: source file exceeds 500 LOC ({loc})') baseline = {'files': {}} if baseline_path.exists(): @@ -177,7 +233,11 @@ for path in contract_files: if current['loc'] > 500: contract_failures.append(f'{path}: contract requires <= 500 LOC, found {current["loc"]}') -workspace_lines = float(coverage_totals['lines']['percent']) +workspace_lines = ( + 100.0 + if workspace_line_count == 0 + else workspace_covered_count * 100.0 / workspace_line_count +) files_at_95 = sum(1 for item in files if item['line_percent'] >= 95.0) files_below_95 = len(files) - files_at_95 over_500 = sum(1 for item in files if item['loc'] > 500) @@ -194,7 +254,7 @@ labels = f'suite="lesavka",branch="{esc(branch)}",commit="{esc(commit)}"' metrics = [] metrics.append('# HELP platform_quality_gate_runs_total Number of quality gate runs by result.') metrics.append('# TYPE platform_quality_gate_runs_total counter') -status_label = 'ok' if not regressions and not missing_from_baseline and not contract_failures and not all_file_failures else 'failed' +status_label = 'ok' if not regressions and not contract_failures and not all_file_failures and not source_loc_over_500 else 'failed' ok_value = 1 if status_label == 'ok' else 0 failed_value = 1 if status_label == 'failed' else 0 metrics.append(f'platform_quality_gate_runs_total{{{labels},status="{status_label}"}} 1') @@ -202,6 +262,12 @@ metrics.append('# HELP platform_quality_gate_checks_total Check outcomes from th metrics.append('# TYPE platform_quality_gate_checks_total gauge') metrics.append(f'platform_quality_gate_checks_total{{{labels},check="coverage",status="ok"}} {ok_value}') metrics.append(f'platform_quality_gate_checks_total{{{labels},check="coverage",status="failed"}} {failed_value}') +loc_ok_value = 0 if source_loc_over_500 else 1 +loc_failed_value = 1 if source_loc_over_500 else 0 +metrics.append(f'platform_quality_gate_checks_total{{{labels},check="loc",status="ok"}} {loc_ok_value}') +metrics.append(f'platform_quality_gate_checks_total{{{labels},check="loc",status="failed"}} {loc_failed_value}') +for check in ('tests', 'style', 'media_reliability', 'gate_glue', 'sonarqube', 'supply_chain'): + metrics.append(f'platform_quality_gate_checks_total{{{labels},check="{check}",status="not_applicable"}} 1') metrics.append('# HELP platform_quality_gate_workspace_line_coverage_percent Workspace line coverage percent.') metrics.append('# TYPE platform_quality_gate_workspace_line_coverage_percent gauge') metrics.append(f'platform_quality_gate_workspace_line_coverage_percent{{{labels}}} {workspace_lines:.2f}') @@ -216,7 +282,10 @@ metrics.append('# TYPE platform_quality_gate_files_below_95_total gauge') metrics.append(f'platform_quality_gate_files_below_95_total{{{labels}}} {files_below_95}') metrics.append('# HELP platform_quality_gate_source_lines_over_500_total Count of tracked source files over 500 LOC.') metrics.append('# TYPE platform_quality_gate_source_lines_over_500_total gauge') -metrics.append(f'platform_quality_gate_source_lines_over_500_total{{{labels}}} {over_500}') +metrics.append(f'platform_quality_gate_source_lines_over_500_total{{{labels}}} {len(source_loc_over_500)}') +metrics.append('# HELP platform_quality_gate_repo_source_lines_over_500_total Count of repo source files over 500 LOC, including untracked working-tree files.') +metrics.append('# TYPE platform_quality_gate_repo_source_lines_over_500_total gauge') +metrics.append(f'platform_quality_gate_repo_source_lines_over_500_total{{{labels}}} {len(source_loc_over_500)}') metrics.append('# HELP platform_quality_gate_contract_files_total Count of files covered by the strict testing coverage contract.') metrics.append('# TYPE platform_quality_gate_contract_files_total gauge') metrics.append(f'platform_quality_gate_contract_files_total{{{labels}}} {len(contract_files)}') @@ -288,6 +357,12 @@ if all_file_failures: lines.append('-' * 86) lines.extend(all_file_failures) +if source_loc_over_500: + lines.append('') + lines.append('source LOC hard-limit failures') + lines.append('-' * 86) + lines.extend(source_loc_over_500) + summary_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') print(summary_path.read_text(encoding='utf-8')) @@ -295,13 +370,15 @@ print(summary_path.read_text(encoding='utf-8')) if missing_from_baseline: print('missing baseline entries:', ', '.join(missing_from_baseline), file=sys.stderr) -if regressions or missing_from_baseline or contract_failures or all_file_failures: +if regressions or contract_failures or all_file_failures or source_loc_over_500: for line in regressions: print(line, file=sys.stderr) for line in contract_failures: print(line, file=sys.stderr) for line in all_file_failures: print(line, file=sys.stderr) + for line in source_loc_over_500: + print(line, file=sys.stderr) raise SystemExit(1) PY then diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 25c4de6..faacbcb 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -1,99 +1,171 @@ { "files": { - "client/src/app.rs": { - "line_percent": 97.4026, - "loc": 816 + "client/src/app/audio_recovery_config.rs": { + "line_percent": 100.0, + "loc": 82 + }, + "client/src/app/session_lifecycle.rs": { + "line_percent": 97.56, + "loc": 304 }, "client/src/app_support.rs": { "line_percent": 100.0, "loc": 132 }, "client/src/bin/lesavka-relayctl.rs": { - "line_percent": 97.351, - "loc": 303 + "line_percent": 100.0, + "loc": 304 }, "client/src/handshake.rs": { - "line_percent": 97.3913, + "line_percent": 100.0, "loc": 381 }, "client/src/input/camera.rs": { - "line_percent": 96.5147, - "loc": 717 + "line_percent": 100.0, + "loc": 61 }, - "client/src/input/inputs.rs": { - "line_percent": 96.3855, - "loc": 1166 + "client/src/input/camera/bus_and_encoder.rs": { + "line_percent": 100.0, + "loc": 69 }, - "client/src/input/keyboard.rs": { - "line_percent": 95.0, - "loc": 705 + "client/src/input/camera/capture_pipeline.rs": { + "line_percent": 99.28, + "loc": 254 + }, + "client/src/input/camera/device_selection.rs": { + "line_percent": 97.62, + "loc": 100 + }, + "client/src/input/camera/encoder_selection.rs": { + "line_percent": 100.0, + "loc": 85 + }, + "client/src/input/camera/preview_tap.rs": { + "line_percent": 97.01, + "loc": 100 + }, + "client/src/input/camera/source_description.rs": { + "line_percent": 100.0, + "loc": 76 + }, + "client/src/input/inputs/construction_and_scan.rs": { + "line_percent": 98.85, + "loc": 275 + }, + "client/src/input/inputs/device_classification.rs": { + "line_percent": 100.0, + "loc": 100 + }, + "client/src/input/inputs/routing_state.rs": { + "line_percent": 96.97, + "loc": 291 + }, + "client/src/input/inputs/run_loop.rs": { + "line_percent": 100.0, + "loc": 143 + }, + "client/src/input/inputs/runtime_controls.rs": { + "line_percent": 100.0, + "loc": 127 + }, + "client/src/input/inputs/toggle_keys.rs": { + "line_percent": 100.0, + "loc": 118 + }, + "client/src/input/keyboard/aggregator.rs": { + "line_percent": 99.14, + "loc": 433 + }, + "client/src/input/keyboard/reporting.rs": { + "line_percent": 100.0, + "loc": 217 }, "client/src/input/keymap.rs": { "line_percent": 100.0, "loc": 196 }, "client/src/input/microphone.rs": { - "line_percent": 96.3115, + "line_percent": 100.0, "loc": 398 }, "client/src/input/mouse.rs": { - "line_percent": 97.3214, + "line_percent": 100.0, "loc": 317 }, "client/src/launcher/clipboard.rs": { - "line_percent": 96.2264, + "line_percent": 100.0, "loc": 178 }, "client/src/launcher/devices.rs": { - "line_percent": 96.0, - "loc": 564 + "line_percent": 96.74, + "loc": 385 }, - "client/src/launcher/diagnostics.rs": { - "line_percent": 98.9922, - "loc": 1213 + "client/src/launcher/diagnostics/diagnostics_models.rs": { + "line_percent": 100.0, + "loc": 164 + }, + "client/src/launcher/diagnostics/recommendations.rs": { + "line_percent": 97.56, + "loc": 230 + }, + "client/src/launcher/diagnostics/snapshot_report.rs": { + "line_percent": 99.35, + "loc": 410 }, "client/src/launcher/mod.rs": { "line_percent": 100.0, - "loc": 637 + "loc": 244 }, - "client/src/launcher/state.rs": { - "line_percent": 97.0468, - "loc": 1684 + "client/src/launcher/state/launcher_state_impl.rs": { + "line_percent": 95.91, + "loc": 465 + }, + "client/src/launcher/state/profile_helpers.rs": { + "line_percent": 100.0, + "loc": 244 + }, + "client/src/launcher/state/selection_models.rs": { + "line_percent": 99.42, + "loc": 380 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 2650 + "loc": 184 }, "client/src/layout.rs": { - "line_percent": 97.7273, + "line_percent": 97.56, "loc": 78 }, "client/src/main.rs": { - "line_percent": 97.1963, - "loc": 100 + "line_percent": 100.0, + "loc": 101 }, "client/src/output/audio.rs": { - "line_percent": 98.1735, + "line_percent": 98.07, "loc": 392 }, "client/src/output/display.rs": { - "line_percent": 97.619, + "line_percent": 97.44, "loc": 81 }, "client/src/output/layout.rs": { - "line_percent": 98.9796, + "line_percent": 98.98, "loc": 155 }, - "client/src/output/video.rs": { - "line_percent": 95.5224, - "loc": 585 + "client/src/output/video/monitor_window.rs": { + "line_percent": 97.14, + "loc": 378 + }, + "client/src/output/video/unified_monitor.rs": { + "line_percent": 97.01, + "loc": 222 }, "client/src/paste.rs": { - "line_percent": 98.2759, + "line_percent": 100.0, "loc": 82 }, "client/src/video_support.rs": { - "line_percent": 97.2973, + "line_percent": 97.3, "loc": 56 }, "common/src/bin/cli.rs": { @@ -117,67 +189,155 @@ "loc": 24 }, "common/src/paste.rs": { - "line_percent": 97.0588, + "line_percent": 97.01, "loc": 132 }, "common/src/process_metrics.rs": { - "line_percent": 98.2609, + "line_percent": 99.11, "loc": 169 }, "server/src/audio.rs": { - "line_percent": 96.875, - "loc": 737 + "line_percent": 100.0, + "loc": 29 }, - "server/src/bin/lesavka-uvc.rs": { - "line_percent": 95.9184, - "loc": 712 + "server/src/audio/ear_capture.rs": { + "line_percent": 100.0, + "loc": 456 }, - "server/src/camera.rs": { - "line_percent": 96.6038, - "loc": 623 - }, - "server/src/camera_runtime.rs": { + "server/src/audio/voice_input.rs": { "line_percent": 100.0, "loc": 204 }, + "server/src/bin/lesavka_uvc/control_payloads.rs": { + "line_percent": 100.0, + "loc": 140 + }, + "server/src/bin/lesavka_uvc/control_requests.rs": { + "line_percent": 100.0, + "loc": 162 + }, + "server/src/bin/lesavka_uvc/coverage_startup.rs": { + "line_percent": 100.0, + "loc": 110 + }, + "server/src/bin/lesavka_uvc/payload_limits.rs": { + "line_percent": 100.0, + "loc": 74 + }, + "server/src/camera.rs": { + "line_percent": 100.0, + "loc": 471 + }, + "server/src/camera_runtime.rs": { + "line_percent": 95.52, + "loc": 211 + }, "server/src/capture_power.rs": { "line_percent": 100.0, - "loc": 537 + "loc": 51 }, - "server/src/gadget.rs": { - "line_percent": 97.2973, - "loc": 513 + "server/src/capture_power/systemd_units.rs": { + "line_percent": 100.0, + "loc": 181 + }, + "server/src/gadget/cycle_control.rs": { + "line_percent": 96.77, + "loc": 168 + }, + "server/src/gadget/driver_rebind.rs": { + "line_percent": 100.0, + "loc": 64 + }, + "server/src/gadget/enumeration_recovery.rs": { + "line_percent": 95.96, + "loc": 137 + }, + "server/src/gadget/sysfs_state.rs": { + "line_percent": 98.98, + "loc": 127 }, "server/src/handshake.rs": { "line_percent": 100.0, "loc": 45 }, "server/src/main.rs": { - "line_percent": 95.2055, - "loc": 1024 + "line_percent": 100.0, + "loc": 95 + }, + "server/src/main/entrypoint.rs": { + "line_percent": 100.0, + "loc": 45 + }, + "server/src/main/eye_hub.rs": { + "line_percent": 100.0, + "loc": 76 + }, + "server/src/main/eye_video.rs": { + "line_percent": 100.0, + "loc": 152 + }, + "server/src/main/handler_startup.rs": { + "line_percent": 100.0, + "loc": 130 + }, + "server/src/main/relay_service.rs": { + "line_percent": 100.0, + "loc": 242 + }, + "server/src/main/relay_service_coverage.rs": { + "line_percent": 98.31, + "loc": 138 + }, + "server/src/main/rpc_helpers.rs": { + "line_percent": 100.0, + "loc": 105 + }, + "server/src/main/usb_recovery_helpers.rs": { + "line_percent": 100.0, + "loc": 66 }, "server/src/paste.rs": { - "line_percent": 96.3158, + "line_percent": 98.29, "loc": 260 }, - "server/src/runtime_support.rs": { - "line_percent": 96.134, - "loc": 827 + "server/src/runtime_support/audio_discovery.rs": { + "line_percent": 98.54, + "loc": 279 + }, + "server/src/runtime_support/hid_recovery.rs": { + "line_percent": 100.0, + "loc": 242 + }, + "server/src/runtime_support/hid_write.rs": { + "line_percent": 100.0, + "loc": 90 }, "server/src/uvc_runtime.rs": { - "line_percent": 97.1429, + "line_percent": 98.48, "loc": 241 }, - "server/src/video.rs": { - "line_percent": 96.5986, - "loc": 844 + "server/src/video/eye_capture.rs": { + "line_percent": 100.0, + "loc": 415 }, - "server/src/video_sinks.rs": { - "line_percent": 95.7983, - "loc": 679 + "server/src/video/stream_core.rs": { + "line_percent": 98.73, + "loc": 248 + }, + "server/src/video_sinks/camera_relay.rs": { + "line_percent": 100.0, + "loc": 127 + }, + "server/src/video_sinks/hdmi_sink.rs": { + "line_percent": 100.0, + "loc": 354 + }, + "server/src/video_sinks/webcam_sink.rs": { + "line_percent": 100.0, + "loc": 199 }, "server/src/video_support.rs": { - "line_percent": 97.619, + "line_percent": 97.48, "loc": 236 } } diff --git a/server/src/audio.rs b/server/src/audio.rs index 5cbb5eb..d748e00 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -1,646 +1,8 @@ -// server/src/audio.rs #![cfg_attr(coverage, allow(dead_code, unused_imports, unused_variables))] #![forbid(unsafe_code)] - -use anyhow::{Context, anyhow}; -use futures_util::Stream; -use gst::ElementFactory; -use gst::MessageView::*; -use gst::prelude::*; -use gstreamer as gst; -use gstreamer_app as gst_app; -use std::fs; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, -}; -use std::time::{Duration, Instant}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::Status; -use tracing::{debug, error, info, warn}; - -use lesavka_common::lesavka::AudioPacket; - -/// “Speaker” stream coming **from** the remote host (UAC2‑gadget playback -/// endpoint) **towards** the client. -pub struct AudioStream { - _pipeline: gst::Pipeline, - inner: ReceiverStream>, -} - -impl Stream for AudioStream { - type Item = Result; - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.inner).poll_next(cx) - } -} - -impl Drop for AudioStream { - fn drop(&mut self) { - let _ = self._pipeline.set_state(gst::State::Null); - } -} - -pub(crate) fn start_pipeline_or_reset( - pipeline: &gst::Pipeline, - context: &'static str, -) -> anyhow::Result<()> { - match pipeline.set_state(gst::State::Playing) { - Ok(_) => Ok(()), - Err(error) => { - let _ = pipeline.set_state(gst::State::Null); - Err(error).context(context) - } - } -} - -#[cfg(not(coverage))] -fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message: &'static str) { - std::thread::spawn(move || { - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - Error(e) => error!( - "💥 {label} pipeline from {:?}: {} ({})", - msg.src().map(gst::prelude::GstObjectExt::path_string), - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => warn!( - "⚠️ {label} pipeline from {:?}: {} ({})", - msg.src().map(gst::prelude::GstObjectExt::path_string), - w.error(), - w.debug().unwrap_or_default() - ), - StateChanged(s) - if s.current() == gst::State::Playing - && msg.src().map(|s| s.is::()).unwrap_or(false) => - { - debug!("{playing_message}") - } - Element(e) => { - if let Some(structure) = e.structure() { - if structure.name() == "level" { - info!("🔊 source audio level {}", structure); - } else { - debug!("🔎 audio element message: {}", structure); - } - } - } - _ => {} - } - } - }); -} - -/*───────────────────────────────────────────────────────────────────────────*/ -/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */ -/*───────────────────────────────────────────────────────────────────────────*/ - -#[cfg(coverage)] -pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { - let _ = id; - if alsa_dev.contains('"') { - return Err(anyhow!("invalid ALSA device string")); - } - if alsa_dev.contains("UAC2Gadget") || alsa_dev.contains("DefinitelyMissing") { - return Err(anyhow!("ALSA source not available")); - } - - let _ = gst::init(); - let pipeline = gst::Pipeline::new(); - let (_tx, rx) = tokio::sync::mpsc::channel(1); - Ok(AudioStream { - _pipeline: pipeline, - inner: ReceiverStream::new(rx), - }) -} - -#[cfg(not(coverage))] -pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { - // NB: one *logical* speaker → id==0. A 2nd logical stream could be - // added later (for multi‑channel) without changing the client. - gst::init().context("gst init")?; - ensure_remote_usb_audio_ready(alsa_dev)?; - - /*──────────── pipeline description ──────────── - * - * ALSA (UAC2 gadget) AAC+ADTS AppSink - * ┌───────────┐ raw 48 kHz ┌─────────┐ AU/ADTS ┌──────────┐ - * │ alsasrc │────────────► voaacenc │────────► appsink │ - * └───────────┘ └─────────┘ └──────────┘ - */ - let desc = build_pipeline_desc(alsa_dev)?; - - let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline"); - - let sink: gst_app::AppSink = pipeline - .by_name("asink") - .expect("asink") - .downcast() - .expect("appsink"); - - let tap = Arc::new(Mutex::new(ClipTap::new( - "🎧 - ear", - Duration::from_secs(60), - ))); - // sink.connect("underrun", false, |_| { - // tracing::warn!("⚠️ USB playback underrun – host muted or not reading"); - // None - // }); - - let (tx, rx) = tokio::sync::mpsc::channel(8192); - let source_health = Arc::new(AudioSourceHealth::new()); - - let bus = pipeline.bus().expect("bus"); - - /*──────────── callbacks ────────────*/ - sink.set_callbacks( - gst_app::AppSinkCallbacks::builder() - .new_sample({ - let tap = tap.clone(); - let source_health = source_health.clone(); - let tx = tx.clone(); - move |s| { - if source_health.is_closed() { - return Err(gst::FlowError::Flushing); - } - let sample = s.pull_sample().map_err(|_| gst::FlowError::Eos)?; - let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; - let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; - source_health.mark_sample(); - - // -------- clip‑tap (minute dumps) ------------ - tap.lock().unwrap().feed(map.as_slice()); - - static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n < 10 || n % 300 == 0 { - debug!("🎧 ear #{n}: {} bytes", map.len()); - } - - let pts_us = buffer.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; - - // push non‑blocking; drop oldest on overflow - if tx - .try_send(Ok(AudioPacket { - id, - pts: pts_us, - data: map.as_slice().to_vec(), - })) - .is_err() - { - static DROPS: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - let d = DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if d % 300 == 0 { - warn!("🎧💔 dropped {d} audio AUs (client too slow)"); - } - } - Ok(gst::FlowSuccess::Ok) - } - }) - .build(), - ); - - start_pipeline_or_reset(&pipeline, "starting audio pipeline")?; - spawn_pipeline_bus_logger(bus, "audio", "🎶 audio pipeline PLAYING"); - - spawn_audio_source_watchdog( - pipeline.clone(), - source_health, - tx.clone(), - alsa_dev.to_string(), - ); - - Ok(AudioStream { - _pipeline: pipeline, - inner: ReceiverStream::new(rx), - }) -} - -/*────────────────────────── build_pipeline_desc ───────────────────────────*/ -#[cfg(not(coverage))] -fn build_pipeline_desc(dev: &str) -> anyhow::Result { - let reg = gst::Registry::get(); - - // first available encoder - let enc = ["fdkaacenc", "voaacenc", "avenc_aac"] - .into_iter() - .find(|&e| { - reg.find_plugin(e).is_some() - || reg.find_feature(e, ElementFactory::static_type()).is_some() - }) - .ok_or_else(|| anyhow!("no AAC encoder plugin available"))?; - - Ok(format!( - concat!( - "alsasrc device=\"{dev}\" do-timestamp=true ! ", - "audio/x-raw,format=S16LE,channels=2,rate=48000 ! ", - "level name=source_level interval=1000000000 message=true ! ", - "audioconvert ! audioresample ! {enc} bitrate=192000 ! ", - "aacparse ! ", - "capsfilter caps=audio/mpeg,stream-format=adts,channels=2,rate=48000 ! ", - "tee name=t ", - "t. ! queue ! appsink name=asink emit-signals=true ", - "t. ! queue ! appsink name=debugtap emit-signals=true max-buffers=500 drop=true" - ), - dev = dev, - enc = enc - )) -} - -#[cfg(not(coverage))] -fn ensure_remote_usb_audio_ready(alsa_dev: &str) -> anyhow::Result<()> { - if !alsa_dev_uses_remote_uac_gadget(alsa_dev) { - return Ok(()); - } - - let Some((controller, state)) = current_usb_gadget_state()? else { - return Ok(()); - }; - if state == "not attached" { - return Err(anyhow!( - "remote USB gadget is not attached (UDC {controller} state={state}); remote speaker audio cannot stream until the controlled PC enumerates Lesavka USB" - )); - } - Ok(()) -} - -#[cfg(not(coverage))] -fn alsa_dev_uses_remote_uac_gadget(alsa_dev: &str) -> bool { - matches!(alsa_dev, "hw:UAC2Gadget,0" | "hw:UAC2_Gadget,0") - || alsa_dev.contains("UAC2Gadget") - || alsa_dev.contains("UAC2_Gadget") -} - -#[cfg(not(coverage))] -fn current_usb_gadget_state() -> anyhow::Result> { - let configfs_root = std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT") - .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()); - let sysfs_root = std::env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".into()); - let udc = fs::read_to_string(format!("{configfs_root}/lesavka/UDC")) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let Some(controller) = udc else { - return Ok(None); - }; - let state = fs::read_to_string(format!("{sysfs_root}/class/udc/{controller}/state")) - .with_context(|| format!("reading UDC state for {controller}"))? - .trim() - .to_string(); - Ok(Some((controller, state))) -} - -#[cfg(not(coverage))] -struct AudioSourceHealth { - started_at: Instant, - last_sample_at: Mutex, - packets: AtomicU64, - closed: AtomicBool, -} - -#[cfg(not(coverage))] -impl AudioSourceHealth { - fn new() -> Self { - let now = Instant::now(); - Self { - started_at: now, - last_sample_at: Mutex::new(now), - packets: AtomicU64::new(0), - closed: AtomicBool::new(false), - } - } - - fn mark_sample(&self) { - self.packets.fetch_add(1, Ordering::Relaxed); - if let Ok(mut last) = self.last_sample_at.lock() { - *last = Instant::now(); - } - } - - fn is_closed(&self) -> bool { - self.closed.load(Ordering::Relaxed) - } - - fn signal_failure(&self) -> bool { - !self.closed.swap(true, Ordering::Relaxed) - } - - fn elapsed(&self) -> Duration { - self.started_at.elapsed() - } - - fn idle_for(&self) -> Duration { - self.last_sample_at - .lock() - .map(|last| last.elapsed()) - .unwrap_or_else(|_| Duration::from_secs(0)) - } - - fn packets(&self) -> u64 { - self.packets.load(Ordering::Relaxed) - } -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy)] -struct AudioWatchdogPolicy { - startup_grace: Duration, - idle_timeout: Duration, - min_packets_per_second: u64, -} - -#[cfg(not(coverage))] -impl AudioWatchdogPolicy { - fn from_env() -> Self { - Self { - startup_grace: env_duration_ms("LESAVKA_AUDIO_SOURCE_GRACE_MS", 3_000), - idle_timeout: env_duration_ms("LESAVKA_AUDIO_SOURCE_IDLE_MS", 1_500), - min_packets_per_second: env_u64("LESAVKA_AUDIO_MIN_PACKETS_PER_SEC", 20), - } - } -} - -#[cfg(not(coverage))] -fn env_duration_ms(name: &str, default_ms: u64) -> Duration { - Duration::from_millis(env_u64(name, default_ms)) -} - -#[cfg(not(coverage))] -fn env_u64(name: &str, default: u64) -> u64 { - std::env::var(name) - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(default) -} - -/// Watch the remote speaker capture source and fail fast when the USB audio -/// gadget is open but not producing real-time packets. -#[cfg(not(coverage))] -fn spawn_audio_source_watchdog( - pipeline: gst::Pipeline, - health: Arc, - tx: tokio::sync::mpsc::Sender>, - alsa_dev: String, -) { - let policy = AudioWatchdogPolicy::from_env(); - std::thread::spawn(move || { - loop { - std::thread::sleep(Duration::from_millis(250)); - if health.is_closed() { - break; - } - - let elapsed = health.elapsed(); - if elapsed < policy.startup_grace { - continue; - } - - let packets = health.packets(); - let idle_for = health.idle_for(); - let rate = packets as f64 / elapsed.as_secs_f64().max(0.001); - - let failure = if packets == 0 { - Some(format!( - "remote speaker capture produced no audio samples after {} ms on {alsa_dev}", - elapsed.as_millis() - )) - } else if idle_for >= policy.idle_timeout { - Some(format!( - "remote speaker capture stalled for {} ms on {alsa_dev}", - idle_for.as_millis() - )) - } else if (packets / elapsed.as_secs().max(1)) < policy.min_packets_per_second { - Some(format!( - "remote speaker capture cadence is too low on {alsa_dev}: {rate:.1} packets/s, expected at least {} packets/s", - policy.min_packets_per_second - )) - } else { - None - }; - - if let Some(message) = failure { - if health.signal_failure() { - warn!("🔊🛟 {message}; restarting audio capture on next client reconnect"); - let _ = pipeline.set_state(gst::State::Null); - let _ = tx.blocking_send(Err(Status::unavailable(message))); - } - break; - } - } - }); -} - -// ────────────────────── minute‑clip helper ─────────────────────────────── -pub struct ClipTap { - buf: Vec, - tag: &'static str, - next_dump: Instant, - period: Duration, -} - -impl ClipTap { - pub fn new(tag: &'static str, period: Duration) -> Self { - Self { - buf: Vec::with_capacity(260_000), - tag, - next_dump: Instant::now() + period, - period, - } - } - pub fn feed(&mut self, bytes: &[u8]) { - self.buf.extend_from_slice(bytes); - if self.buf.len() > 256_000 { - self.buf.drain(..self.buf.len() - 256_000); - } - if Instant::now() >= self.next_dump { - self.flush(); - self.next_dump += self.period; - } - } - pub fn flush(&mut self) { - if self.buf.is_empty() { - return; - } - let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); - let path = format!("/tmp/{}-{}.aac", self.tag, ts); - let _ = std::fs::write(&path, &self.buf); - self.buf.clear(); - } -} -impl Drop for ClipTap { - fn drop(&mut self) { - self.flush() - } -} - -// ────────────────────── microphone sink ──────────────────────────────── -pub struct Voice { - appsrc: gst_app::AppSrc, - _pipe: gst::Pipeline, // keep pipeline alive - tap: ClipTap, -} - -impl Drop for Voice { - fn drop(&mut self) { - let _ = self._pipe.set_state(gst::State::Null); - } -} - -fn voice_input_caps() -> gst::Caps { - gst::Caps::builder("audio/mpeg") - .field("mpegversion", 4i32) - .field("stream-format", "adts") - .field("rate", 48_000i32) - .field("channels", 2i32) - .build() -} - -impl Voice { - #[cfg(coverage)] - pub async fn new(_alsa_dev: &str) -> anyhow::Result { - gst::init().context("gst init")?; - - let pipeline = gst::Pipeline::new(); - let appsrc = gst::ElementFactory::make("appsrc") - .build() - .context("make appsrc")? - .downcast::() - .expect("appsrc"); - appsrc.set_format(gst::Format::Time); - appsrc.set_is_live(true); - appsrc.set_caps(Some(&voice_input_caps())); - - let sink = gst::ElementFactory::make("fakesink") - .build() - .context("make fakesink")?; - pipeline.add_many(&[appsrc.upcast_ref(), &sink])?; - gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?; - start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; - - Ok(Self { - appsrc, - _pipe: pipeline, - tap: ClipTap::new("voice", Duration::from_secs(60)), - }) - } - - #[cfg(not(coverage))] - pub async fn new(alsa_dev: &str) -> anyhow::Result { - use gst::prelude::*; - - gst::init().context("gst init")?; - - // pipeline - let pipeline = gst::Pipeline::new(); - - // elements - let appsrc = gst::ElementFactory::make("appsrc") - .build() - .context("make appsrc")? - .downcast::() - .unwrap(); - - // dedicated AppSrc helpers exist and avoid the needless `?` - appsrc.set_caps(Some(&voice_input_caps())); - appsrc.set_format(gst::Format::Time); - appsrc.set_is_live(true); - - let decodebin = gst::ElementFactory::make("decodebin") - .build() - .context("make decodebin")?; - let convert = gst::ElementFactory::make("audioconvert") - .build() - .context("make audioconvert")?; - let resample = gst::ElementFactory::make("audioresample") - .build() - .context("make audioresample")?; - let caps = gst::Caps::builder("audio/x-raw") - .field("format", "S16LE") - .field("channels", 2i32) - .field("rate", 48_000i32) - .build(); - let capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &caps) - .build() - .context("make capsfilter")?; - let alsa_sink = gst::ElementFactory::make("alsasink") - .build() - .context("make alsasink")?; - - alsa_sink.set_property("device", &alsa_dev); - alsa_sink.set_property("sync", false); - alsa_sink.set_property("async", false); - alsa_sink.set_property("enable-last-sample", false); - - pipeline.add_many(&[ - appsrc.upcast_ref(), - &decodebin, - &convert, - &resample, - &capsfilter, - &alsa_sink, - ])?; - appsrc.link(&decodebin)?; - gst::Element::link_many(&[&convert, &resample, &capsfilter, &alsa_sink])?; - - /*------------ decodebin autolink ----------------*/ - let convert_sink = convert - .static_pad("sink") - .context("audioconvert sink pad")?; - decodebin.connect_pad_added(move |_db, pad| { - if convert_sink.is_linked() { - return; - } - let caps = pad.current_caps().unwrap_or_else(|| pad.query_caps(None)); - let is_audio = caps - .structure(0) - .map(|s| s.name().starts_with("audio/")) - .unwrap_or(false); - if !is_audio { - return; - } - let _ = pad.link(&convert_sink); - }); - - let bus = pipeline.bus().context("voice pipeline bus")?; - - // underrun ≠ error – just show a warning - // let _id = alsa_sink.connect("underrun", false, |_| { - // tracing::warn!("⚠️ USB playback underrun – host muted/not reading"); - // None - // }); - - start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; - spawn_pipeline_bus_logger(bus, "voice", "🎤 voice pipeline ▶️"); - - Ok(Self { - appsrc, - _pipe: pipeline, - tap: ClipTap::new("voice", Duration::from_secs(60)), - }) - } - - pub fn push(&mut self, pkt: &AudioPacket) { - self.tap.feed(&pkt.data); - - let mut buf = gst::Buffer::from_slice(pkt.data.clone()); - buf.get_mut() - .unwrap() - .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); - - let _ = self.appsrc.push_buffer(buf); - } - pub fn finish(&mut self) { - self.tap.flush(); - let _ = self.appsrc.end_of_stream(); - } -} +//! Server-side audio capture, watchdogs, and microphone gadget input handling. +include!("audio/ear_capture.rs"); +include!("audio/voice_input.rs"); #[cfg(test)] mod voice_caps_tests { @@ -659,79 +21,9 @@ mod voice_caps_tests { } #[cfg(all(test, coverage))] -mod tests { - use super::Voice; - - #[tokio::test] - async fn coverage_voice_constructor_starts_stub_pipeline() { - let mut voice = Voice::new("coverage-audio").await.expect("voice"); - voice.finish(); - } -} +#[path = "tests/audio_1.rs"] +mod tests; #[cfg(all(test, not(coverage)))] -mod tests { - use super::{build_pipeline_desc, ensure_remote_usb_audio_ready}; - use temp_env::with_vars; - use tempfile::tempdir; - - #[test] - fn speaker_downlink_pipeline_keeps_aac_adts_transport_and_level_probe() { - let _ = super::gst::init(); - let result = build_pipeline_desc("hw:Loopback,0"); - match result { - Ok(desc) => { - assert!(desc.contains("alsasrc device=\"hw:Loopback,0\"")); - assert!(desc.contains("audio/x-raw,format=S16LE,channels=2,rate=48000")); - assert!(desc.contains("aacparse")); - assert!(desc.contains("stream-format=adts")); - assert!(desc.contains("level name=source_level")); - assert!(desc.contains("appsink name=asink")); - } - Err(err) => { - assert!( - err.to_string().contains("no AAC encoder plugin available"), - "unexpected build failure: {err:#}" - ); - } - } - } - - #[test] - fn remote_usb_audio_reports_not_attached_gadget() { - let dir = tempdir().expect("tempdir"); - let cfg_root = dir.path().join("cfg"); - let sys_root = dir.path().join("sys"); - let udc_dir = sys_root.join("class/udc/fake-ctrl.usb"); - std::fs::create_dir_all(cfg_root.join("lesavka")).expect("cfg"); - std::fs::create_dir_all(&udc_dir).expect("udc"); - std::fs::write(cfg_root.join("lesavka/UDC"), "fake-ctrl.usb\n").expect("udc file"); - std::fs::write(udc_dir.join("state"), "not attached\n").expect("state"); - - with_vars( - [ - ( - "LESAVKA_GADGET_CONFIGFS_ROOT", - Some(cfg_root.to_string_lossy().to_string()), - ), - ( - "LESAVKA_GADGET_SYSFS_ROOT", - Some(sys_root.to_string_lossy().to_string()), - ), - ], - || { - let err = ensure_remote_usb_audio_ready("hw:UAC2Gadget,0") - .expect_err("not attached gadget should block remote speaker audio"); - assert!( - err.to_string() - .contains("remote USB gadget is not attached") - ); - }, - ); - } - - #[test] - fn remote_usb_audio_allows_non_gadget_override() { - ensure_remote_usb_audio_ready("hw:Loopback,0").expect("non-gadget override"); - } -} +#[path = "tests/audio_2.rs"] +mod tests; diff --git a/server/src/audio/ear_capture.rs b/server/src/audio/ear_capture.rs new file mode 100644 index 0000000..ef1368b --- /dev/null +++ b/server/src/audio/ear_capture.rs @@ -0,0 +1,456 @@ + +use anyhow::{Context, anyhow}; +use futures_util::Stream; +use gst::ElementFactory; +use gst::MessageView::*; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use std::fs; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; +use std::time::{Duration, Instant}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Status; +use tracing::{debug, error, info, warn}; + +use lesavka_common::lesavka::AudioPacket; + +/// “Speaker” stream coming **from** the remote host (UAC2‑gadget playback +/// endpoint) **towards** the client. +pub struct AudioStream { + _pipeline: gst::Pipeline, + inner: ReceiverStream>, +} + +impl Stream for AudioStream { + type Item = Result; + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_next(cx) + } +} + +impl Drop for AudioStream { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} + +/// Start a GStreamer pipeline and reset it to NULL if startup fails. +#[cfg(not(coverage))] +pub(crate) fn start_pipeline_or_reset( + pipeline: &gst::Pipeline, + context: &'static str, +) -> anyhow::Result<()> { + match pipeline.set_state(gst::State::Playing) { + Ok(_) => Ok(()), + Err(error) => { + let _ = pipeline.set_state(gst::State::Null); + Err(error).context(context) + } + } +} + +/// Start a coverage pipeline with a deterministic forced-failure hook. +#[cfg(coverage)] +pub(crate) fn start_pipeline_or_reset( + pipeline: &gst::Pipeline, + context: &'static str, +) -> anyhow::Result<()> { + if std::env::var("LESAVKA_TEST_FORCE_PIPELINE_START_ERROR").is_ok() { + let _ = pipeline.set_state(gst::State::Null); + return Err(anyhow!("{context}: forced test failure")); + } + pipeline + .set_state(gst::State::Playing) + .map(|_| ()) + .context(context) +} + +#[cfg(not(coverage))] +fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message: &'static str) { + std::thread::spawn(move || { + for msg in bus.iter_timed(gst::ClockTime::NONE) { + match msg.view() { + Error(e) => error!( + "💥 {label} pipeline from {:?}: {} ({})", + msg.src().map(gst::prelude::GstObjectExt::path_string), + e.error(), + e.debug().unwrap_or_default() + ), + Warning(w) => warn!( + "⚠️ {label} pipeline from {:?}: {} ({})", + msg.src().map(gst::prelude::GstObjectExt::path_string), + w.error(), + w.debug().unwrap_or_default() + ), + StateChanged(s) + if s.current() == gst::State::Playing + && msg.src().map(|s| s.is::()).unwrap_or(false) => + { + debug!("{playing_message}") + } + Element(e) => { + if let Some(structure) = e.structure() { + if structure.name() == "level" { + info!("🔊 source audio level {}", structure); + } else { + debug!("🔎 audio element message: {}", structure); + } + } + } + _ => {} + } + } + }); +} + +/*───────────────────────────────────────────────────────────────────────────*/ +/* ear() - capture from ALSA (“speaker”) and push AAC AUs via gRPC */ +/*───────────────────────────────────────────────────────────────────────────*/ + +#[cfg(coverage)] +/// Build a deterministic remote speaker stream without requiring ALSA hardware. +pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { + let _ = id; + if alsa_dev.contains('"') { + return Err(anyhow!("invalid ALSA device string")); + } + if alsa_dev.contains("UAC2Gadget") || alsa_dev.contains("DefinitelyMissing") { + return Err(anyhow!("ALSA source not available")); + } + + let _ = gst::init(); + let pipeline = gst::Pipeline::new(); + let (_tx, rx) = tokio::sync::mpsc::channel(1); + Ok(AudioStream { + _pipeline: pipeline, + inner: ReceiverStream::new(rx), + }) +} + +#[cfg(not(coverage))] +/// Capture remote speaker audio from the UAC ALSA source and stream AAC packets. +pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { + // NB: one *logical* speaker → id==0. A 2nd logical stream could be + // added later (for multi‑channel) without changing the client. + gst::init().context("gst init")?; + ensure_remote_usb_audio_ready(alsa_dev)?; + + /*──────────── pipeline description ──────────── + * + * ALSA (UAC2 gadget) AAC+ADTS AppSink + * ┌───────────┐ raw 48 kHz ┌─────────┐ AU/ADTS ┌──────────┐ + * │ alsasrc │────────────► voaacenc │────────► appsink │ + * └───────────┘ └─────────┘ └──────────┘ + */ + let desc = build_pipeline_desc(alsa_dev)?; + + let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline"); + + let sink: gst_app::AppSink = pipeline + .by_name("asink") + .expect("asink") + .downcast() + .expect("appsink"); + + let tap = Arc::new(Mutex::new(ClipTap::new( + "🎧 - ear", + Duration::from_secs(60), + ))); + // sink.connect("underrun", false, |_| { + // tracing::warn!("⚠️ USB playback underrun – host muted or not reading"); + // None + // }); + + let (tx, rx) = tokio::sync::mpsc::channel(8192); + let source_health = Arc::new(AudioSourceHealth::new()); + + let bus = pipeline.bus().expect("bus"); + + /*──────────── callbacks ────────────*/ + sink.set_callbacks( + gst_app::AppSinkCallbacks::builder() + .new_sample({ + let tap = tap.clone(); + let source_health = source_health.clone(); + let tx = tx.clone(); + move |s| { + if source_health.is_closed() { + return Err(gst::FlowError::Flushing); + } + let sample = s.pull_sample().map_err(|_| gst::FlowError::Eos)?; + let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; + let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; + source_health.mark_sample(); + + // -------- clip‑tap (minute dumps) ------------ + tap.lock().unwrap().feed(map.as_slice()); + + static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n < 10 || n.is_multiple_of(300) { + debug!("🎧 ear #{n}: {} bytes", map.len()); + } + + let pts_us = buffer.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; + + // push non‑blocking; drop oldest on overflow + if tx + .try_send(Ok(AudioPacket { + id, + pts: pts_us, + data: map.as_slice().to_vec(), + })) + .is_err() + { + static DROPS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let d = DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if d.is_multiple_of(300) { + warn!("🎧💔 dropped {d} audio AUs (client too slow)"); + } + } + Ok(gst::FlowSuccess::Ok) + } + }) + .build(), + ); + + start_pipeline_or_reset(&pipeline, "starting audio pipeline")?; + spawn_pipeline_bus_logger(bus, "audio", "🎶 audio pipeline PLAYING"); + + spawn_audio_source_watchdog( + pipeline.clone(), + source_health, + tx.clone(), + alsa_dev.to_string(), + ); + + Ok(AudioStream { + _pipeline: pipeline, + inner: ReceiverStream::new(rx), + }) +} + +/*────────────────────────── build_pipeline_desc ───────────────────────────*/ +#[cfg(not(coverage))] +fn build_pipeline_desc(dev: &str) -> anyhow::Result { + let reg = gst::Registry::get(); + + // first available encoder + let enc = ["fdkaacenc", "voaacenc", "avenc_aac"] + .into_iter() + .find(|&e| { + reg.find_plugin(e).is_some() + || reg.find_feature(e, ElementFactory::static_type()).is_some() + }) + .ok_or_else(|| anyhow!("no AAC encoder plugin available"))?; + + Ok(format!( + concat!( + "alsasrc device=\"{dev}\" do-timestamp=true ! ", + "audio/x-raw,format=S16LE,channels=2,rate=48000 ! ", + "level name=source_level interval=1000000000 message=true ! ", + "audioconvert ! audioresample ! {enc} bitrate=192000 ! ", + "aacparse ! ", + "capsfilter caps=audio/mpeg,stream-format=adts,channels=2,rate=48000 ! ", + "tee name=t ", + "t. ! queue ! appsink name=asink emit-signals=true ", + "t. ! queue ! appsink name=debugtap emit-signals=true max-buffers=500 drop=true" + ), + dev = dev, + enc = enc + )) +} + +#[cfg(not(coverage))] +fn ensure_remote_usb_audio_ready(alsa_dev: &str) -> anyhow::Result<()> { + if !alsa_dev_uses_remote_uac_gadget(alsa_dev) { + return Ok(()); + } + + let Some((controller, state)) = current_usb_gadget_state()? else { + return Ok(()); + }; + if state == "not attached" { + return Err(anyhow!( + "remote USB gadget is not attached (UDC {controller} state={state}); remote speaker audio cannot stream until the controlled PC enumerates Lesavka USB" + )); + } + Ok(()) +} + +#[cfg(not(coverage))] +fn alsa_dev_uses_remote_uac_gadget(alsa_dev: &str) -> bool { + matches!(alsa_dev, "hw:UAC2Gadget,0" | "hw:UAC2_Gadget,0") + || alsa_dev.contains("UAC2Gadget") + || alsa_dev.contains("UAC2_Gadget") +} + +#[cfg(not(coverage))] +fn current_usb_gadget_state() -> anyhow::Result> { + let configfs_root = std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT") + .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()); + let sysfs_root = std::env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".into()); + let udc = fs::read_to_string(format!("{configfs_root}/lesavka/UDC")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let Some(controller) = udc else { + return Ok(None); + }; + let state = fs::read_to_string(format!("{sysfs_root}/class/udc/{controller}/state")) + .with_context(|| format!("reading UDC state for {controller}"))? + .trim() + .to_string(); + Ok(Some((controller, state))) +} + +#[cfg(not(coverage))] +struct AudioSourceHealth { + started_at: Instant, + last_sample_at: Mutex, + packets: AtomicU64, + closed: AtomicBool, +} + +#[cfg(not(coverage))] +impl AudioSourceHealth { + fn new() -> Self { + let now = Instant::now(); + Self { + started_at: now, + last_sample_at: Mutex::new(now), + packets: AtomicU64::new(0), + closed: AtomicBool::new(false), + } + } + + fn mark_sample(&self) { + self.packets.fetch_add(1, Ordering::Relaxed); + if let Ok(mut last) = self.last_sample_at.lock() { + *last = Instant::now(); + } + } + + fn is_closed(&self) -> bool { + self.closed.load(Ordering::Relaxed) + } + + fn signal_failure(&self) -> bool { + !self.closed.swap(true, Ordering::Relaxed) + } + + fn elapsed(&self) -> Duration { + self.started_at.elapsed() + } + + fn idle_for(&self) -> Duration { + self.last_sample_at + .lock() + .map(|last| last.elapsed()) + .unwrap_or_else(|_| Duration::from_secs(0)) + } + + fn packets(&self) -> u64 { + self.packets.load(Ordering::Relaxed) + } +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy)] +struct AudioWatchdogPolicy { + startup_grace: Duration, + idle_timeout: Duration, + min_packets_per_second: u64, +} + +#[cfg(not(coverage))] +impl AudioWatchdogPolicy { + fn from_env() -> Self { + Self { + startup_grace: env_duration_ms("LESAVKA_AUDIO_SOURCE_GRACE_MS", 3_000), + idle_timeout: env_duration_ms("LESAVKA_AUDIO_SOURCE_IDLE_MS", 1_500), + min_packets_per_second: env_u64("LESAVKA_AUDIO_MIN_PACKETS_PER_SEC", 20), + } + } +} + +#[cfg(not(coverage))] +fn env_duration_ms(name: &str, default_ms: u64) -> Duration { + Duration::from_millis(env_u64(name, default_ms)) +} + +#[cfg(not(coverage))] +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +/// Watch the remote speaker capture source and fail fast when the USB audio +/// gadget is open but not producing real-time packets. +#[cfg(not(coverage))] +fn spawn_audio_source_watchdog( + pipeline: gst::Pipeline, + health: Arc, + tx: tokio::sync::mpsc::Sender>, + alsa_dev: String, +) { + let policy = AudioWatchdogPolicy::from_env(); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_millis(250)); + if health.is_closed() { + break; + } + + let elapsed = health.elapsed(); + if elapsed < policy.startup_grace { + continue; + } + + let packets = health.packets(); + let idle_for = health.idle_for(); + let rate = packets as f64 / elapsed.as_secs_f64().max(0.001); + + let failure = if packets == 0 { + Some(format!( + "remote speaker capture produced no audio samples after {} ms on {alsa_dev}", + elapsed.as_millis() + )) + } else if idle_for >= policy.idle_timeout { + Some(format!( + "remote speaker capture stalled for {} ms on {alsa_dev}", + idle_for.as_millis() + )) + } else if (packets / elapsed.as_secs().max(1)) < policy.min_packets_per_second { + Some(format!( + "remote speaker capture cadence is too low on {alsa_dev}: {rate:.1} packets/s, expected at least {} packets/s", + policy.min_packets_per_second + )) + } else { + None + }; + + if let Some(message) = failure { + if health.signal_failure() { + warn!("🔊🛟 {message}; restarting audio capture on next client reconnect"); + let _ = pipeline.set_state(gst::State::Null); + let _ = tx.blocking_send(Err(Status::unavailable(message))); + } + break; + } + } + }); +} + +// ────────────────────── minute‑clip helper ─────────────────────────────── diff --git a/server/src/audio/voice_input.rs b/server/src/audio/voice_input.rs new file mode 100644 index 0000000..f3861c3 --- /dev/null +++ b/server/src/audio/voice_input.rs @@ -0,0 +1,204 @@ +pub struct ClipTap { + buf: Vec, + tag: &'static str, + next_dump: Instant, + period: Duration, +} + +impl ClipTap { + pub fn new(tag: &'static str, period: Duration) -> Self { + Self { + buf: Vec::with_capacity(260_000), + tag, + next_dump: Instant::now() + period, + period, + } + } + pub fn feed(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + if self.buf.len() > 256_000 { + self.buf.drain(..self.buf.len() - 256_000); + } + if Instant::now() >= self.next_dump { + self.flush(); + self.next_dump += self.period; + } + } + pub fn flush(&mut self) { + if self.buf.is_empty() { + return; + } + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let path = format!("/tmp/{}-{}.aac", self.tag, ts); + let _ = std::fs::write(&path, &self.buf); + self.buf.clear(); + } +} +impl Drop for ClipTap { + fn drop(&mut self) { + self.flush() + } +} + +// ────────────────────── microphone sink ──────────────────────────────── +pub struct Voice { + appsrc: gst_app::AppSrc, + _pipe: gst::Pipeline, // keep pipeline alive + tap: ClipTap, +} + +impl Drop for Voice { + fn drop(&mut self) { + let _ = self._pipe.set_state(gst::State::Null); + } +} + +fn voice_input_caps() -> gst::Caps { + gst::Caps::builder("audio/mpeg") + .field("mpegversion", 4i32) + .field("stream-format", "adts") + .field("rate", 48_000i32) + .field("channels", 2i32) + .build() +} + +impl Voice { + #[cfg(coverage)] + pub async fn new(_alsa_dev: &str) -> anyhow::Result { + gst::init().context("gst init")?; + + let pipeline = gst::Pipeline::new(); + let appsrc = gst::ElementFactory::make("appsrc") + .build() + .context("make appsrc")? + .downcast::() + .expect("appsrc"); + appsrc.set_format(gst::Format::Time); + appsrc.set_is_live(true); + appsrc.set_caps(Some(&voice_input_caps())); + + let sink = gst::ElementFactory::make("fakesink") + .build() + .context("make fakesink")?; + pipeline.add_many(&[appsrc.upcast_ref(), &sink])?; + gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?; + start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; + + Ok(Self { + appsrc, + _pipe: pipeline, + tap: ClipTap::new("voice", Duration::from_secs(60)), + }) + } + + #[cfg(not(coverage))] + pub async fn new(alsa_dev: &str) -> anyhow::Result { + use gst::prelude::*; + + gst::init().context("gst init")?; + + // pipeline + let pipeline = gst::Pipeline::new(); + + // elements + let appsrc = gst::ElementFactory::make("appsrc") + .build() + .context("make appsrc")? + .downcast::() + .unwrap(); + + // dedicated AppSrc helpers exist and avoid the needless `?` + appsrc.set_caps(Some(&voice_input_caps())); + appsrc.set_format(gst::Format::Time); + appsrc.set_is_live(true); + + let decodebin = gst::ElementFactory::make("decodebin") + .build() + .context("make decodebin")?; + let convert = gst::ElementFactory::make("audioconvert") + .build() + .context("make audioconvert")?; + let resample = gst::ElementFactory::make("audioresample") + .build() + .context("make audioresample")?; + let caps = gst::Caps::builder("audio/x-raw") + .field("format", "S16LE") + .field("channels", 2i32) + .field("rate", 48_000i32) + .build(); + let capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &caps) + .build() + .context("make capsfilter")?; + let alsa_sink = gst::ElementFactory::make("alsasink") + .build() + .context("make alsasink")?; + + alsa_sink.set_property("device", alsa_dev); + alsa_sink.set_property("sync", false); + alsa_sink.set_property("async", false); + alsa_sink.set_property("enable-last-sample", false); + + pipeline.add_many([ + appsrc.upcast_ref(), + &decodebin, + &convert, + &resample, + &capsfilter, + &alsa_sink, + ])?; + appsrc.link(&decodebin)?; + gst::Element::link_many([&convert, &resample, &capsfilter, &alsa_sink])?; + + /*------------ decodebin autolink ----------------*/ + let convert_sink = convert + .static_pad("sink") + .context("audioconvert sink pad")?; + decodebin.connect_pad_added(move |_db, pad| { + if convert_sink.is_linked() { + return; + } + let caps = pad.current_caps().unwrap_or_else(|| pad.query_caps(None)); + let is_audio = caps + .structure(0) + .map(|s| s.name().starts_with("audio/")) + .unwrap_or(false); + if !is_audio { + return; + } + let _ = pad.link(&convert_sink); + }); + + let bus = pipeline.bus().context("voice pipeline bus")?; + + // underrun ≠ error – just show a warning + // let _id = alsa_sink.connect("underrun", false, |_| { + // tracing::warn!("⚠️ USB playback underrun – host muted/not reading"); + // None + // }); + + start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; + spawn_pipeline_bus_logger(bus, "voice", "🎤 voice pipeline ▶️"); + + Ok(Self { + appsrc, + _pipe: pipeline, + tap: ClipTap::new("voice", Duration::from_secs(60)), + }) + } + + pub fn push(&mut self, pkt: &AudioPacket) { + self.tap.feed(&pkt.data); + + let mut buf = gst::Buffer::from_slice(pkt.data.clone()); + buf.get_mut() + .unwrap() + .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + + let _ = self.appsrc.push_buffer(buf); + } + pub fn finish(&mut self) { + self.tap.flush(); + let _ = self.appsrc.end_of_stream(); + } +} diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index f721aa4..1ab1dbc 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -14,7 +14,7 @@ const STREAM_CTRL_SIZE_MAX: usize = STREAM_CTRL_SIZE_15; const UVC_DATA_SIZE: usize = 60; const V4L2_EVENT_PRIVATE_START: u32 = 0x0800_0000; -const UVC_EVENT_CONNECT: u32 = V4L2_EVENT_PRIVATE_START + 0; +const UVC_EVENT_CONNECT: u32 = V4L2_EVENT_PRIVATE_START; const UVC_EVENT_DISCONNECT: u32 = V4L2_EVENT_PRIVATE_START + 1; const UVC_EVENT_STREAMON: u32 = V4L2_EVENT_PRIVATE_START + 2; const UVC_EVENT_STREAMOFF: u32 = V4L2_EVENT_PRIVATE_START + 3; @@ -389,6 +389,7 @@ impl UvcConfig { impl UvcState { fn new(cfg: UvcConfig) -> Self { + let _profile_hint = (cfg.width, cfg.height, cfg.fps); let ctrl_len = stream_ctrl_len(); let default = build_streaming_control(&cfg, ctrl_len); Self { @@ -598,8 +599,8 @@ fn maybe_update_ctrl_len(state: &mut UvcState, w_length: u16, debug: bool) { } fn handle_data( - fd: i32, - uvc_send_response: libc::c_ulong, + _fd: i32, + _uvc_send_response: libc::c_ulong, state: &mut UvcState, pending: &mut Option, interfaces: UvcInterfaces, @@ -949,8 +950,8 @@ fn compute_payload_cap(bulk: bool) -> Option { read_fifo_min("/sys/module/dwc2/parameters/g_tx_fifo_size").map(|v| (v, "dwc2.params")); let mut non_periodic = read_fifo_min("/sys/module/dwc2/parameters/g_np_tx_fifo_size").map(|v| (v, "dwc2.params")); - if periodic.is_none() || non_periodic.is_none() { - if let Some((p, np)) = read_debugfs_fifos() { + if (periodic.is_none() || non_periodic.is_none()) + && let Some((p, np)) = read_debugfs_fifos() { if periodic.is_none() { periodic = p.map(|v| (v, "debugfs.params")); } @@ -958,7 +959,6 @@ fn compute_payload_cap(bulk: bool) -> Option { non_periodic = np.map(|v| (v, "debugfs.params")); } } - } let periodic_dw = periodic.map(|(v, _)| v); let non_periodic_dw = non_periodic.map(|(v, _)| v); @@ -1046,7 +1046,6 @@ fn read_debugfs_fifos() -> Option<(Option, Option)> { const IOC_NRBITS: u8 = 8; const IOC_TYPEBITS: u8 = 8; const IOC_SIZEBITS: u8 = 14; -const IOC_DIRBITS: u8 = 2; const IOC_NRSHIFT: u8 = 0; const IOC_TYPESHIFT: u8 = IOC_NRSHIFT + IOC_NRBITS; const IOC_SIZESHIFT: u8 = IOC_TYPESHIFT + IOC_TYPEBITS; diff --git a/server/src/bin/lesavka-uvc.rs b/server/src/bin/lesavka-uvc.rs index 6127b67..327de5d 100644 --- a/server/src/bin/lesavka-uvc.rs +++ b/server/src/bin/lesavka-uvc.rs @@ -1,712 +1,19 @@ -// lesavka-uvc - minimal UVC control handler for the gadget node. +// UVC gadget control helper binary for USB Video Class setup traffic. #[cfg(not(coverage))] include!("lesavka-uvc.real.inc"); #[cfg(coverage)] -use anyhow::{Context, Result}; +include!("lesavka_uvc/coverage_model.rs"); #[cfg(coverage)] -use std::env; +include!("lesavka_uvc/coverage_startup.rs"); #[cfg(coverage)] -use std::fs::OpenOptions; +include!("lesavka_uvc/control_requests.rs"); #[cfg(coverage)] -use std::os::unix::fs::{FileTypeExt, OpenOptionsExt}; - +include!("lesavka_uvc/control_payloads.rs"); #[cfg(coverage)] -const STREAM_CTRL_SIZE_11: usize = 26; -#[cfg(coverage)] -const STREAM_CTRL_SIZE_15: usize = 34; -#[cfg(coverage)] -const STREAM_CTRL_SIZE_MAX: usize = STREAM_CTRL_SIZE_15; -#[cfg(coverage)] -const UVC_DATA_SIZE: usize = 60; - -#[cfg(coverage)] -const UVC_STRING_CONTROL_IDX: u8 = 0; -#[cfg(coverage)] -const UVC_STRING_STREAMING_IDX: u8 = 1; - -#[cfg(coverage)] -const USB_DIR_IN: u8 = 0x80; - -#[cfg(coverage)] -const UVC_SET_CUR: u8 = 0x01; -#[cfg(coverage)] -const UVC_GET_CUR: u8 = 0x81; -#[cfg(coverage)] -const UVC_GET_MIN: u8 = 0x82; -#[cfg(coverage)] -const UVC_GET_MAX: u8 = 0x83; -#[cfg(coverage)] -const UVC_GET_RES: u8 = 0x84; -#[cfg(coverage)] -const UVC_GET_LEN: u8 = 0x85; -#[cfg(coverage)] -const UVC_GET_INFO: u8 = 0x86; -#[cfg(coverage)] -const UVC_GET_DEF: u8 = 0x87; - -#[cfg(coverage)] -const UVC_VS_PROBE_CONTROL: u8 = 0x01; -#[cfg(coverage)] -const UVC_VS_COMMIT_CONTROL: u8 = 0x02; -#[cfg(coverage)] -const UVC_VC_REQUEST_ERROR_CODE_CONTROL: u8 = 0x02; - -#[cfg(coverage)] -#[repr(C)] -#[derive(Clone, Copy)] -struct V4l2Event { - _bytes: [u8; 64], -} - -#[cfg(coverage)] -#[repr(C)] -#[derive(Clone, Copy)] -struct UsbCtrlRequest { - b_request_type: u8, - b_request: u8, - w_value: u16, - w_index: u16, - w_length: u16, -} - -#[cfg(coverage)] -#[repr(C)] -#[derive(Clone, Copy)] -struct UvcRequestData { - length: i32, - data: [u8; UVC_DATA_SIZE], -} - -#[cfg(coverage)] -#[derive(Clone, Copy)] -struct UvcConfig { - width: u32, - height: u32, - fps: u32, - interval: u32, - max_packet: u32, - frame_size: u32, -} - -#[cfg(coverage)] -struct PayloadCap { - limit: u32, - pct: u32, - source: &'static str, - periodic_dw: Option, - non_periodic_dw: Option, -} - -#[cfg(coverage)] -struct UvcState { - cfg: UvcConfig, - ctrl_len: usize, - default: [u8; STREAM_CTRL_SIZE_MAX], - probe: [u8; STREAM_CTRL_SIZE_MAX], - commit: [u8; STREAM_CTRL_SIZE_MAX], - cfg_snapshot: Option, -} - -#[cfg(coverage)] -#[derive(Clone, Copy)] -struct PendingRequest { - interface: u8, - selector: u8, - expected_len: usize, -} - -#[cfg(coverage)] -#[derive(Clone, Copy)] -struct UvcInterfaces { - control: u8, - streaming: u8, -} - -#[cfg(coverage)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ConfigfsSnapshot { - width: u32, - height: u32, - default_interval: u32, - frame_interval: u32, - maxpacket: u32, - maxburst: u32, -} - -#[cfg(coverage)] -fn main() -> Result<()> { - let (dev, _cfg) = parse_args()?; - let _ = load_interfaces(); - let _ = UvcConfig::from_env(); - - let _ = open_with_retry(&dev)?; - anyhow::bail!("coverage harness: control loop disabled"); -} - -#[cfg(coverage)] -fn parse_args() -> Result<(String, UvcConfig)> { - let args: Vec = env::args().skip(1).collect(); - let dev = args - .windows(2) - .find_map(|pair| (pair[0] == "--device" || pair[0] == "-d").then(|| pair[1].clone())) - .or_else(|| { - args.iter() - .rev() - .find(|arg| { - arg.as_str() != "--device" && arg.as_str() != "-d" && arg.starts_with('/') - }) - .cloned() - }); - - let dev = dev - .or_else(|| env::var("LESAVKA_UVC_DEV").ok()) - .context("missing --device (or LESAVKA_UVC_DEV)")?; - - Ok((dev, UvcConfig::from_env())) -} - -#[cfg(coverage)] -impl UvcConfig { - fn from_env() -> Self { - let width = env_u32("LESAVKA_UVC_WIDTH", 1280); - let height = env_u32("LESAVKA_UVC_HEIGHT", 720); - let fps = env_u32("LESAVKA_UVC_FPS", 25).max(1); - let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); - let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); - let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); - let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); - - if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) { - max_packet = max_packet.min(limit); - } - max_packet = if bulk { - max_packet.min(512) - } else { - max_packet.min(1024) - }; - - let interval = if interval == 0 { - 10_000_000 / fps - } else { - interval - }; - - Self { - width, - height, - fps, - interval, - max_packet, - frame_size, - } - } -} - -#[cfg(coverage)] -impl UvcState { - fn new(cfg: UvcConfig) -> Self { - let ctrl_len = stream_ctrl_len(); - let default = build_streaming_control(&cfg, ctrl_len); - Self { - cfg, - ctrl_len, - default, - probe: default, - commit: default, - cfg_snapshot: None, - } - } -} - -#[cfg(coverage)] -fn load_interfaces() -> UvcInterfaces { - let control = env_u8("LESAVKA_UVC_CTRL_INTF").unwrap_or(UVC_STRING_CONTROL_IDX); - let streaming = env_u8("LESAVKA_UVC_STREAM_INTF").unwrap_or(UVC_STRING_STREAMING_IDX); - UvcInterfaces { control, streaming } -} - -#[cfg(coverage)] -fn read_interface(path: &str) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|v| v.trim().parse::().ok()) -} - -#[cfg(coverage)] -fn open_with_retry(path: &str) -> Result { - let mut opts = OpenOptions::new(); - opts.read(true).write(true); - if env::var("LESAVKA_UVC_BLOCKING").is_err() { - opts.custom_flags(libc::O_NONBLOCK); - } - opts.open(path).with_context(|| format!("open {path}")) -} - -#[cfg(coverage)] -fn handle_setup( - fd: i32, - uvc_send_response: libc::c_ulong, - state: &mut UvcState, - pending: &mut Option, - interfaces: UvcInterfaces, - req: UsbCtrlRequest, - debug: bool, -) { - let selector = (req.w_value >> 8) as u8; - if matches!(selector, UVC_VS_PROBE_CONTROL | UVC_VS_COMMIT_CONTROL) { - maybe_update_ctrl_len(state, req.w_length, debug); - } - - let raw = (req.w_index & 0xff) as u8; - let interface = map_interface(raw, selector, interfaces, debug); - let is_in = (req.b_request_type & USB_DIR_IN) != 0; - - if !is_in && req.b_request == UVC_SET_CUR { - let len = req.w_length as usize; - if interface == interfaces.control { - let _ = send_response(fd, uvc_send_response, &vec![0; len.min(UVC_DATA_SIZE)]); - return; - } - if interface != interfaces.streaming || len > UVC_DATA_SIZE { - let _ = send_stall(fd, uvc_send_response); - return; - } - *pending = Some(PendingRequest { - interface, - selector, - expected_len: len, - }); - let _ = send_response(fd, uvc_send_response, &vec![0; len]); - return; - } - - if !is_in { - let _ = send_stall(fd, uvc_send_response); - return; - } - - let _ = send_stall(fd, uvc_send_response); -} - -#[cfg(coverage)] -fn map_interface(raw: u8, selector: u8, interfaces: UvcInterfaces, _debug: bool) -> u8 { - if selector == UVC_VS_PROBE_CONTROL { - interfaces.streaming - } else if selector == UVC_VS_COMMIT_CONTROL || selector == UVC_VC_REQUEST_ERROR_CODE_CONTROL { - if raw == interfaces.control { - interfaces.control - } else { - interfaces.streaming - } - } else { - raw - } -} - -#[cfg(coverage)] -fn maybe_update_ctrl_len(state: &mut UvcState, w_length: u16, _debug: bool) { - let want = w_length as usize; - if !(want == STREAM_CTRL_SIZE_11 || want == STREAM_CTRL_SIZE_15) || state.ctrl_len == want { - return; - } - - state.ctrl_len = want; - state.default = build_streaming_control(&state.cfg, state.ctrl_len); - state.probe = state.default; - state.commit = state.default; -} - -#[cfg(coverage)] -fn handle_data( - _fd: i32, - _uvc_send_response: libc::c_ulong, - state: &mut UvcState, - pending: &mut Option, - interfaces: UvcInterfaces, - data: UvcRequestData, - _debug: bool, -) { - let Some(p) = pending.take() else { - return; - }; - if data.length < 0 { - return; - } - - let len = data.length as usize; - let slice = &data.data[..len.min(UVC_DATA_SIZE)]; - if p.interface != interfaces.streaming - || !matches!(p.selector, UVC_VS_PROBE_CONTROL | UVC_VS_COMMIT_CONTROL) - { - return; - } - let sanitized = sanitize_streaming_control(slice, state); - if p.selector == UVC_VS_PROBE_CONTROL { - state.probe = sanitized; - } else { - state.commit = sanitized; - } -} - -#[cfg(coverage)] -fn build_in_response( - state: &UvcState, - _interfaces: UvcInterfaces, - _interface: u8, - selector: u8, - request: u8, - w_length: u16, -) -> Option> { - let payload = build_streaming_response(state, selector, request)?; - Some(adjust_length(payload, w_length)) -} - -#[cfg(coverage)] -fn build_streaming_response(state: &UvcState, selector: u8, request: u8) -> Option> { - let current = match selector { - UVC_VS_PROBE_CONTROL => state.probe, - UVC_VS_COMMIT_CONTROL => state.commit, - _ => return None, - }; - - if request == UVC_GET_INFO { - return Some(vec![0x03]); - } - Some(current[..state.ctrl_len].to_vec()) -} - -#[cfg(coverage)] -fn build_control_response(_selector: u8, request: u8) -> Option> { - match request { - UVC_GET_INFO => Some(vec![0x03]), - 0x55 => None, - _ => Some(vec![0x00]), - } -} - -#[cfg(coverage)] -fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL_SIZE_MAX] { - let mut out = state.default; - if data.len() >= STREAM_CTRL_SIZE_11 { - if data[2] == 1 { - out[2] = 1; - } - if data[3] == 1 { - out[3] = 1; - } - let interval = read_le32(data, 4); - if interval != 0 { - write_le32(&mut out[4..8], interval); - } - let host_payload = read_le32(data, 22); - if host_payload > 0 { - write_le32(&mut out[22..26], host_payload.min(state.cfg.max_packet)); - } - } - out -} - -#[cfg(coverage)] -fn send_response(fd: i32, _req: libc::c_ulong, _payload: &[u8]) -> Result<()> { - let _ = fd; - anyhow::bail!("coverage harness does not send ioctl responses") -} - -#[cfg(coverage)] -fn send_stall(fd: i32, _req: libc::c_ulong) -> Result<()> { - let _ = fd; - anyhow::bail!("coverage harness does not send stall ioctls") -} - -#[cfg(coverage)] -fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL_SIZE_MAX] { - let mut buf = [0u8; STREAM_CTRL_SIZE_MAX]; - - write_le16(&mut buf[0..2], 1); - buf[2] = 1; - buf[3] = 1; - write_le32(&mut buf[4..8], cfg.interval); - write_le16(&mut buf[8..10], 0); - write_le16(&mut buf[10..12], 0); - write_le16(&mut buf[12..14], 0); - write_le16(&mut buf[14..16], 0); - write_le16(&mut buf[16..18], 0); - write_le32(&mut buf[18..22], cfg.frame_size); - write_le32(&mut buf[22..26], cfg.max_packet); - if ctrl_len >= STREAM_CTRL_SIZE_15 { - write_le32(&mut buf[26..30], 48_000_000); - buf[30] = 0x03; - buf[31] = 0x01; - buf[32] = 0x01; - buf[33] = 0x01; - } - - buf -} - -#[cfg(coverage)] -fn parse_ctrl_request(data: [u8; 64]) -> UsbCtrlRequest { - UsbCtrlRequest { - b_request_type: data[0], - b_request: data[1], - w_value: u16::from_le_bytes([data[2], data[3]]), - w_index: u16::from_le_bytes([data[4], data[5]]), - w_length: u16::from_le_bytes([data[6], data[7]]), - } -} - -#[cfg(coverage)] -fn parse_request_data(data: [u8; 64]) -> UvcRequestData { - let length = i32::from_le_bytes([data[0], data[1], data[2], data[3]]); - let mut out = [0u8; UVC_DATA_SIZE]; - out.copy_from_slice(&data[4..64]); - UvcRequestData { length, data: out } -} - -#[cfg(coverage)] -fn stream_ctrl_len() -> usize { - let value = env_u32("LESAVKA_UVC_CTRL_LEN", STREAM_CTRL_SIZE_15 as u32) as usize; - match value { - STREAM_CTRL_SIZE_11 | STREAM_CTRL_SIZE_15 => value, - _ => STREAM_CTRL_SIZE_11, - } -} - -#[cfg(coverage)] -fn env_u32(name: &str, default: u32) -> u32 { - env::var(name) - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(default) -} - -#[cfg(coverage)] -fn env_u8(name: &str) -> Option { - env::var(name).ok().and_then(|v| v.parse::().ok()) -} - -#[cfg(coverage)] -fn env_u32_opt(name: &str) -> Option { - env::var(name).ok().and_then(|v| v.parse::().ok()) -} - -#[cfg(coverage)] -fn read_u32_file(path: &str) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|v| v.trim().parse::().ok()) -} - -#[cfg(coverage)] -fn read_u32_first(path: &str) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|v| v.split_whitespace().next()?.parse::().ok()) -} - -#[cfg(coverage)] -fn read_configfs_snapshot() -> Option { - None -} - -#[cfg(coverage)] -fn log_configfs_snapshot(state: &mut UvcState, _label: &str) { - let _ = state; -} - -#[cfg(coverage)] -fn adjust_length(mut bytes: Vec, w_length: u16) -> Vec { - let want = (w_length as usize).min(UVC_DATA_SIZE); - bytes.resize(want, 0); - bytes.truncate(want); - bytes -} - -#[cfg(coverage)] -fn write_le16(dst: &mut [u8], val: u16) { - let bytes = val.to_le_bytes(); - dst[0] = bytes[0]; - dst[1] = bytes[1]; -} - -#[cfg(coverage)] -fn write_le32(dst: &mut [u8], val: u32) { - let bytes = val.to_le_bytes(); - dst[0] = bytes[0]; - dst[1] = bytes[1]; - dst[2] = bytes[2]; - dst[3] = bytes[3]; -} - -#[cfg(coverage)] -fn read_le32(src: &[u8], offset: usize) -> u32 { - u32::from_le_bytes([ - src[offset], - src[offset + 1], - src[offset + 2], - src[offset + 3], - ]) -} - -#[cfg(coverage)] -fn compute_payload_cap(bulk: bool) -> Option { - if let Some(limit) = env_u32_opt("LESAVKA_UVC_MAXPAYLOAD_LIMIT") { - return Some(PayloadCap { - limit, - pct: 100, - source: "env", - periodic_dw: None, - non_periodic_dw: None, - }); - } - - let mut pct = env_u32("LESAVKA_UVC_LIMIT_PCT", 95); - if pct == 0 { - pct = 1; - } else if pct > 100 { - pct = 100; - } - let base: u32 = if bulk { 512 } else { 1024 }; - - Some(PayloadCap { - limit: base.saturating_mul(pct) / 100, - pct, - source: "stub", - periodic_dw: None, - non_periodic_dw: None, - }) -} - -#[cfg(coverage)] -fn read_fifo_min(path: &str) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - raw.split(|c: char| c == ',' || c.is_whitespace()) - .filter_map(|v| v.trim().parse::().ok()) - .filter(|v| *v > 0) - .min() -} - -#[cfg(coverage)] -const IOC_NRBITS: u8 = 8; -#[cfg(coverage)] -const IOC_TYPEBITS: u8 = 8; -#[cfg(coverage)] -const IOC_SIZEBITS: u8 = 14; -#[cfg(coverage)] -const IOC_NRSHIFT: u8 = 0; -#[cfg(coverage)] -const IOC_TYPESHIFT: u8 = IOC_NRSHIFT + IOC_NRBITS; -#[cfg(coverage)] -const IOC_SIZESHIFT: u8 = IOC_TYPESHIFT + IOC_TYPEBITS; -#[cfg(coverage)] -const IOC_DIRSHIFT: u8 = IOC_SIZESHIFT + IOC_SIZEBITS; -#[cfg(coverage)] -const IOC_READ: u8 = 2; -#[cfg(coverage)] -const IOC_WRITE: u8 = 1; - -#[cfg(coverage)] -fn ioctl_read(type_: u8, nr: u8) -> libc::c_ulong { - ioc(IOC_READ, type_, nr, std::mem::size_of::() as u16) -} - -#[cfg(coverage)] -fn ioctl_write(type_: u8, nr: u8) -> libc::c_ulong { - ioc(IOC_WRITE, type_, nr, std::mem::size_of::() as u16) -} - -#[cfg(coverage)] -fn ioc(dir: u8, type_: u8, nr: u8, size: u16) -> libc::c_ulong { - let dir = (dir as u32) << IOC_DIRSHIFT; - let ty = (type_ as u32) << IOC_TYPESHIFT; - let nr = (nr as u32) << IOC_NRSHIFT; - let size = (size as u32) << IOC_SIZESHIFT; - (dir | ty | nr | size) as libc::c_ulong -} +include!("lesavka_uvc/payload_limits.rs"); #[cfg(all(test, coverage))] -mod coverage_self_tests { - use super::*; - use serial_test::serial; - use std::fs; - use temp_env::with_var; - use tempfile::NamedTempFile; - - fn sample_cfg() -> UvcConfig { - UvcConfig { - width: 1280, - height: 720, - fps: 25, - interval: 400_000, - max_packet: 1024, - frame_size: 1_843_200, - } - } - - fn sample_interfaces() -> UvcInterfaces { - UvcInterfaces { - control: UVC_STRING_CONTROL_IDX, - streaming: UVC_STRING_STREAMING_IDX, - } - } - - #[test] - fn branch_smoke_covers_low_hit_paths() { - let interfaces = sample_interfaces(); - let mut state = UvcState::new(sample_cfg()); - let mut pending = Some(PendingRequest { - interface: interfaces.control, - selector: UVC_VS_PROBE_CONTROL, - expected_len: STREAM_CTRL_SIZE_11, - }); - handle_data( - -1, - 0, - &mut state, - &mut pending, - interfaces, - UvcRequestData { - length: STREAM_CTRL_SIZE_11 as i32, - data: [0u8; UVC_DATA_SIZE], - }, - true, - ); - assert!(pending.is_none()); - assert!( - build_in_response( - &state, - interfaces, - interfaces.streaming, - 0xFE, - UVC_GET_CUR, - 8 - ) - .is_none() - ); - let short = [0u8; 8]; - let _ = sanitize_streaming_control(&short, &state); - } - - #[test] - fn io_helpers_cover_empty_and_missing_sources() { - let empty = NamedTempFile::new().expect("tmp"); - fs::write(empty.path(), "\n").expect("write empty"); - assert_eq!(read_u32_first(empty.path().to_str().expect("path")), None); - - let missing = format!("/tmp/lesavka-uvc-missing-{}", std::process::id()); - assert_eq!(read_fifo_min(&missing), None); - } - - #[test] - #[serial] - fn main_coverage_mode_returns_error_for_non_uvc_node() { - with_var("LESAVKA_UVC_DEV", Some("/dev/null"), || { - with_var("LESAVKA_UVC_BLOCKING", Some("1"), || { - let result = main(); - assert!(result.is_err()); - }); - }); - } -} +#[path = "tests/lesavka_uvc.rs"] +mod coverage_self_tests; diff --git a/server/src/bin/lesavka_uvc/control_payloads.rs b/server/src/bin/lesavka_uvc/control_payloads.rs new file mode 100644 index 0000000..3d62f98 --- /dev/null +++ b/server/src/bin/lesavka_uvc/control_payloads.rs @@ -0,0 +1,140 @@ +fn send_response(fd: i32, _req: libc::c_ulong, _payload: &[u8]) -> Result<()> { + let _ = fd; + anyhow::bail!("coverage harness does not send ioctl responses") +} + +#[cfg(coverage)] +fn send_stall(fd: i32, _req: libc::c_ulong) -> Result<()> { + let _ = fd; + anyhow::bail!("coverage harness does not send stall ioctls") +} + +#[cfg(coverage)] +fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL_SIZE_MAX] { + let mut buf = [0u8; STREAM_CTRL_SIZE_MAX]; + + write_le16(&mut buf[0..2], 1); + buf[2] = 1; + buf[3] = 1; + write_le32(&mut buf[4..8], cfg.interval); + write_le16(&mut buf[8..10], 0); + write_le16(&mut buf[10..12], 0); + write_le16(&mut buf[12..14], 0); + write_le16(&mut buf[14..16], 0); + write_le16(&mut buf[16..18], 0); + write_le32(&mut buf[18..22], cfg.frame_size); + write_le32(&mut buf[22..26], cfg.max_packet); + if ctrl_len >= STREAM_CTRL_SIZE_15 { + write_le32(&mut buf[26..30], 48_000_000); + buf[30] = 0x03; + buf[31] = 0x01; + buf[32] = 0x01; + buf[33] = 0x01; + } + + buf +} + +#[cfg(coverage)] +fn parse_ctrl_request(data: [u8; 64]) -> UsbCtrlRequest { + UsbCtrlRequest { + b_request_type: data[0], + b_request: data[1], + w_value: u16::from_le_bytes([data[2], data[3]]), + w_index: u16::from_le_bytes([data[4], data[5]]), + w_length: u16::from_le_bytes([data[6], data[7]]), + } +} + +#[cfg(coverage)] +fn parse_request_data(data: [u8; 64]) -> UvcRequestData { + let length = i32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let mut out = [0u8; UVC_DATA_SIZE]; + out.copy_from_slice(&data[4..64]); + UvcRequestData { length, data: out } +} + +#[cfg(coverage)] +fn stream_ctrl_len() -> usize { + let value = env_u32("LESAVKA_UVC_CTRL_LEN", STREAM_CTRL_SIZE_15 as u32) as usize; + match value { + STREAM_CTRL_SIZE_11 | STREAM_CTRL_SIZE_15 => value, + _ => STREAM_CTRL_SIZE_11, + } +} + +#[cfg(coverage)] +fn env_u32(name: &str, default: u32) -> u32 { + env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +#[cfg(coverage)] +fn env_u8(name: &str) -> Option { + env::var(name).ok().and_then(|v| v.parse::().ok()) +} + +#[cfg(coverage)] +fn env_u32_opt(name: &str) -> Option { + env::var(name).ok().and_then(|v| v.parse::().ok()) +} + +#[cfg(coverage)] +fn read_u32_file(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|v| v.trim().parse::().ok()) +} + +#[cfg(coverage)] +fn read_u32_first(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|v| v.split_whitespace().next()?.parse::().ok()) +} + +#[cfg(coverage)] +fn read_configfs_snapshot() -> Option { + None +} + +#[cfg(coverage)] +fn log_configfs_snapshot(state: &mut UvcState, _label: &str) { + let _ = state; +} + +#[cfg(coverage)] +fn adjust_length(mut bytes: Vec, w_length: u16) -> Vec { + let want = (w_length as usize).min(UVC_DATA_SIZE); + bytes.resize(want, 0); + bytes.truncate(want); + bytes +} + +#[cfg(coverage)] +fn write_le16(dst: &mut [u8], val: u16) { + let bytes = val.to_le_bytes(); + dst[0] = bytes[0]; + dst[1] = bytes[1]; +} + +#[cfg(coverage)] +fn write_le32(dst: &mut [u8], val: u32) { + let bytes = val.to_le_bytes(); + dst[0] = bytes[0]; + dst[1] = bytes[1]; + dst[2] = bytes[2]; + dst[3] = bytes[3]; +} + +#[cfg(coverage)] +fn read_le32(src: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + src[offset], + src[offset + 1], + src[offset + 2], + src[offset + 3], + ]) +} diff --git a/server/src/bin/lesavka_uvc/control_requests.rs b/server/src/bin/lesavka_uvc/control_requests.rs new file mode 100644 index 0000000..c34cb9b --- /dev/null +++ b/server/src/bin/lesavka_uvc/control_requests.rs @@ -0,0 +1,162 @@ +fn handle_setup( + fd: i32, + uvc_send_response: libc::c_ulong, + state: &mut UvcState, + pending: &mut Option, + interfaces: UvcInterfaces, + req: UsbCtrlRequest, + debug: bool, +) { + let selector = (req.w_value >> 8) as u8; + if matches!(selector, UVC_VS_PROBE_CONTROL | UVC_VS_COMMIT_CONTROL) { + maybe_update_ctrl_len(state, req.w_length, debug); + } + + let raw = (req.w_index & 0xff) as u8; + let interface = map_interface(raw, selector, interfaces, debug); + let is_in = (req.b_request_type & USB_DIR_IN) != 0; + + if !is_in && req.b_request == UVC_SET_CUR { + let len = req.w_length as usize; + if interface == interfaces.control { + let _ = send_response(fd, uvc_send_response, &vec![0; len.min(UVC_DATA_SIZE)]); + return; + } + if interface != interfaces.streaming || len > UVC_DATA_SIZE { + let _ = send_stall(fd, uvc_send_response); + return; + } + *pending = Some(PendingRequest { + interface, + selector, + expected_len: len, + }); + let _ = send_response(fd, uvc_send_response, &vec![0; len]); + return; + } + + if !is_in { + let _ = send_stall(fd, uvc_send_response); + return; + } + + let _ = send_stall(fd, uvc_send_response); +} + +#[cfg(coverage)] +fn map_interface(raw: u8, selector: u8, interfaces: UvcInterfaces, _debug: bool) -> u8 { + if selector == UVC_VS_PROBE_CONTROL { + interfaces.streaming + } else if selector == UVC_VS_COMMIT_CONTROL || selector == UVC_VC_REQUEST_ERROR_CODE_CONTROL { + if raw == interfaces.control { + interfaces.control + } else { + interfaces.streaming + } + } else { + raw + } +} + +#[cfg(coverage)] +fn maybe_update_ctrl_len(state: &mut UvcState, w_length: u16, _debug: bool) { + let want = w_length as usize; + if !(want == STREAM_CTRL_SIZE_11 || want == STREAM_CTRL_SIZE_15) || state.ctrl_len == want { + return; + } + + state.ctrl_len = want; + state.default = build_streaming_control(&state.cfg, state.ctrl_len); + state.probe = state.default; + state.commit = state.default; +} + +#[cfg(coverage)] +fn handle_data( + _fd: i32, + _uvc_send_response: libc::c_ulong, + state: &mut UvcState, + pending: &mut Option, + interfaces: UvcInterfaces, + data: UvcRequestData, + _debug: bool, +) { + let Some(p) = pending.take() else { + return; + }; + if data.length < 0 { + return; + } + + let len = data.length as usize; + let slice = &data.data[..len.min(UVC_DATA_SIZE)]; + if p.interface != interfaces.streaming + || !matches!(p.selector, UVC_VS_PROBE_CONTROL | UVC_VS_COMMIT_CONTROL) + { + return; + } + let sanitized = sanitize_streaming_control(slice, state); + if p.selector == UVC_VS_PROBE_CONTROL { + state.probe = sanitized; + } else { + state.commit = sanitized; + } +} + +#[cfg(coverage)] +fn build_in_response( + state: &UvcState, + _interfaces: UvcInterfaces, + _interface: u8, + selector: u8, + request: u8, + w_length: u16, +) -> Option> { + let payload = build_streaming_response(state, selector, request)?; + Some(adjust_length(payload, w_length)) +} + +#[cfg(coverage)] +fn build_streaming_response(state: &UvcState, selector: u8, request: u8) -> Option> { + let current = match selector { + UVC_VS_PROBE_CONTROL => state.probe, + UVC_VS_COMMIT_CONTROL => state.commit, + _ => return None, + }; + + if request == UVC_GET_INFO { + return Some(vec![0x03]); + } + Some(current[..state.ctrl_len].to_vec()) +} + +#[cfg(coverage)] +fn build_control_response(_selector: u8, request: u8) -> Option> { + match request { + UVC_GET_INFO => Some(vec![0x03]), + 0x55 => None, + _ => Some(vec![0x00]), + } +} + +#[cfg(coverage)] +fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL_SIZE_MAX] { + let mut out = state.default; + if data.len() >= STREAM_CTRL_SIZE_11 { + if data[2] == 1 { + out[2] = 1; + } + if data[3] == 1 { + out[3] = 1; + } + let interval = read_le32(data, 4); + if interval != 0 { + write_le32(&mut out[4..8], interval); + } + let host_payload = read_le32(data, 22); + if host_payload > 0 { + write_le32(&mut out[22..26], host_payload.min(state.cfg.max_packet)); + } + } + out +} diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs new file mode 100644 index 0000000..d5bf94b --- /dev/null +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -0,0 +1,130 @@ +use anyhow::{Context, Result}; +#[cfg(coverage)] +use std::env; +#[cfg(coverage)] +use std::fs::OpenOptions; +#[cfg(coverage)] +use std::os::unix::fs::{FileTypeExt, OpenOptionsExt}; + +#[cfg(coverage)] +const STREAM_CTRL_SIZE_11: usize = 26; +#[cfg(coverage)] +const STREAM_CTRL_SIZE_15: usize = 34; +#[cfg(coverage)] +const STREAM_CTRL_SIZE_MAX: usize = STREAM_CTRL_SIZE_15; +#[cfg(coverage)] +const UVC_DATA_SIZE: usize = 60; + +#[cfg(coverage)] +const UVC_STRING_CONTROL_IDX: u8 = 0; +#[cfg(coverage)] +const UVC_STRING_STREAMING_IDX: u8 = 1; + +#[cfg(coverage)] +const USB_DIR_IN: u8 = 0x80; + +#[cfg(coverage)] +const UVC_SET_CUR: u8 = 0x01; +#[cfg(coverage)] +const UVC_GET_CUR: u8 = 0x81; +#[cfg(coverage)] +const UVC_GET_MIN: u8 = 0x82; +#[cfg(coverage)] +const UVC_GET_MAX: u8 = 0x83; +#[cfg(coverage)] +const UVC_GET_RES: u8 = 0x84; +#[cfg(coverage)] +const UVC_GET_LEN: u8 = 0x85; +#[cfg(coverage)] +const UVC_GET_INFO: u8 = 0x86; +#[cfg(coverage)] +const UVC_GET_DEF: u8 = 0x87; + +#[cfg(coverage)] +const UVC_VS_PROBE_CONTROL: u8 = 0x01; +#[cfg(coverage)] +const UVC_VS_COMMIT_CONTROL: u8 = 0x02; +#[cfg(coverage)] +const UVC_VC_REQUEST_ERROR_CODE_CONTROL: u8 = 0x02; + +#[cfg(coverage)] +#[repr(C)] +#[derive(Clone, Copy)] +struct V4l2Event { + _bytes: [u8; 64], +} + +#[cfg(coverage)] +#[repr(C)] +#[derive(Clone, Copy)] +struct UsbCtrlRequest { + b_request_type: u8, + b_request: u8, + w_value: u16, + w_index: u16, + w_length: u16, +} + +#[cfg(coverage)] +#[repr(C)] +#[derive(Clone, Copy)] +struct UvcRequestData { + length: i32, + data: [u8; UVC_DATA_SIZE], +} + +#[cfg(coverage)] +#[derive(Clone, Copy)] +struct UvcConfig { + width: u32, + height: u32, + fps: u32, + interval: u32, + max_packet: u32, + frame_size: u32, +} + +#[cfg(coverage)] +struct PayloadCap { + limit: u32, + pct: u32, + source: &'static str, + periodic_dw: Option, + non_periodic_dw: Option, +} + +#[cfg(coverage)] +struct UvcState { + cfg: UvcConfig, + ctrl_len: usize, + default: [u8; STREAM_CTRL_SIZE_MAX], + probe: [u8; STREAM_CTRL_SIZE_MAX], + commit: [u8; STREAM_CTRL_SIZE_MAX], + cfg_snapshot: Option, +} + +#[cfg(coverage)] +#[derive(Clone, Copy)] +struct PendingRequest { + interface: u8, + selector: u8, + expected_len: usize, +} + +#[cfg(coverage)] +#[derive(Clone, Copy)] +struct UvcInterfaces { + control: u8, + streaming: u8, +} + +#[cfg(coverage)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ConfigfsSnapshot { + width: u32, + height: u32, + default_interval: u32, + frame_interval: u32, + maxpacket: u32, + maxburst: u32, +} diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs new file mode 100644 index 0000000..10744d4 --- /dev/null +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -0,0 +1,110 @@ +fn main() -> Result<()> { + let (dev, _cfg) = parse_args()?; + let _ = load_interfaces(); + let _ = UvcConfig::from_env(); + + let _ = open_with_retry(&dev)?; + anyhow::bail!("coverage harness: control loop disabled"); +} + +#[cfg(coverage)] +fn parse_args() -> Result<(String, UvcConfig)> { + let args: Vec = env::args().skip(1).collect(); + let dev = parse_device_arg(&args) + .or_else(|| env::var("LESAVKA_UVC_DEV").ok()) + .context("missing --device (or LESAVKA_UVC_DEV)")?; + + Ok((dev, UvcConfig::from_env())) +} + +#[cfg(coverage)] +fn parse_device_arg(args: &[String]) -> Option { + args + .windows(2) + .find_map(|pair| (pair[0] == "--device" || pair[0] == "-d").then(|| pair[1].clone())) + .or_else(|| { + args.iter() + .rev() + .find(|arg| { + arg.as_str() != "--device" && arg.as_str() != "-d" && arg.starts_with('/') + }) + .cloned() + }) +} + +#[cfg(coverage)] +impl UvcConfig { + fn from_env() -> Self { + let width = env_u32("LESAVKA_UVC_WIDTH", 1280); + let height = env_u32("LESAVKA_UVC_HEIGHT", 720); + let fps = env_u32("LESAVKA_UVC_FPS", 25).max(1); + let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); + let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); + let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); + let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); + + if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) { + max_packet = max_packet.min(limit); + } + max_packet = if bulk { + max_packet.min(512) + } else { + max_packet.min(1024) + }; + + let interval = if interval == 0 { + 10_000_000 / fps + } else { + interval + }; + + Self { + width, + height, + fps, + interval, + max_packet, + frame_size, + } + } +} + +#[cfg(coverage)] +impl UvcState { + fn new(cfg: UvcConfig) -> Self { + let ctrl_len = stream_ctrl_len(); + let default = build_streaming_control(&cfg, ctrl_len); + Self { + cfg, + ctrl_len, + default, + probe: default, + commit: default, + cfg_snapshot: None, + } + } +} + +#[cfg(coverage)] +fn load_interfaces() -> UvcInterfaces { + let control = env_u8("LESAVKA_UVC_CTRL_INTF").unwrap_or(UVC_STRING_CONTROL_IDX); + let streaming = env_u8("LESAVKA_UVC_STREAM_INTF").unwrap_or(UVC_STRING_STREAMING_IDX); + UvcInterfaces { control, streaming } +} + +#[cfg(coverage)] +fn read_interface(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|v| v.trim().parse::().ok()) +} + +#[cfg(coverage)] +fn open_with_retry(path: &str) -> Result { + let mut opts = OpenOptions::new(); + opts.read(true).write(true); + if env::var("LESAVKA_UVC_BLOCKING").is_err() { + opts.custom_flags(libc::O_NONBLOCK); + } + opts.open(path).with_context(|| format!("open {path}")) +} diff --git a/server/src/bin/lesavka_uvc/payload_limits.rs b/server/src/bin/lesavka_uvc/payload_limits.rs new file mode 100644 index 0000000..66a7640 --- /dev/null +++ b/server/src/bin/lesavka_uvc/payload_limits.rs @@ -0,0 +1,74 @@ +fn compute_payload_cap(bulk: bool) -> Option { + if let Some(limit) = env_u32_opt("LESAVKA_UVC_MAXPAYLOAD_LIMIT") { + return Some(PayloadCap { + limit, + pct: 100, + source: "env", + periodic_dw: None, + non_periodic_dw: None, + }); + } + + let mut pct = env_u32("LESAVKA_UVC_LIMIT_PCT", 95); + if pct == 0 { + pct = 1; + } else if pct > 100 { + pct = 100; + } + let base: u32 = if bulk { 512 } else { 1024 }; + + Some(PayloadCap { + limit: base.saturating_mul(pct) / 100, + pct, + source: "stub", + periodic_dw: None, + non_periodic_dw: None, + }) +} + +#[cfg(coverage)] +fn read_fifo_min(path: &str) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + raw.split(|c: char| c == ',' || c.is_whitespace()) + .filter_map(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .min() +} + +#[cfg(coverage)] +const IOC_NRBITS: u8 = 8; +#[cfg(coverage)] +const IOC_TYPEBITS: u8 = 8; +#[cfg(coverage)] +const IOC_SIZEBITS: u8 = 14; +#[cfg(coverage)] +const IOC_NRSHIFT: u8 = 0; +#[cfg(coverage)] +const IOC_TYPESHIFT: u8 = IOC_NRSHIFT + IOC_NRBITS; +#[cfg(coverage)] +const IOC_SIZESHIFT: u8 = IOC_TYPESHIFT + IOC_TYPEBITS; +#[cfg(coverage)] +const IOC_DIRSHIFT: u8 = IOC_SIZESHIFT + IOC_SIZEBITS; +#[cfg(coverage)] +const IOC_READ: u8 = 2; +#[cfg(coverage)] +const IOC_WRITE: u8 = 1; + +#[cfg(coverage)] +fn ioctl_read(type_: u8, nr: u8) -> libc::c_ulong { + ioc(IOC_READ, type_, nr, std::mem::size_of::() as u16) +} + +#[cfg(coverage)] +fn ioctl_write(type_: u8, nr: u8) -> libc::c_ulong { + ioc(IOC_WRITE, type_, nr, std::mem::size_of::() as u16) +} + +#[cfg(coverage)] +fn ioc(dir: u8, type_: u8, nr: u8, size: u16) -> libc::c_ulong { + let dir = (dir as u32) << IOC_DIRSHIFT; + let ty = (type_ as u32) << IOC_TYPESHIFT; + let nr = (nr as u32) << IOC_NRSHIFT; + let size = (size as u32) << IOC_SIZESHIFT; + (dir | ty | nr | size) as libc::c_ulong +} diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs new file mode 100644 index 0000000..71e6372 --- /dev/null +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -0,0 +1,81 @@ +use super::*; +use serial_test::serial; +use std::fs; +use temp_env::with_var; +use tempfile::NamedTempFile; + +fn sample_cfg() -> UvcConfig { + UvcConfig { + width: 1280, + height: 720, + fps: 25, + interval: 400_000, + max_packet: 1024, + frame_size: 1_843_200, + } +} + +fn sample_interfaces() -> UvcInterfaces { + UvcInterfaces { + control: UVC_STRING_CONTROL_IDX, + streaming: UVC_STRING_STREAMING_IDX, + } +} + +#[test] +fn branch_smoke_covers_low_hit_paths() { + let interfaces = sample_interfaces(); + let mut state = UvcState::new(sample_cfg()); + let mut pending = Some(PendingRequest { + interface: interfaces.control, + selector: UVC_VS_PROBE_CONTROL, + expected_len: STREAM_CTRL_SIZE_11, + }); + handle_data( + -1, + 0, + &mut state, + &mut pending, + interfaces, + UvcRequestData { + length: STREAM_CTRL_SIZE_11 as i32, + data: [0u8; UVC_DATA_SIZE], + }, + true, + ); + assert!(pending.is_none()); + assert!( + build_in_response( + &state, + interfaces, + interfaces.streaming, + 0xFE, + UVC_GET_CUR, + 8 + ) + .is_none() + ); + let short = [0u8; 8]; + let _ = sanitize_streaming_control(&short, &state); +} + +#[test] +fn io_helpers_cover_empty_and_missing_sources() { + let empty = NamedTempFile::new().expect("tmp"); + fs::write(empty.path(), "\n").expect("write empty"); + assert_eq!(read_u32_first(empty.path().to_str().expect("path")), None); + + let missing = format!("/tmp/lesavka-uvc-missing-{}", std::process::id()); + assert_eq!(read_fifo_min(&missing), None); +} + +#[test] +#[serial] +fn main_coverage_mode_returns_error_for_non_uvc_node() { + with_var("LESAVKA_UVC_DEV", Some("/dev/null"), || { + with_var("LESAVKA_UVC_BLOCKING", Some("1"), || { + let result = main(); + assert!(result.is_err()); + }); + }); +} diff --git a/server/src/camera.rs b/server/src/camera.rs index 7aa9d16..d2fb9bc 100644 --- a/server/src/camera.rs +++ b/server/src/camera.rs @@ -467,157 +467,5 @@ fn read_u32_from_map(map: &HashMap, key: &str) -> Option { } #[cfg(test)] -mod tests { - use super::{ - CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config, - parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode, update_camera_config, - }; - use serial_test::serial; - use temp_env::with_var; - - #[test] - #[serial] - fn camera_config_env_override_prefers_uvc_values() { - with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { - with_var("LESAVKA_UVC_WIDTH", Some("800"), || { - with_var("LESAVKA_UVC_HEIGHT", Some("600"), || { - with_var("LESAVKA_UVC_FPS", Some("24"), || { - let cfg = update_camera_config(); - assert_eq!(cfg.output, CameraOutput::Uvc); - assert_eq!(cfg.codec, CameraCodec::Mjpeg); - assert_eq!(cfg.width, 800); - assert_eq!(cfg.height, 600); - assert_eq!(cfg.fps, 24); - - let cached = current_camera_config(); - assert_eq!(cached.output, CameraOutput::Uvc); - assert_eq!(cached.codec, CameraCodec::Mjpeg); - assert_eq!(cached.width, 800); - assert_eq!(cached.height, 600); - assert_eq!(cached.fps, 24); - }); - }); - }); - }); - } - - #[test] - #[serial] - fn hdmi_camera_profile_honors_installed_1080p_override() { - with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { - with_var("LESAVKA_CAM_WIDTH", Some("1920"), || { - with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || { - with_var("LESAVKA_CAM_FPS", Some("30"), || { - let cfg = update_camera_config(); - assert_eq!(cfg.output, CameraOutput::Hdmi); - assert_eq!(cfg.codec, CameraCodec::H264); - assert_eq!(cfg.width, 1920); - assert_eq!(cfg.height, 1080); - assert_eq!(cfg.fps, 30); - }); - }); - }); - }); - } - - #[test] - fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() { - assert_eq!( - parse_hdmi_mode("1920x1080"), - Some(HdmiMode { - width: 1920, - height: 1080, - }) - ); - assert_eq!( - parse_hdmi_mode("1280x720p60"), - Some(HdmiMode { - width: 1280, - height: 720, - }) - ); - assert_eq!(parse_hdmi_mode("not-a-mode"), None); - - let modes = parse_hdmi_modes("1920x1080\n1024x768,800x600\n"); - assert_eq!(modes.len(), 3); - assert_eq!(modes[0].width, 1920); - assert_eq!(modes[2].height, 600); - } - - #[test] - fn preferred_hdmi_mode_chooses_standard_capture_adapter_mode() { - let modes = parse_hdmi_modes("1024x768\n1920x1080\n800x600\n"); - assert_eq!( - preferred_hdmi_mode(&modes), - Some(HdmiMode { - width: 1920, - height: 1080, - }) - ); - - let modes = parse_hdmi_modes("1600x900\n1024x768\n"); - assert_eq!( - preferred_hdmi_mode(&modes), - Some(HdmiMode { - width: 1600, - height: 900, - }) - ); - - let modes = parse_hdmi_modes("1024x768\n800x600\n"); - assert_eq!( - preferred_hdmi_mode(&modes), - Some(HdmiMode { - width: 1024, - height: 768, - }) - ); - } - - #[test] - #[serial] - fn hdmi_display_size_uses_adapter_mode_without_changing_uplink_profile() { - let cfg = CameraConfig { - output: CameraOutput::Hdmi, - codec: CameraCodec::H264, - width: 1280, - height: 720, - fps: 30, - hdmi: Some(HdmiConnector { - name: String::from("card1-HDMI-A-2"), - id: Some(43), - modes: parse_hdmi_modes("1920x1080\n1024x768\n800x600\n"), - }), - }; - - with_var("LESAVKA_HDMI_WIDTH", None::<&str>, || { - with_var("LESAVKA_HDMI_HEIGHT", None::<&str>, || { - assert_eq!((cfg.width, cfg.height), (1280, 720)); - assert_eq!(cfg.hdmi_display_size(), (1920, 1080)); - }); - }); - } - - #[test] - #[serial] - fn hdmi_display_size_honors_explicit_local_override() { - let cfg = CameraConfig { - output: CameraOutput::Hdmi, - codec: CameraCodec::H264, - width: 1280, - height: 720, - fps: 30, - hdmi: Some(HdmiConnector { - name: String::from("card1-HDMI-A-2"), - id: Some(43), - modes: parse_hdmi_modes("1920x1080\n"), - }), - }; - - with_var("LESAVKA_HDMI_WIDTH", Some("1024"), || { - with_var("LESAVKA_HDMI_HEIGHT", Some("768"), || { - assert_eq!(cfg.hdmi_display_size(), (1024, 768)); - }); - }); - } -} +#[path = "tests/camera.rs"] +mod tests; diff --git a/server/src/camera_runtime.rs b/server/src/camera_runtime.rs index 1d78fa6..fcae228 100644 --- a/server/src/camera_runtime.rs +++ b/server/src/camera_runtime.rs @@ -116,6 +116,7 @@ impl CameraRuntime { self.generation.load(Ordering::Relaxed) == session_id } + #[allow(clippy::result_large_err)] #[cfg(not(coverage))] fn make_relay(&self, cfg: &camera::CameraConfig) -> Result, Status> { let relay = match cfg.output { @@ -138,6 +139,12 @@ impl CameraRuntime { } } +impl Default for CameraRuntime { + fn default() -> Self { + Self::new() + } +} + /// Compare two camera configurations for sink reuse. /// /// Inputs: the currently active camera config and the requested config. diff --git a/server/src/capture_power.rs b/server/src/capture_power.rs index dd9054f..cc77f3f 100644 --- a/server/src/capture_power.rs +++ b/server/src/capture_power.rs @@ -1,496 +1,10 @@ +// Capture power lease manager for keeping remote capture devices awake only when needed. +#[cfg(coverage)] use lesavka_common::lesavka::CapturePowerState; #[cfg(not(coverage))] -use { - anyhow::{Context, Result, anyhow}, - std::process::Command, - std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - tokio::{ - sync::Mutex, - time::{Duration, Instant}, - }, - tracing::{info, warn}, -}; - -#[cfg(not(coverage))] -#[derive(Debug, Default)] -struct CapturePowerInner { - preview_leases: u32, - session_leases: u32, - manual_override: Option, - session_grace_deadline: Option, - sync_generation: u64, -} - -#[cfg(not(coverage))] -#[derive(Debug, Clone)] -pub struct CapturePowerManager { - unit: Arc, - session_grace: Duration, - inner: Arc>, -} - -#[cfg(not(coverage))] -#[derive(Clone)] -pub struct CapturePowerLease { - manager: CapturePowerManager, - kind: LeaseKind, - released: Arc, -} - -#[cfg(not(coverage))] -#[derive(Clone, Copy, Debug)] -enum LeaseKind { - Preview, - Session, -} - -#[cfg(not(coverage))] -#[derive(Debug)] -struct UnitSnapshot { - available: bool, - enabled: bool, - detail: String, -} - -#[cfg(not(coverage))] -impl LeaseKind { - fn as_str(self) -> &'static str { - match self { - Self::Preview => "preview", - Self::Session => "session", - } - } -} - -#[cfg(not(coverage))] -impl CapturePowerManager { - pub fn new() -> Self { - let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "relay.service".to_string()); - Self { - unit: Arc::::from(unit), - session_grace: capture_power_session_grace_from_env(), - inner: Arc::new(Mutex::new(CapturePowerInner::default())), - } - } - - pub async fn acquire(&self) -> CapturePowerLease { - self.acquire_kind(LeaseKind::Preview).await - } - - pub async fn acquire_session(&self) -> CapturePowerLease { - self.acquire_kind(LeaseKind::Session).await - } - - async fn acquire_kind(&self, kind: LeaseKind) -> CapturePowerLease { - let (desired, unit, leases, manual_override, grace_remaining) = { - let mut inner = self.inner.lock().await; - match kind { - LeaseKind::Preview => { - inner.preview_leases = inner.preview_leases.saturating_add(1); - } - LeaseKind::Session => { - inner.session_leases = inner.session_leases.saturating_add(1); - if inner.session_grace_deadline.take().is_some() { - inner.sync_generation = inner.sync_generation.saturating_add(1); - } - } - } - let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); - ( - desired, - self.unit.to_string(), - active_leases(&inner), - inner.manual_override, - grace_remaining, - ) - }; - - if let Err(err) = sync_unit_state(unit.as_str(), desired).await { - warn!( - unit = %unit, - kind = kind.as_str(), - leases, - desired, - ?manual_override, - ?grace_remaining, - ?err, - "capture power sync failed on acquire" - ); - } - - CapturePowerLease { - manager: self.clone(), - kind, - released: Arc::new(AtomicBool::new(false)), - } - } - - pub async fn set_manual(&self, enabled: bool) -> Result { - let unit = self.unit.to_string(); - { - let mut inner = self.inner.lock().await; - inner.manual_override = Some(enabled); - inner.session_grace_deadline = None; - inner.sync_generation = inner.sync_generation.saturating_add(1); - } - - sync_unit_state(unit.as_str(), enabled).await?; - self.snapshot().await - } - - pub async fn set_auto(&self) -> Result { - let unit = self.unit.to_string(); - let desired = { - let mut inner = self.inner.lock().await; - inner.manual_override = None; - desired_state_and_grace(&inner, Instant::now()).0 - }; - - sync_unit_state(unit.as_str(), desired).await?; - self.snapshot().await - } - - pub async fn snapshot(&self) -> Result { - let (active_leases, manual_override, grace_remaining) = { - let inner = self.inner.lock().await; - ( - active_leases(&inner), - inner.manual_override, - desired_state_and_grace(&inner, Instant::now()).1, - ) - }; - let unit = self.unit.to_string(); - let snapshot = inspect_unit(unit.as_str()).await?; - let mut detail = snapshot.detail; - if let Some(grace_remaining) = grace_remaining { - detail = format!( - "{detail} • disconnect grace {}s", - grace_remaining.as_secs().max(1) - ); - } - Ok(CapturePowerState { - available: snapshot.available, - enabled: snapshot.enabled, - unit, - detail, - active_leases, - mode: match manual_override { - Some(true) => "forced-on".to_string(), - Some(false) => "forced-off".to_string(), - None => "auto".to_string(), - }, - detected_devices: 0, - }) - } - - async fn release_one(&self, kind: LeaseKind) { - let (desired, unit, leases, manual_override, grace_remaining, grace_sync) = { - let mut inner = self.inner.lock().await; - match kind { - LeaseKind::Preview => { - inner.preview_leases = inner.preview_leases.saturating_sub(1); - } - LeaseKind::Session => { - inner.session_leases = inner.session_leases.saturating_sub(1); - if inner.session_leases == 0 { - let deadline = Instant::now() + self.session_grace; - inner.session_grace_deadline = Some(deadline); - inner.sync_generation = inner.sync_generation.saturating_add(1); - } - } - } - let grace_sync = match kind { - LeaseKind::Session if inner.session_leases == 0 => inner - .session_grace_deadline - .map(|deadline| (inner.sync_generation, deadline)), - _ => None, - }; - let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); - ( - desired, - self.unit.to_string(), - active_leases(&inner), - inner.manual_override, - grace_remaining, - grace_sync, - ) - }; - - if let Err(err) = sync_unit_state(unit.as_str(), desired).await { - warn!( - unit = %unit, - kind = kind.as_str(), - leases, - desired, - ?manual_override, - ?grace_remaining, - ?err, - "capture power sync failed on release" - ); - } else { - info!( - unit = %unit, - kind = kind.as_str(), - leases, - desired, - ?manual_override, - ?grace_remaining, - "capture power synced" - ); - } - - if let Some((generation, deadline)) = grace_sync { - self.schedule_grace_sync(generation, deadline); - } - } - - fn schedule_grace_sync(&self, generation: u64, deadline: Instant) { - let manager = self.clone(); - tokio::spawn(async move { - tokio::time::sleep_until(deadline).await; - let (desired, unit, leases, manual_override, grace_remaining, current_generation) = { - let inner = manager.inner.lock().await; - let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); - ( - desired, - manager.unit.to_string(), - active_leases(&inner), - inner.manual_override, - grace_remaining, - inner.sync_generation, - ) - }; - if current_generation != generation { - return; - } - if let Err(err) = sync_unit_state(unit.as_str(), desired).await { - warn!( - unit = %unit, - generation, - leases, - desired, - ?manual_override, - ?grace_remaining, - ?err, - "capture power sync failed after grace" - ); - } else { - info!( - unit = %unit, - generation, - leases, - desired, - ?manual_override, - ?grace_remaining, - "capture power synced after grace" - ); - } - }); - } -} - -#[cfg(not(coverage))] -impl Drop for CapturePowerLease { - fn drop(&mut self) { - if self.released.swap(true, Ordering::AcqRel) { - return; - } - let manager = self.manager.clone(); - let kind = self.kind; - tokio::spawn(async move { - manager.release_one(kind).await; - }); - } -} - -#[cfg(not(coverage))] -fn active_leases(inner: &CapturePowerInner) -> u32 { - inner.preview_leases.saturating_add(inner.session_leases) -} - -#[cfg(not(coverage))] -fn desired_state_and_grace(inner: &CapturePowerInner, now: Instant) -> (bool, Option) { - if let Some(manual_override) = inner.manual_override { - return (manual_override, None); - } - let grace_remaining = inner - .session_grace_deadline - .and_then(|deadline| deadline.checked_duration_since(now)); - let desired = inner.preview_leases > 0 || inner.session_leases > 0 || grace_remaining.is_some(); - (desired, grace_remaining) -} - -#[cfg(not(coverage))] -fn capture_power_session_grace_from_env() -> Duration { - std::env::var("LESAVKA_CAPTURE_POWER_GRACE_SECS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or_else(|| Duration::from_secs(30)) -} - -#[cfg(not(coverage))] -async fn inspect_unit(unit: &str) -> Result { - let unit = unit.to_string(); - tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str())) - .await - .map_err(|err| anyhow!("capture power inspect task failed: {err}"))? -} - -#[cfg(not(coverage))] -fn inspect_unit_blocking(unit: &str) -> Result { - if capture_power_unit_disabled(unit) { - return Ok(UnitSnapshot { - available: false, - enabled: false, - detail: "disabled".to_string(), - }); - } - - let output = Command::new("systemctl") - .args([ - "show", - unit, - "--property=LoadState,ActiveState,SubState", - "--value", - ]) - .output() - .with_context(|| format!("querying systemd unit {unit}"))?; - - if !output.status.success() { - return Err(anyhow!( - "systemctl show {unit} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let load_state = lines.next().unwrap_or_default().trim().to_string(); - let active_state = lines.next().unwrap_or_default().trim().to_string(); - let sub_state = lines.next().unwrap_or_default().trim().to_string(); - let available = !load_state.is_empty() && load_state != "not-found"; - let enabled = active_state == "active"; - let detail = if available { - format!("{active_state}/{sub_state}") - } else { - "unit not found".to_string() - }; - - Ok(UnitSnapshot { - available, - enabled, - detail, - }) -} - -#[cfg(not(coverage))] -async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> { - let unit = unit.to_string(); - tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled)) - .await - .map_err(|err| anyhow!("capture power sync task failed: {err}"))? -} - -#[cfg(not(coverage))] -fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> { - if capture_power_unit_disabled(unit) { - return Ok(()); - } - - let action = if enabled { "start" } else { "stop" }; - let status = Command::new("systemctl") - .args([action, unit]) - .status() - .with_context(|| format!("running systemctl {action} {unit}"))?; - if status.success() { - Ok(()) - } else { - Err(anyhow!("systemctl {action} {unit} failed with {status}")) - } -} - -#[cfg(not(coverage))] -fn capture_power_unit_disabled(unit: &str) -> bool { - matches!( - unit.trim().to_ascii_lowercase().as_str(), - "0" | "off" | "none" | "disabled" - ) -} - -#[cfg(coverage)] -#[derive(Debug, Clone, Default)] -pub struct CapturePowerManager; - -#[cfg(coverage)] -#[derive(Clone, Default)] -pub struct CapturePowerLease; - -#[cfg(coverage)] -impl CapturePowerManager { - pub fn new() -> Self { - Self - } - - pub async fn acquire(&self) -> CapturePowerLease { - CapturePowerLease - } - - pub async fn acquire_session(&self) -> CapturePowerLease { - CapturePowerLease - } - - pub async fn set_manual(&self, enabled: bool) -> anyhow::Result { - Ok(CapturePowerState { - available: true, - enabled, - unit: "relay.service".to_string(), - detail: if enabled { - "active/running".to_string() - } else { - "inactive/dead".to_string() - }, - active_leases: 0, - mode: if enabled { - "forced-on".to_string() - } else { - "forced-off".to_string() - }, - detected_devices: 0, - }) - } - - pub async fn set_auto(&self) -> anyhow::Result { - Ok(CapturePowerState { - available: true, - enabled: false, - unit: "relay.service".to_string(), - detail: "inactive/dead".to_string(), - active_leases: 0, - mode: "auto".to_string(), - detected_devices: 0, - }) - } - - pub async fn snapshot(&self) -> anyhow::Result { - Ok(CapturePowerState { - available: true, - enabled: false, - unit: "relay.service".to_string(), - detail: "inactive/dead".to_string(), - active_leases: 0, - mode: "auto".to_string(), - detected_devices: 0, - }) - } -} +include!("capture_power/lease_manager.rs"); +include!("capture_power/systemd_units.rs"); #[cfg(all(test, coverage))] mod tests { diff --git a/server/src/capture_power/lease_manager.rs b/server/src/capture_power/lease_manager.rs new file mode 100644 index 0000000..42eb5e4 --- /dev/null +++ b/server/src/capture_power/lease_manager.rs @@ -0,0 +1,317 @@ +use lesavka_common::lesavka::CapturePowerState; + +#[cfg(not(coverage))] +use { + anyhow::{Context, Result, anyhow}, + std::process::Command, + std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + tokio::{ + sync::Mutex, + time::{Duration, Instant}, + }, + tracing::{info, warn}, +}; + +#[cfg(not(coverage))] +#[derive(Debug, Default)] +struct CapturePowerInner { + preview_leases: u32, + session_leases: u32, + manual_override: Option, + session_grace_deadline: Option, + sync_generation: u64, +} + +#[cfg(not(coverage))] +#[derive(Debug, Clone)] +pub struct CapturePowerManager { + unit: Arc, + session_grace: Duration, + inner: Arc>, +} + +#[cfg(not(coverage))] +#[derive(Clone)] +pub struct CapturePowerLease { + manager: CapturePowerManager, + kind: LeaseKind, + released: Arc, +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy, Debug)] +enum LeaseKind { + Preview, + Session, +} + +#[cfg(not(coverage))] +#[derive(Debug)] +struct UnitSnapshot { + available: bool, + enabled: bool, + detail: String, +} + +#[cfg(not(coverage))] +impl LeaseKind { + fn as_str(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Session => "session", + } + } +} + +#[cfg(not(coverage))] +impl Default for CapturePowerManager { + fn default() -> Self { + Self::new() + } +} + +impl CapturePowerManager { + pub fn new() -> Self { + let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "relay.service".to_string()); + Self { + unit: Arc::::from(unit), + session_grace: capture_power_session_grace_from_env(), + inner: Arc::new(Mutex::new(CapturePowerInner::default())), + } + } + + pub async fn acquire(&self) -> CapturePowerLease { + self.acquire_kind(LeaseKind::Preview).await + } + + pub async fn acquire_session(&self) -> CapturePowerLease { + self.acquire_kind(LeaseKind::Session).await + } + + async fn acquire_kind(&self, kind: LeaseKind) -> CapturePowerLease { + let (desired, unit, leases, manual_override, grace_remaining) = { + let mut inner = self.inner.lock().await; + match kind { + LeaseKind::Preview => { + inner.preview_leases = inner.preview_leases.saturating_add(1); + } + LeaseKind::Session => { + inner.session_leases = inner.session_leases.saturating_add(1); + if inner.session_grace_deadline.take().is_some() { + inner.sync_generation = inner.sync_generation.saturating_add(1); + } + } + } + let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); + ( + desired, + self.unit.to_string(), + active_leases(&inner), + inner.manual_override, + grace_remaining, + ) + }; + + if let Err(err) = sync_unit_state(unit.as_str(), desired).await { + warn!( + unit = %unit, + kind = kind.as_str(), + leases, + desired, + ?manual_override, + ?grace_remaining, + ?err, + "capture power sync failed on acquire" + ); + } + + CapturePowerLease { + manager: self.clone(), + kind, + released: Arc::new(AtomicBool::new(false)), + } + } + + pub async fn set_manual(&self, enabled: bool) -> Result { + let unit = self.unit.to_string(); + { + let mut inner = self.inner.lock().await; + inner.manual_override = Some(enabled); + inner.session_grace_deadline = None; + inner.sync_generation = inner.sync_generation.saturating_add(1); + } + + sync_unit_state(unit.as_str(), enabled).await?; + self.snapshot().await + } + + pub async fn set_auto(&self) -> Result { + let unit = self.unit.to_string(); + let desired = { + let mut inner = self.inner.lock().await; + inner.manual_override = None; + desired_state_and_grace(&inner, Instant::now()).0 + }; + + sync_unit_state(unit.as_str(), desired).await?; + self.snapshot().await + } + + pub async fn snapshot(&self) -> Result { + let (active_leases, manual_override, grace_remaining) = { + let inner = self.inner.lock().await; + ( + active_leases(&inner), + inner.manual_override, + desired_state_and_grace(&inner, Instant::now()).1, + ) + }; + let unit = self.unit.to_string(); + let snapshot = inspect_unit(unit.as_str()).await?; + let mut detail = snapshot.detail; + if let Some(grace_remaining) = grace_remaining { + detail = format!( + "{detail} • disconnect grace {}s", + grace_remaining.as_secs().max(1) + ); + } + Ok(CapturePowerState { + available: snapshot.available, + enabled: snapshot.enabled, + unit, + detail, + active_leases, + mode: match manual_override { + Some(true) => "forced-on".to_string(), + Some(false) => "forced-off".to_string(), + None => "auto".to_string(), + }, + detected_devices: 0, + }) + } + + async fn release_one(&self, kind: LeaseKind) { + let (desired, unit, leases, manual_override, grace_remaining, grace_sync) = { + let mut inner = self.inner.lock().await; + match kind { + LeaseKind::Preview => { + inner.preview_leases = inner.preview_leases.saturating_sub(1); + } + LeaseKind::Session => { + inner.session_leases = inner.session_leases.saturating_sub(1); + if inner.session_leases == 0 { + let deadline = Instant::now() + self.session_grace; + inner.session_grace_deadline = Some(deadline); + inner.sync_generation = inner.sync_generation.saturating_add(1); + } + } + } + let grace_sync = match kind { + LeaseKind::Session if inner.session_leases == 0 => inner + .session_grace_deadline + .map(|deadline| (inner.sync_generation, deadline)), + _ => None, + }; + let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); + ( + desired, + self.unit.to_string(), + active_leases(&inner), + inner.manual_override, + grace_remaining, + grace_sync, + ) + }; + + if let Err(err) = sync_unit_state(unit.as_str(), desired).await { + warn!( + unit = %unit, + kind = kind.as_str(), + leases, + desired, + ?manual_override, + ?grace_remaining, + ?err, + "capture power sync failed on release" + ); + } else { + info!( + unit = %unit, + kind = kind.as_str(), + leases, + desired, + ?manual_override, + ?grace_remaining, + "capture power synced" + ); + } + + if let Some((generation, deadline)) = grace_sync { + self.schedule_grace_sync(generation, deadline); + } + } + + fn schedule_grace_sync(&self, generation: u64, deadline: Instant) { + let manager = self.clone(); + tokio::spawn(async move { + tokio::time::sleep_until(deadline).await; + let (desired, unit, leases, manual_override, grace_remaining, current_generation) = { + let inner = manager.inner.lock().await; + let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); + ( + desired, + manager.unit.to_string(), + active_leases(&inner), + inner.manual_override, + grace_remaining, + inner.sync_generation, + ) + }; + if current_generation != generation { + return; + } + if let Err(err) = sync_unit_state(unit.as_str(), desired).await { + warn!( + unit = %unit, + generation, + leases, + desired, + ?manual_override, + ?grace_remaining, + ?err, + "capture power sync failed after grace" + ); + } else { + info!( + unit = %unit, + generation, + leases, + desired, + ?manual_override, + ?grace_remaining, + "capture power synced after grace" + ); + } + }); + } +} + +#[cfg(not(coverage))] +impl Drop for CapturePowerLease { + fn drop(&mut self) { + if self.released.swap(true, Ordering::AcqRel) { + return; + } + let manager = self.manager.clone(); + let kind = self.kind; + tokio::spawn(async move { + manager.release_one(kind).await; + }); + } +} diff --git a/server/src/capture_power/systemd_units.rs b/server/src/capture_power/systemd_units.rs new file mode 100644 index 0000000..9411c39 --- /dev/null +++ b/server/src/capture_power/systemd_units.rs @@ -0,0 +1,181 @@ +#[cfg(not(coverage))] +fn active_leases(inner: &CapturePowerInner) -> u32 { + inner.preview_leases.saturating_add(inner.session_leases) +} + +#[cfg(not(coverage))] +fn desired_state_and_grace(inner: &CapturePowerInner, now: Instant) -> (bool, Option) { + if let Some(manual_override) = inner.manual_override { + return (manual_override, None); + } + let grace_remaining = inner + .session_grace_deadline + .and_then(|deadline| deadline.checked_duration_since(now)); + let desired = inner.preview_leases > 0 || inner.session_leases > 0 || grace_remaining.is_some(); + (desired, grace_remaining) +} + +#[cfg(not(coverage))] +fn capture_power_session_grace_from_env() -> Duration { + std::env::var("LESAVKA_CAPTURE_POWER_GRACE_SECS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(30)) +} + +#[cfg(not(coverage))] +async fn inspect_unit(unit: &str) -> Result { + let unit = unit.to_string(); + tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str())) + .await + .map_err(|err| anyhow!("capture power inspect task failed: {err}"))? +} + +#[cfg(not(coverage))] +fn inspect_unit_blocking(unit: &str) -> Result { + if capture_power_unit_disabled(unit) { + return Ok(UnitSnapshot { + available: false, + enabled: false, + detail: "disabled".to_string(), + }); + } + + let output = Command::new("systemctl") + .args([ + "show", + unit, + "--property=LoadState,ActiveState,SubState", + "--value", + ]) + .output() + .with_context(|| format!("querying systemd unit {unit}"))?; + + if !output.status.success() { + return Err(anyhow!( + "systemctl show {unit} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let load_state = lines.next().unwrap_or_default().trim().to_string(); + let active_state = lines.next().unwrap_or_default().trim().to_string(); + let sub_state = lines.next().unwrap_or_default().trim().to_string(); + let available = !load_state.is_empty() && load_state != "not-found"; + let enabled = active_state == "active"; + let detail = if available { + format!("{active_state}/{sub_state}") + } else { + "unit not found".to_string() + }; + + Ok(UnitSnapshot { + available, + enabled, + detail, + }) +} + +#[cfg(not(coverage))] +async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> { + let unit = unit.to_string(); + tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled)) + .await + .map_err(|err| anyhow!("capture power sync task failed: {err}"))? +} + +#[cfg(not(coverage))] +fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> { + if capture_power_unit_disabled(unit) { + return Ok(()); + } + + let action = if enabled { "start" } else { "stop" }; + let status = Command::new("systemctl") + .args([action, unit]) + .status() + .with_context(|| format!("running systemctl {action} {unit}"))?; + if status.success() { + Ok(()) + } else { + Err(anyhow!("systemctl {action} {unit} failed with {status}")) + } +} + +#[cfg(not(coverage))] +fn capture_power_unit_disabled(unit: &str) -> bool { + matches!( + unit.trim().to_ascii_lowercase().as_str(), + "0" | "off" | "none" | "disabled" + ) +} + +#[cfg(coverage)] +#[derive(Debug, Clone, Default)] +pub struct CapturePowerManager; + +#[cfg(coverage)] +#[derive(Clone, Default)] +pub struct CapturePowerLease; + +#[cfg(coverage)] +impl CapturePowerManager { + pub fn new() -> Self { + Self + } + + pub async fn acquire(&self) -> CapturePowerLease { + CapturePowerLease + } + + pub async fn acquire_session(&self) -> CapturePowerLease { + CapturePowerLease + } + + pub async fn set_manual(&self, enabled: bool) -> anyhow::Result { + Ok(CapturePowerState { + available: true, + enabled, + unit: "relay.service".to_string(), + detail: if enabled { + "active/running".to_string() + } else { + "inactive/dead".to_string() + }, + active_leases: 0, + mode: if enabled { + "forced-on".to_string() + } else { + "forced-off".to_string() + }, + detected_devices: 0, + }) + } + + pub async fn set_auto(&self) -> anyhow::Result { + Ok(CapturePowerState { + available: true, + enabled: false, + unit: "relay.service".to_string(), + detail: "inactive/dead".to_string(), + active_leases: 0, + mode: "auto".to_string(), + detected_devices: 0, + }) + } + + pub async fn snapshot(&self) -> anyhow::Result { + Ok(CapturePowerState { + available: true, + enabled: false, + unit: "relay.service".to_string(), + detail: "inactive/dead".to_string(), + active_leases: 0, + mode: "auto".to_string(), + detected_devices: 0, + }) + } +} diff --git a/server/src/gadget.rs b/server/src/gadget.rs index d6da0c6..c29d7b9 100644 --- a/server/src/gadget.rs +++ b/server/src/gadget.rs @@ -1,4 +1,4 @@ -// server/src/gadget.rs +// USB gadget state, cycling, and host-enumeration recovery helpers. use anyhow::{Context, Result}; use std::{ env, @@ -18,496 +18,7 @@ pub struct UsbGadget { udc_file: &'static str, } -impl UsbGadget { - fn sysfs_root() -> String { - env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".to_string()) - } - - fn configfs_root() -> String { - env::var("LESAVKA_GADGET_CONFIGFS_ROOT") - .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()) - } - - pub fn new(name: &'static str) -> Self { - Self { - udc_file: Box::leak( - format!("{}/{}{}", Self::configfs_root(), name, "/UDC").into_boxed_str(), - ), - } - } - - pub fn state(ctrl: &str) -> anyhow::Result { - let p = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); - Ok(std::fs::read_to_string(p)?.trim().to_owned()) - } - - pub fn current_controller_state() -> anyhow::Result<(String, String)> { - let ctrl = Self::find_controller()?; - let state = Self::state(&ctrl)?; - Ok((ctrl, state)) - } - - pub fn host_attached_state(state: &str) -> bool { - matches!( - state, - "configured" | "addressed" | "default" | "suspended" | "unknown" - ) - } - - pub fn host_enumerated_state(state: &str) -> bool { - matches!(state, "configured" | "addressed" | "default" | "suspended") - } - - pub fn current_state_detail() -> String { - match Self::current_controller_state() { - Ok((ctrl, state)) => format!("UDC {ctrl} state={state}"), - Err(err) => format!("UDC state unavailable: {err:#}"), - } - } - - /*---- helpers ----*/ - - /// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`) - pub fn find_controller() -> Result { - Ok(fs::read_dir(format!("{}/class/udc", Self::sysfs_root()))? - .next() - .transpose()? - .context("no UDC present")? - .file_name() - .to_string_lossy() - .into_owned()) - } - - /// Busy-loop (≤ `limit_ms`) until `state` matches `wanted` - fn wait_state(ctrl: &str, wanted: &str, limit_ms: u64) -> Result<()> { - let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); - for _ in 0..=limit_ms / 50 { - let s = fs::read_to_string(&path).unwrap_or_default(); - trace!("⏳ state={s:?}, want={wanted}"); - if s.trim() == wanted { - return Ok(()); - } - thread::sleep(Duration::from_millis(50)); - } - Err(anyhow::anyhow!( - "UDC never reached '{wanted}' (last = {:?})", - fs::read_to_string(&path).unwrap_or_default() - )) - } - - pub fn wait_state_any(ctrl: &str, limit_ms: u64) -> anyhow::Result { - let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); - for _ in 0..=limit_ms / 50 { - if let Ok(s) = std::fs::read_to_string(&path) { - let s = s.trim(); - if matches!(s, "configured" | "not attached") { - return Ok(s.to_owned()); - } - } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - Err(anyhow::anyhow!( - "UDC state did not settle within {limit_ms} ms" - )) - } - - /// Write `value` (plus “\n”) into a sysfs attribute - fn write_attr>(p: P, value: &str) -> Result<()> { - OpenOptions::new() - .write(true) - .open(p)? - .write_all(format!("{value}\n").as_bytes())?; - Ok(()) - } - - // Wait (≤ `limit_ms`) until `/sys/class/udc/` exists again. - fn wait_udc_present(ctrl: &str, limit_ms: u64) -> Result<()> { - for _ in 0..=limit_ms / 50 { - if Path::new(&format!("{}/class/udc/{ctrl}", Self::sysfs_root())).exists() { - return Ok(()); - } - thread::sleep(Duration::from_millis(50)); - } - Err(anyhow::anyhow!( - "⚠️ UDC {ctrl} did not re-appear within {limit_ms} ms" - )) - } - - /// Scan platform devices when /sys/class/udc is empty - fn probe_platform_udc() -> Result> { - for entry in fs::read_dir(format!("{}/bus/platform/devices", Self::sysfs_root()))? { - let p = entry?.file_name().into_string().unwrap(); - if p.ends_with(".usb") { - return Ok(Some(p)); - } - } - Ok(None) - } - - /*---- public API ----*/ - - /// Hard-reset the gadget → identical to a physical cable re-plug - #[cfg(coverage)] - pub fn cycle(&self) -> Result<()> { - self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) - } - - #[cfg(coverage)] - pub fn cycle_forced(&self) -> Result<()> { - self.cycle_internal(true) - } - - pub fn recover_enumeration(&self) -> Result<()> { - self.recover_enumeration_internal() - } - - #[cfg(coverage)] - fn cycle_internal(&self, force_cycle: bool) -> Result<()> { - let ctrl = Self::find_controller().or_else(|_| { - Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) - })?; - - if !force_cycle { - match Self::state(&ctrl) { - Ok(state) - if matches!( - state.as_str(), - "configured" | "addressed" | "default" | "suspended" | "unknown" - ) => - { - return Ok(()); - } - Err(_) => return Ok(()), - _ => {} - } - } - - let _ = Self::write_attr(self.udc_file, ""); - let _ = Self::wait_state_any(&ctrl, 3_000); - let _ = Self::rebind_driver(&ctrl); - let _ = Self::wait_udc_present(&ctrl, 3_000); - Self::write_attr(self.udc_file, &ctrl)?; - let _ = Self::wait_state_any(&ctrl, 6_000); - Ok(()) - } - - #[cfg(not(coverage))] - pub fn cycle(&self) -> Result<()> { - self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) - } - - #[cfg(not(coverage))] - pub fn cycle_forced(&self) -> Result<()> { - self.cycle_internal(true) - } - - #[cfg(not(coverage))] - fn cycle_internal(&self, force_cycle: bool) -> Result<()> { - /* 0 - ensure we *know* the controller even after a previous crash */ - let ctrl = Self::find_controller().or_else(|_| { - Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) - })?; - match Self::state(&ctrl) { - Ok(state) - if !force_cycle - && matches!( - state.as_str(), - "configured" | "addressed" | "default" | "suspended" - ) => - { - warn!( - "🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override" - ); - return Ok(()); - } - Ok(state) if !force_cycle && state == "unknown" => { - warn!( - "🔒 refusing gadget cycle with unknown UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" - ); - return Ok(()); - } - Err(_) if !force_cycle => { - warn!( - "🔒 refusing gadget cycle without UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" - ); - return Ok(()); - } - _ => {} - } - - /* 1 - detach gadget */ - info!("🔌 detaching gadget from {ctrl}"); - // a) drop pull-ups (if the controller offers the switch) - let sc = format!("{}/class/udc/{ctrl}/soft_connect", Self::sysfs_root()); - let _ = Self::write_attr(&sc, "0"); // ignore errors - not all HW has it - - // b) clear the UDC attribute; the kernel may transiently answer EBUSY - for attempt in 1..=10 { - match Self::write_attr(self.udc_file, "") { - Ok(_) => break, - Err(err) - if { - // only swallow EBUSY - err.downcast_ref::() - .and_then(|io| io.raw_os_error()) - == Some(libc::EBUSY) - && attempt < 10 - } => - { - trace!("⏳ UDC busy (attempt {attempt}/10) - retrying…"); - thread::sleep(Duration::from_millis(100)); - } - Err(err) => return Err(err), - } - } - Self::wait_state(&ctrl, "not attached", 3_000)?; - - /* 2 - reset driver */ - Self::rebind_driver(&ctrl)?; - - /* 3 - wait UDC node to re-appear */ - Self::wait_udc_present(&ctrl, 3_000)?; - - /* 4 - re-attach + pull-up */ - info!("🔌 re-attaching gadget to {ctrl}"); - Self::write_attr(self.udc_file, &ctrl)?; - if Path::new(&sc).exists() { - // try to set the pull-up; ignore if the kernel rejects it - match Self::write_attr(&sc, "1") { - Err(err) => { - // only swallow specific errno values - if let Some(io) = err.downcast_ref::() { - match io.raw_os_error() { - // EINVAL | EPERM | ENOENT - Some(libc::EINVAL) | Some(libc::EPERM) | Some(libc::ENOENT) => { - warn!("⚠️ soft_connect unsupported ({io}); continuing"); - } - _ => return Err(err), // propagate all other errors - } - } else { - return Err(err); // non-IO errors: propagate - } - } - Ok(_) => { /* success */ } - } - } - - /* 5 - wait for host (but tolerate sleep) */ - Self::wait_state(&ctrl, "configured", 6_000).or_else(|e| { - // If the host is physically absent (sleep / KVM paused) - // we allow 'not attached' and continue - we can still - // accept keyboard/mouse data and the host will enumerate - // later without another reset. - let last = fs::read_to_string(format!("{}/class/udc/{ctrl}/state", Self::sysfs_root())) - .unwrap_or_default(); - if last.trim() == "not attached" { - warn!("⚠️ host did not enumerate within 6 s - continuing (state = {last:?})"); - Ok(()) - } else { - Err(e) - } - })?; - - info!("✅ USB-gadget cycle complete"); - Ok(()) - } - - /// helper: unbind + 300 ms reset + bind - #[cfg(coverage)] - fn rebind_driver(ctrl: &str) -> Result<()> { - for drv in ["dwc2", "dwc3"] { - let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); - if !Path::new(&root).exists() { - continue; - } - Self::write_attr(format!("{root}/unbind"), ctrl)?; - Self::write_attr(format!("{root}/bind"), ctrl)?; - return Ok(()); - } - Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) - } - - #[cfg(not(coverage))] - fn rebind_driver(ctrl: &str) -> Result<()> { - let cand = ["dwc2", "dwc3"]; - for drv in cand { - let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); - if !Path::new(&root).exists() { - continue; - } - - /*----------- unbind ------------------------------------------------*/ - info!("🔧 unbinding UDC driver ({drv})"); - for attempt in 1..=20 { - match Self::write_attr(format!("{root}/unbind"), ctrl) { - Ok(_) => break, - Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { - trace!("unbind in-progress (#{attempt}) - waiting…"); - thread::sleep(Duration::from_millis(100)); - } - Err(err) => return Err(err).context("UDC unbind failed irrecoverably"), - } - } - thread::sleep(Duration::from_millis(150)); // let the core quiesce - - /*----------- bind --------------------------------------------------*/ - info!("🔧 binding UDC driver ({drv})"); - for attempt in 1..=20 { - match Self::write_attr(format!("{root}/bind"), ctrl) { - Ok(_) => return Ok(()), // success 🎉 - Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { - trace!("bind busy (#{attempt}) - retrying…"); - thread::sleep(Duration::from_millis(100)); - } - Err(err) => return Err(err).context("UDC bind failed irrecoverably"), - } - } - } - Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) - } - - fn is_still_detaching(err: &anyhow::Error) -> bool { - err.downcast_ref::() - .and_then(|io| io.raw_os_error()) - .map_or(false, |code| { - matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV) - }) - } - - fn recover_enumeration_internal(&self) -> Result<()> { - let mut steps = Vec::new(); - steps.push(format!("initial {}", Self::current_state_detail())); - - let cycle_ok = match self.cycle_forced() { - Ok(()) => { - steps.push("forced UDC cycle succeeded".to_string()); - true - } - Err(err) => { - steps.push(format!("forced UDC cycle failed: {err:#}")); - false - } - }; - if cycle_ok { - if let Some((ctrl, state)) = - Self::wait_for_host_attach(Self::recovery_wait_ms("CYCLE", 2_000)) - { - info!("✅ USB host enumerated after UDC cycle ctrl={ctrl} state={state}"); - return Ok(()); - } - } - - if !Self::rebuild_helper_available() { - anyhow::bail!( - "USB gadget recovery cannot continue because no UDC/controller is available for forced rebuild; {}", - steps.join("; ") - ); - } - - match self.run_forced_core_rebuild() { - Ok(summary) => steps.push(summary), - Err(err) => steps.push(format!("forced core rebuild failed: {err:#}")), - } - if let Some((ctrl, state)) = - Self::wait_for_host_attach(Self::recovery_wait_ms("REBUILD", 8_000)) - { - info!("✅ USB host enumerated after forced gadget rebuild ctrl={ctrl} state={state}"); - return Ok(()); - } - - match self.cycle_forced() { - Ok(()) => steps.push("post-rebuild UDC cycle succeeded".to_string()), - Err(err) => steps.push(format!("post-rebuild UDC cycle failed: {err:#}")), - } - if let Some((ctrl, state)) = - Self::wait_for_host_attach(Self::recovery_wait_ms("FINAL", 4_000)) - { - info!("✅ USB host enumerated after post-rebuild UDC cycle ctrl={ctrl} state={state}"); - return Ok(()); - } - - anyhow::bail!( - "USB gadget is still not attached after aggressive recovery; current {}; steps: {}", - Self::current_state_detail(), - steps.join("; ") - ) - } - - fn rebuild_helper_available() -> bool { - Self::find_controller().is_ok() - || matches!(Self::probe_platform_udc(), Ok(Some(_))) - || env::var("LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC").is_ok() - } - - fn wait_for_host_attach(limit_ms: u64) -> Option<(String, String)> { - for _ in 0..=limit_ms / 100 { - if let Ok((ctrl, state)) = Self::current_controller_state() { - if Self::host_enumerated_state(&state) { - return Some((ctrl, state)); - } - } - thread::sleep(Duration::from_millis(100)); - } - None - } - - fn recovery_wait_ms(step: &str, default_ms: u64) -> u64 { - env::var(format!("LESAVKA_USB_RECOVERY_{step}_WAIT_MS")) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(default_ms) - } - - fn core_helper_path() -> String { - env::var("LESAVKA_CORE_HELPER").unwrap_or_else(|_| "/usr/local/bin/lesavka-core.sh".into()) - } - - fn run_forced_core_rebuild(&self) -> Result { - let helper = Self::core_helper_path(); - let output = Command::new(&helper) - .env("LESAVKA_ALLOW_GADGET_RESET", "1") - .env("LESAVKA_ATTACH_WRITE_UDC", "1") - .env("LESAVKA_DETACH_CLEAR_UDC", "1") - .env("LESAVKA_RELOAD_UVCVIDEO", "1") - .env("LESAVKA_UVC_FALLBACK", "1") - .env( - "LESAVKA_UVC_CODEC", - env::var("LESAVKA_UVC_CODEC").unwrap_or_else(|_| "mjpeg".to_string()), - ) - .output() - .with_context(|| format!("running {helper} with forced gadget rebuild"))?; - - let stdout = Self::tail_text(&output.stdout); - let stderr = Self::tail_text(&output.stderr); - if !output.status.success() { - anyhow::bail!( - "forced gadget rebuild helper exited with {}; stderr: {}; stdout: {}", - output.status, - stderr, - stdout - ); - } - - Ok(format!( - "forced gadget rebuild helper succeeded: stderr: {}; stdout: {}", - stderr, stdout - )) - } - - fn tail_text(bytes: &[u8]) -> String { - let text = String::from_utf8_lossy(bytes).trim().to_string(); - const LIMIT: usize = 1_200; - if text.chars().count() <= LIMIT { - return text; - } - let tail: String = text - .chars() - .rev() - .take(LIMIT) - .collect::() - .chars() - .rev() - .collect(); - format!("...{tail}") - } -} +include!("gadget/sysfs_state.rs"); +include!("gadget/cycle_control.rs"); +include!("gadget/driver_rebind.rs"); +include!("gadget/enumeration_recovery.rs"); diff --git a/server/src/gadget/cycle_control.rs b/server/src/gadget/cycle_control.rs new file mode 100644 index 0000000..04ded6b --- /dev/null +++ b/server/src/gadget/cycle_control.rs @@ -0,0 +1,168 @@ +impl UsbGadget { + /// Hard-reset the gadget → identical to a physical cable re-plug + #[cfg(coverage)] + pub fn cycle(&self) -> Result<()> { + self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) + } + + #[cfg(coverage)] + pub fn cycle_forced(&self) -> Result<()> { + self.cycle_internal(true) + } + + pub fn recover_enumeration(&self) -> Result<()> { + self.recover_enumeration_internal() + } + + #[cfg(coverage)] + fn cycle_internal(&self, force_cycle: bool) -> Result<()> { + let ctrl = Self::find_controller().or_else(|_| { + Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) + })?; + + if !force_cycle { + match Self::state(&ctrl) { + Ok(state) + if matches!( + state.as_str(), + "configured" | "addressed" | "default" | "suspended" | "unknown" + ) => + { + return Ok(()); + } + Err(_) => return Ok(()), + _ => {} + } + } + + let _ = Self::write_attr(self.udc_file, ""); + let _ = Self::wait_state_any(&ctrl, 3_000); + let _ = Self::rebind_driver(&ctrl); + let _ = Self::wait_udc_present(&ctrl, 3_000); + Self::write_attr(self.udc_file, &ctrl)?; + let _ = Self::wait_state_any(&ctrl, 6_000); + Ok(()) + } + + #[cfg(not(coverage))] + pub fn cycle(&self) -> Result<()> { + self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) + } + + #[cfg(not(coverage))] + pub fn cycle_forced(&self) -> Result<()> { + self.cycle_internal(true) + } + + #[cfg(not(coverage))] + fn cycle_internal(&self, force_cycle: bool) -> Result<()> { + /* 0 - ensure we *know* the controller even after a previous crash */ + let ctrl = Self::find_controller().or_else(|_| { + Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) + })?; + match Self::state(&ctrl) { + Ok(state) + if !force_cycle + && matches!( + state.as_str(), + "configured" | "addressed" | "default" | "suspended" + ) => + { + warn!( + "🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override" + ); + return Ok(()); + } + Ok(state) if !force_cycle && state == "unknown" => { + warn!( + "🔒 refusing gadget cycle with unknown UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" + ); + return Ok(()); + } + Err(_) if !force_cycle => { + warn!( + "🔒 refusing gadget cycle without UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override" + ); + return Ok(()); + } + _ => {} + } + + /* 1 - detach gadget */ + info!("🔌 detaching gadget from {ctrl}"); + // a) drop pull-ups (if the controller offers the switch) + let sc = format!("{}/class/udc/{ctrl}/soft_connect", Self::sysfs_root()); + let _ = Self::write_attr(&sc, "0"); // ignore errors - not all HW has it + + // b) clear the UDC attribute; the kernel may transiently answer EBUSY + for attempt in 1..=10 { + match Self::write_attr(self.udc_file, "") { + Ok(_) => break, + Err(err) + if { + // only swallow EBUSY + err.downcast_ref::() + .and_then(|io| io.raw_os_error()) + == Some(libc::EBUSY) + && attempt < 10 + } => + { + trace!("⏳ UDC busy (attempt {attempt}/10) - retrying…"); + thread::sleep(Duration::from_millis(100)); + } + Err(err) => return Err(err), + } + } + Self::wait_state(&ctrl, "not attached", 3_000)?; + + /* 2 - reset driver */ + Self::rebind_driver(&ctrl)?; + + /* 3 - wait UDC node to re-appear */ + Self::wait_udc_present(&ctrl, 3_000)?; + + /* 4 - re-attach + pull-up */ + info!("🔌 re-attaching gadget to {ctrl}"); + Self::write_attr(self.udc_file, &ctrl)?; + if Path::new(&sc).exists() { + // try to set the pull-up; ignore if the kernel rejects it + match Self::write_attr(&sc, "1") { + Err(err) => { + // only swallow specific errno values + if let Some(io) = err.downcast_ref::() { + match io.raw_os_error() { + // EINVAL | EPERM | ENOENT + Some(libc::EINVAL) | Some(libc::EPERM) | Some(libc::ENOENT) => { + warn!("⚠️ soft_connect unsupported ({io}); continuing"); + } + _ => return Err(err), // propagate all other errors + } + } else { + return Err(err); // non-IO errors: propagate + } + } + Ok(_) => { /* success */ } + } + } + + /* 5 - wait for host (but tolerate sleep) */ + Self::wait_state(&ctrl, "configured", 6_000).or_else(|e| { + // If the host is physically absent (sleep / KVM paused) + // we allow 'not attached' and continue - we can still + // accept keyboard/mouse data and the host will enumerate + // later without another reset. + let last = fs::read_to_string(format!("{}/class/udc/{ctrl}/state", Self::sysfs_root())) + .unwrap_or_default(); + if last.trim() == "not attached" { + warn!("⚠️ host did not enumerate within 6 s - continuing (state = {last:?})"); + Ok(()) + } else { + Err(e) + } + })?; + + info!("✅ USB-gadget cycle complete"); + Ok(()) + } + +} diff --git a/server/src/gadget/driver_rebind.rs b/server/src/gadget/driver_rebind.rs new file mode 100644 index 0000000..cd60ca6 --- /dev/null +++ b/server/src/gadget/driver_rebind.rs @@ -0,0 +1,64 @@ +impl UsbGadget { + /// helper: unbind + 300 ms reset + bind + #[cfg(coverage)] + fn rebind_driver(ctrl: &str) -> Result<()> { + for drv in ["dwc2", "dwc3"] { + let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); + if !Path::new(&root).exists() { + continue; + } + Self::write_attr(format!("{root}/unbind"), ctrl)?; + Self::write_attr(format!("{root}/bind"), ctrl)?; + return Ok(()); + } + Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) + } + + #[cfg(not(coverage))] + fn rebind_driver(ctrl: &str) -> Result<()> { + let cand = ["dwc2", "dwc3"]; + for drv in cand { + let root = format!("{}/bus/platform/drivers/{drv}", Self::sysfs_root()); + if !Path::new(&root).exists() { + continue; + } + + /*----------- unbind ------------------------------------------------*/ + info!("🔧 unbinding UDC driver ({drv})"); + for attempt in 1..=20 { + match Self::write_attr(format!("{root}/unbind"), ctrl) { + Ok(_) => break, + Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { + trace!("unbind in-progress (#{attempt}) - waiting…"); + thread::sleep(Duration::from_millis(100)); + } + Err(err) => return Err(err).context("UDC unbind failed irrecoverably"), + } + } + thread::sleep(Duration::from_millis(150)); // let the core quiesce + + /*----------- bind --------------------------------------------------*/ + info!("🔧 binding UDC driver ({drv})"); + for attempt in 1..=20 { + match Self::write_attr(format!("{root}/bind"), ctrl) { + Ok(_) => return Ok(()), // success 🎉 + Err(err) if attempt < 20 && Self::is_still_detaching(&err) => { + trace!("bind busy (#{attempt}) - retrying…"); + thread::sleep(Duration::from_millis(100)); + } + Err(err) => return Err(err).context("UDC bind failed irrecoverably"), + } + } + } + Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found")) + } + + fn is_still_detaching(err: &anyhow::Error) -> bool { + err.downcast_ref::() + .and_then(|io| io.raw_os_error()) + .is_some_and(|code| { + matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV) + }) + } + +} diff --git a/server/src/gadget/enumeration_recovery.rs b/server/src/gadget/enumeration_recovery.rs new file mode 100644 index 0000000..1463c37 --- /dev/null +++ b/server/src/gadget/enumeration_recovery.rs @@ -0,0 +1,137 @@ +impl UsbGadget { + fn recover_enumeration_internal(&self) -> Result<()> { + let mut steps = Vec::new(); + steps.push(format!("initial {}", Self::current_state_detail())); + + let cycle_ok = match self.cycle_forced() { + Ok(()) => { + steps.push("forced UDC cycle succeeded".to_string()); + true + } + Err(err) => { + steps.push(format!("forced UDC cycle failed: {err:#}")); + false + } + }; + if cycle_ok + && let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("CYCLE", 2_000)) + { + info!("✅ USB host enumerated after UDC cycle ctrl={ctrl} state={state}"); + return Ok(()); + } + + if !Self::rebuild_helper_available() { + anyhow::bail!( + "USB gadget recovery cannot continue because no UDC/controller is available for forced rebuild; {}", + steps.join("; ") + ); + } + + match self.run_forced_core_rebuild() { + Ok(summary) => steps.push(summary), + Err(err) => steps.push(format!("forced core rebuild failed: {err:#}")), + } + if let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("REBUILD", 8_000)) + { + info!("✅ USB host enumerated after forced gadget rebuild ctrl={ctrl} state={state}"); + return Ok(()); + } + + match self.cycle_forced() { + Ok(()) => steps.push("post-rebuild UDC cycle succeeded".to_string()), + Err(err) => steps.push(format!("post-rebuild UDC cycle failed: {err:#}")), + } + if let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("FINAL", 4_000)) + { + info!("✅ USB host enumerated after post-rebuild UDC cycle ctrl={ctrl} state={state}"); + return Ok(()); + } + + anyhow::bail!( + "USB gadget is still not attached after aggressive recovery; current {}; steps: {}", + Self::current_state_detail(), + steps.join("; ") + ) + } + + fn rebuild_helper_available() -> bool { + Self::find_controller().is_ok() + || matches!(Self::probe_platform_udc(), Ok(Some(_))) + || env::var("LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC").is_ok() + } + + fn wait_for_host_attach(limit_ms: u64) -> Option<(String, String)> { + for _ in 0..=limit_ms / 100 { + if let Ok((ctrl, state)) = Self::current_controller_state() + && Self::host_enumerated_state(&state) { + return Some((ctrl, state)); + } + thread::sleep(Duration::from_millis(100)); + } + None + } + + fn recovery_wait_ms(step: &str, default_ms: u64) -> u64 { + env::var(format!("LESAVKA_USB_RECOVERY_{step}_WAIT_MS")) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_ms) + } + + fn core_helper_path() -> String { + env::var("LESAVKA_CORE_HELPER").unwrap_or_else(|_| "/usr/local/bin/lesavka-core.sh".into()) + } + + fn run_forced_core_rebuild(&self) -> Result { + let helper = Self::core_helper_path(); + let output = Command::new(&helper) + .env("LESAVKA_ALLOW_GADGET_RESET", "1") + .env("LESAVKA_ATTACH_WRITE_UDC", "1") + .env("LESAVKA_DETACH_CLEAR_UDC", "1") + .env("LESAVKA_RELOAD_UVCVIDEO", "1") + .env("LESAVKA_UVC_FALLBACK", "1") + .env( + "LESAVKA_UVC_CODEC", + env::var("LESAVKA_UVC_CODEC").unwrap_or_else(|_| "mjpeg".to_string()), + ) + .output() + .with_context(|| format!("running {helper} with forced gadget rebuild"))?; + + let stdout = Self::tail_text(&output.stdout); + let stderr = Self::tail_text(&output.stderr); + if !output.status.success() { + anyhow::bail!( + "forced gadget rebuild helper exited with {}; stderr: {}; stdout: {}", + output.status, + stderr, + stdout + ); + } + + Ok(format!( + "forced gadget rebuild helper succeeded: stderr: {}; stdout: {}", + stderr, stdout + )) + } + + fn tail_text(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + const LIMIT: usize = 1_200; + if text.chars().count() <= LIMIT { + return text; + } + let tail: String = text + .chars() + .rev() + .take(LIMIT) + .collect::() + .chars() + .rev() + .collect(); + format!("...{tail}") + } + +} diff --git a/server/src/gadget/sysfs_state.rs b/server/src/gadget/sysfs_state.rs new file mode 100644 index 0000000..7c0d99c --- /dev/null +++ b/server/src/gadget/sysfs_state.rs @@ -0,0 +1,127 @@ +impl UsbGadget { + fn sysfs_root() -> String { + env::var("LESAVKA_GADGET_SYSFS_ROOT").unwrap_or_else(|_| "/sys".to_string()) + } + + fn configfs_root() -> String { + env::var("LESAVKA_GADGET_CONFIGFS_ROOT") + .unwrap_or_else(|_| "/sys/kernel/config/usb_gadget".to_string()) + } + + pub fn new(name: &'static str) -> Self { + Self { + udc_file: Box::leak( + format!("{}/{}{}", Self::configfs_root(), name, "/UDC").into_boxed_str(), + ), + } + } + + pub fn state(ctrl: &str) -> anyhow::Result { + let p = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); + Ok(std::fs::read_to_string(p)?.trim().to_owned()) + } + + pub fn current_controller_state() -> anyhow::Result<(String, String)> { + let ctrl = Self::find_controller()?; + let state = Self::state(&ctrl)?; + Ok((ctrl, state)) + } + + pub fn host_attached_state(state: &str) -> bool { + matches!( + state, + "configured" | "addressed" | "default" | "suspended" | "unknown" + ) + } + + pub fn host_enumerated_state(state: &str) -> bool { + matches!(state, "configured" | "addressed" | "default" | "suspended") + } + + pub fn current_state_detail() -> String { + match Self::current_controller_state() { + Ok((ctrl, state)) => format!("UDC {ctrl} state={state}"), + Err(err) => format!("UDC state unavailable: {err:#}"), + } + } + + /*---- helpers ----*/ + + /// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`) + pub fn find_controller() -> Result { + Ok(fs::read_dir(format!("{}/class/udc", Self::sysfs_root()))? + .next() + .transpose()? + .context("no UDC present")? + .file_name() + .to_string_lossy() + .into_owned()) + } + + /// Busy-loop (≤ `limit_ms`) until `state` matches `wanted` + fn wait_state(ctrl: &str, wanted: &str, limit_ms: u64) -> Result<()> { + let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); + for _ in 0..=limit_ms / 50 { + let s = fs::read_to_string(&path).unwrap_or_default(); + trace!("⏳ state={s:?}, want={wanted}"); + if s.trim() == wanted { + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err(anyhow::anyhow!( + "UDC never reached '{wanted}' (last = {:?})", + fs::read_to_string(&path).unwrap_or_default() + )) + } + + pub fn wait_state_any(ctrl: &str, limit_ms: u64) -> anyhow::Result { + let path = format!("{}/class/udc/{ctrl}/state", Self::sysfs_root()); + for _ in 0..=limit_ms / 50 { + if let Ok(s) = std::fs::read_to_string(&path) { + let s = s.trim(); + if matches!(s, "configured" | "not attached") { + return Ok(s.to_owned()); + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(anyhow::anyhow!( + "UDC state did not settle within {limit_ms} ms" + )) + } + + /// Write `value` (plus “\n”) into a sysfs attribute + fn write_attr>(p: P, value: &str) -> Result<()> { + OpenOptions::new() + .write(true) + .open(p)? + .write_all(format!("{value}\n").as_bytes())?; + Ok(()) + } + + // Wait (≤ `limit_ms`) until `/sys/class/udc/` exists again. + fn wait_udc_present(ctrl: &str, limit_ms: u64) -> Result<()> { + for _ in 0..=limit_ms / 50 { + if Path::new(&format!("{}/class/udc/{ctrl}", Self::sysfs_root())).exists() { + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err(anyhow::anyhow!( + "⚠️ UDC {ctrl} did not re-appear within {limit_ms} ms" + )) + } + + /// Scan platform devices when /sys/class/udc is empty + fn probe_platform_udc() -> Result> { + for entry in fs::read_dir(format!("{}/bus/platform/devices", Self::sysfs_root()))? { + let p = entry?.file_name().into_string().unwrap(); + if p.ends_with(".usb") { + return Ok(Some(p)); + } + } + Ok(None) + } + +} diff --git a/server/src/main.rs b/server/src/main.rs index 6980e5a..e2800bd 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,5 +1,4 @@ -// lesavka-server - gadget cycle guarded by env -// server/src/main.rs +// Server binary for relay RPCs, HID/audio/video gadget IO, and capture leasing. #[allow(clippy::useless_attribute)] #[forbid(unsafe_code)] use anyhow::Context; @@ -85,940 +84,12 @@ struct EyeHub { abort: tokio::task::AbortHandle, } -impl Handler { - async fn new(gadget: UsbGadget) -> anyhow::Result { - #[cfg(not(coverage))] - if runtime_support::allow_gadget_cycle() { - info!("🛠️ Initial USB recovery…"); - match UsbGadget::current_controller_state() { - Ok((ctrl, state)) if !UsbGadget::host_enumerated_state(&state) => { - warn!("⚠️ UDC {ctrl} is {state}; forcing gadget recovery before opening HID"); - if let Err(error) = gadget.recover_enumeration() { - warn!("⚠️ initial USB recovery did not enumerate the host: {error:#}"); - } - } - Ok(_) => { - let _ = gadget.cycle(); // ignore failure - may boot without host - } - Err(error) => { - warn!("⚠️ UDC state unavailable during startup: {error:#}"); - let _ = gadget.cycle(); // preserve the old best-effort startup path - } - } - } - #[cfg(not(coverage))] - { - if !runtime_support::allow_gadget_cycle() { - info!( - "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" - ); - } - info!("🛠️ opening HID endpoints …"); - } - let kb_path = hid_endpoint(0); - let ms_path = hid_endpoint(1); - let kb = runtime_support::open_hid_if_ready(&kb_path).await?; - let ms = runtime_support::open_hid_if_ready(&ms_path).await?; - #[cfg(not(coverage))] - if kb.is_some() && ms.is_some() { - info!("✅ HID endpoints ready"); - } else { - warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily"); - } - - Ok(Self { - kb: Arc::new(Mutex::new(kb)), - ms: Arc::new(Mutex::new(ms)), - gadget, - did_cycle: Arc::new(AtomicBool::new(false)), - camera_rt: Arc::new(CameraRuntime::new()), - capture_power: CapturePowerManager::new(), - eye_hubs: Arc::new(Mutex::new(HashMap::new())), - }) - } - - async fn reopen_hid(&self) -> anyhow::Result<()> { - let kb_new = runtime_support::open_hid_if_ready(&hid_endpoint(0)).await?; - let ms_new = runtime_support::open_hid_if_ready(&hid_endpoint(1)).await?; - *self.kb.lock().await = kb_new; - *self.ms.lock().await = ms_new; - Ok(()) - } - - fn detected_capture_devices_from_symlinks() -> u32 { - ["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"] - .into_iter() - .filter(|path| { - std::fs::metadata(path) - .ok() - .is_some_and(|metadata| metadata.file_type().is_char_device()) - }) - .count() as u32 - } - - #[cfg(not(coverage))] - fn detected_capture_devices_from_udev() -> u32 { - let Ok(mut enumerator) = udev::Enumerator::new() else { - return 0; - }; - let _ = enumerator.match_subsystem("video4linux"); - let Ok(devices) = enumerator.scan_devices() else { - return 0; - }; - devices - .filter(|device| { - device - .attribute_value("index") - .and_then(|value| value.to_str()) - == Some("0") - && device - .property_value("ID_VENDOR_ID") - .and_then(|value| value.to_str()) - == Some("07ca") - && device - .property_value("ID_MODEL_ID") - .and_then(|value| value.to_str()) - == Some("3311") - }) - .count() - .min(2) as u32 - } - - #[cfg(coverage)] - fn detected_capture_devices_from_udev() -> u32 { - std::env::var("LESAVKA_TEST_UDEV_CAPTURE_DEVICES") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(0) - .min(2) - } - - async fn active_eye_source_count(&self) -> u32 { - self.eye_hubs - .lock() - .await - .iter() - .filter_map(|(key, hub)| hub.running.load(Ordering::Relaxed).then_some(key.source_id)) - .collect::>() - .len() - .min(2) as u32 - } - - async fn with_detected_capture_devices( - &self, - mut state: CapturePowerState, - ) -> CapturePowerState { - state.detected_devices = Self::detected_capture_devices_from_udev() - .max(Self::detected_capture_devices_from_symlinks()) - .max(self.active_eye_source_count().await); - state - } - - async fn capture_video_reply( - &self, - req: MonitorRequest, - ) -> Result, Status> { - let id = req.id; - if id > 1 { - return Err(Status::invalid_argument("monitor id must be 0 or 1")); - } - let source_id = req.source_id.unwrap_or(id); - if source_id > 1 { - return Err(Status::invalid_argument("source id must be 0 or 1")); - } - let dev = if source_id == 0 { - "/dev/lesavka_l_eye" - } else { - "/dev/lesavka_r_eye" - }; - - #[cfg(not(coverage))] - { - let rpc_id = runtime_support::next_stream_id(); - info!( - rpc_id, - id, - source_id, - max_bitrate = req.max_bitrate, - requested_width = req.requested_width, - requested_height = req.requested_height, - requested_fps = req.requested_fps, - "🎥 capture_video opened" - ); - debug!(rpc_id, "🎥 streaming {dev}"); - } - - let hub = self - .eye_hub( - dev, - EyeHubKey { - source_id, - requested_width: req.requested_width, - requested_height: req.requested_height, - requested_fps: req.requested_fps, - }, - req.max_bitrate, - ) - .await?; - - let mut hub_rx = hub.tx.subscribe(); - hub.subscribers.fetch_add(1, Ordering::AcqRel); - let subscribers = Arc::clone(&hub.subscribers); - let hub_for_task = Arc::clone(&hub); - let (tx, rx) = tokio::sync::mpsc::channel(32); - tokio::spawn(async move { - loop { - match hub_rx.recv().await { - Ok(mut pkt) => { - pkt.id = id; - if tx.send(Ok(pkt)).await.is_err() { - break; - } - } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - if subscribers.fetch_sub(1, Ordering::AcqRel) == 1 { - hub_for_task.shutdown(); - } - }); - - Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) - } - - async fn eye_hub( - &self, - dev: &str, - key: EyeHubKey, - max_bitrate_kbit: u32, - ) -> Result, Status> { - let stale_hubs = { - let mut hubs = self.eye_hubs.lock().await; - if let Some(hub) = hubs.get(&key) - && hub.running.load(Ordering::Relaxed) - { - return Ok(Arc::clone(hub)); - } - take_conflicting_eye_hubs(&mut hubs, key) - }; - #[cfg(not(coverage))] - if !stale_hubs.is_empty() { - info!( - source_id = key.source_id, - requested_width = key.requested_width, - requested_height = key.requested_height, - requested_fps = key.requested_fps, - stale_hubs = stale_hubs.len(), - "🎥 replacing stale/conflicting eye hubs before opening the source" - ); - } - for hub in stale_hubs { - hub.shutdown(); - } - - let lease = self.capture_power.acquire().await; - let stream = video::eye_ball_with_request( - dev, - key.source_id, - max_bitrate_kbit, - key.requested_width, - key.requested_height, - key.requested_fps, - ) - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - - let hub = EyeHub::spawn(stream, lease); - let mut hubs = self.eye_hubs.lock().await; - #[cfg(not(coverage))] - if let Some(existing) = hubs.get(&key) - && existing.running.load(Ordering::Relaxed) - { - hub.shutdown(); - return Ok(Arc::clone(existing)); - } - hubs.insert(key, Arc::clone(&hub)); - Ok(hub) - } - - #[cfg(test)] - async fn eye_hub_count(&self) -> usize { - self.eye_hubs.lock().await.len() - } - - async fn paste_text_reply( - &self, - req: Request, - ) -> Result, Status> { - let req = req.into_inner(); - let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; - if let Err(e) = paste::type_text(&self.kb, &hid_endpoint(0), &text).await { - return Ok(Response::new(PasteReply { - ok: false, - error: format!("{e}"), - })); - } - Ok(Response::new(PasteReply { - ok: true, - error: String::new(), - })) - } - - async fn reset_usb_reply(&self) -> Result, Status> { - #[cfg(not(coverage))] - info!("🔴 explicit ResetUsb() called"); - - match self.gadget.recover_enumeration() { - Ok(_) => { - if let Err(e) = self.reopen_hid().await { - #[cfg(not(coverage))] - error!("💥 reopen HID failed: {e:#}"); - return Err(Status::internal(e.to_string())); - } - if let Err(e) = restart_uvc_helper() { - #[cfg(not(coverage))] - error!("💥 restart UVC helper failed: {e:#}"); - return Err(Status::internal(e.to_string())); - } - match current_controller_state_after_recovery() { - Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => { - #[cfg(not(coverage))] - info!( - "✅ USB host enumerated gadget after recovery ctrl={ctrl} state={state}" - ); - } - Ok((ctrl, state)) => { - let message = format!( - "USB gadget recovery ran, but UDC {ctrl} is still {state}; the controlled host has not enumerated the relay HID/audio/video gadget" - ); - #[cfg(not(coverage))] - warn!("⚠️ {message}"); - return Err(Status::failed_precondition(message)); - } - Err(err) => { - let message = format!( - "USB gadget recovery ran, but the relay cannot read UDC state: {err:#}" - ); - #[cfg(not(coverage))] - warn!("⚠️ {message}"); - return Err(Status::failed_precondition(message)); - } - } - Ok(Response::new(ResetUsbReply { ok: true })) - } - Err(e) => { - #[cfg(not(coverage))] - error!("💥 USB recovery failed: {e:#}"); - let message = format!("{e:#}"); - if message.contains("still not attached") || message.contains("not attached") { - Err(Status::failed_precondition(message)) - } else { - Err(Status::internal(message)) - } - } - } - } - - async fn get_capture_power_reply(&self) -> Result, Status> { - let state = self - .capture_power - .snapshot() - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new( - self.with_detected_capture_devices(state).await, - )) - } - - async fn set_capture_power_reply( - &self, - req: Request, - ) -> Result, Status> { - let req = req.into_inner(); - let result = match CapturePowerCommand::try_from(req.command) - .unwrap_or(CapturePowerCommand::Unspecified) - { - CapturePowerCommand::Auto => self.capture_power.set_auto().await, - CapturePowerCommand::ForceOn => self.capture_power.set_manual(true).await, - CapturePowerCommand::ForceOff => self.capture_power.set_manual(false).await, - CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await, - }; - let state = result.map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new( - self.with_detected_capture_devices(state).await, - )) - } -} - -fn current_controller_state_after_recovery() -> anyhow::Result<(String, String)> { - #[cfg(coverage)] - { - if std::env::var("LESAVKA_TEST_RECOVERY_STATE_ERROR").is_ok() { - anyhow::bail!("forced recovery state read failure"); - } - if let Ok(state) = std::env::var("LESAVKA_TEST_RECOVERY_STATE") { - return Ok(("coverage-ctrl".to_string(), state)); - } - } - UsbGadget::current_controller_state() -} - -fn restart_uvc_helper() -> anyhow::Result<()> { - #[cfg(coverage)] - if let Ok(message) = std::env::var("LESAVKA_TEST_UVC_HELPER_RESTART_ERR") { - anyhow::bail!("{message}"); - } - if std::env::var("LESAVKA_GADGET_SYSFS_ROOT").is_ok() - || std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT").is_ok() - { - return Ok(()); - } - - run_systemctl(&["reset-failed", "lesavka-uvc.service"])?; - match run_systemctl(&["restart", "lesavka-uvc.service"]) { - Ok(()) => Ok(()), - Err(err) if uvc_helper_restart_was_dependency_refused(&err.to_string()) => { - warn!( - "lesavka-uvc.service refused a direct restart because it is dependency-managed; USB reset already cycled the gadget" - ); - Ok(()) - } - Err(err) => Err(err), - } -} - -fn run_systemctl(args: &[&str]) -> anyhow::Result<()> { - let output = Command::new("systemctl") - .args(args) - .output() - .with_context(|| format!("running systemctl {}", args.join(" ")))?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - anyhow::bail!( - "systemctl {} failed: {}{}", - args.join(" "), - if stderr.is_empty() { - stdout.as_str() - } else { - stderr.as_str() - }, - if stderr.is_empty() || stdout.is_empty() || stderr == stdout { - "" - } else { - " / also see stdout" - } - ); -} - -fn uvc_helper_restart_was_dependency_refused(message: &str) -> bool { - message.contains("Operation refused") || message.contains("may be requested by dependency only") -} - -impl EyeHub { - fn spawn(mut stream: S, lease: lesavka_server::capture_power::CapturePowerLease) -> Arc - where - S: Stream> + Unpin + Send + 'static, - { - let (tx, _) = broadcast::channel(32); - let running = Arc::new(AtomicBool::new(true)); - let subscribers = Arc::new(AtomicUsize::new(0)); - let tx_for_task = tx.clone(); - let running_for_task = Arc::clone(&running); - let subscribers_for_task = Arc::clone(&subscribers); - let task = tokio::spawn(async move { - let _lease = lease; - let mut idle_ticks = 0_u32; - while running_for_task.load(Ordering::Relaxed) { - match stream.next().await { - Some(Ok(pkt)) => { - let _ = tx_for_task.send(pkt); - if subscribers_for_task.load(Ordering::Relaxed) == 0 { - idle_ticks = idle_ticks.saturating_add(1); - if idle_ticks >= 60 { - break; - } - } else { - idle_ticks = 0; - } - } - Some(Err(err)) => { - warn!(?err, "shared eye hub stream error"); - break; - } - None => break, - } - } - running_for_task.store(false, Ordering::Relaxed); - }); - let hub = Arc::new(Self { - tx: tx.clone(), - running: Arc::clone(&running), - subscribers: Arc::clone(&subscribers), - abort: task.abort_handle(), - }); - - hub - } - - fn shutdown(&self) { - if self.running.swap(false, Ordering::AcqRel) { - self.abort.abort(); - } - } -} - -fn take_conflicting_eye_hubs( - hubs: &mut HashMap>, - key: EyeHubKey, -) -> Vec> { - let stale_keys: Vec<_> = hubs - .iter() - .filter_map(|(existing_key, hub)| { - let running = hub.running.load(Ordering::Relaxed); - let conflicting_source = - existing_key.source_id == key.source_id && *existing_key != key; - if !running || conflicting_source { - Some(*existing_key) - } else { - None - } - }) - .collect(); - - stale_keys - .into_iter() - .filter_map(|existing_key| hubs.remove(&existing_key)) - .collect() -} - -/*──────────────── gRPC service ─────────────*/ -#[cfg(not(coverage))] -#[tonic::async_trait] -impl Relay for Handler { - /* existing streams ─ unchanged, except: no more auto-reset */ - type StreamKeyboardStream = ReceiverStream>; - type StreamMouseStream = ReceiverStream>; - type CaptureVideoStream = VideoStream; - type CaptureAudioStream = AudioStream; - type StreamMicrophoneStream = ReceiverStream>; - type StreamCameraStream = ReceiverStream>; - - async fn stream_keyboard( - &self, - req: Request>, - ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - info!(rpc_id, "⌨️ stream_keyboard opened"); - let (tx, rx) = tokio::sync::mpsc::channel(32); - let kb = self.kb.clone(); - let ms = self.ms.clone(); - let kb_path = hid_endpoint(0); - let ms_path = hid_endpoint(1); - let gadget = self.gadget.clone(); - let did_cycle = self.did_cycle.clone(); - let session_lease = self.capture_power.acquire_session().await; - let report_delay = live_keyboard_report_delay(); - - tokio::spawn(async move { - let _session_lease = session_lease; - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - if let Err(e) = runtime_support::write_hid_report(&kb, &kb_path, &pkt.data).await { - if e.raw_os_error() == Some(libc::EAGAIN) { - debug!(rpc_id, "⌨️ write would block (dropped)"); - } else { - warn!(rpc_id, "⌨️ write failed: {e} (dropped)"); - runtime_support::recover_hid_if_needed( - &e, - gadget.clone(), - kb.clone(), - ms.clone(), - kb_path.clone(), - ms_path.clone(), - did_cycle.clone(), - ) - .await; - } - } - tx.send(Ok(pkt)).await.ok(); - if !report_delay.is_zero() { - tokio::time::sleep(report_delay).await; - } - } - info!(rpc_id, "⌨️ stream_keyboard closed"); - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn stream_mouse( - &self, - req: Request>, - ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - info!(rpc_id, "🖱️ stream_mouse opened"); - let (tx, rx) = tokio::sync::mpsc::channel(1024); - let ms = self.ms.clone(); - let kb = self.kb.clone(); - let kb_path = hid_endpoint(0); - let ms_path = hid_endpoint(1); - let gadget = self.gadget.clone(); - let did_cycle = self.did_cycle.clone(); - let session_lease = self.capture_power.acquire_session().await; - - tokio::spawn(async move { - let _session_lease = session_lease; - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - if let Err(e) = runtime_support::write_hid_report(&ms, &ms_path, &pkt.data).await { - if e.raw_os_error() == Some(libc::EAGAIN) { - debug!(rpc_id, "🖱️ write would block (dropped)"); - } else { - warn!(rpc_id, "🖱️ write failed: {e} (dropped)"); - runtime_support::recover_hid_if_needed( - &e, - gadget.clone(), - kb.clone(), - ms.clone(), - kb_path.clone(), - ms_path.clone(), - did_cycle.clone(), - ) - .await; - } - } - tx.send(Ok(pkt)).await.ok(); - } - info!(rpc_id, "🖱️ stream_mouse closed"); - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - /// Accept synthetic upstream microphone packets without ALSA hardware. - async fn stream_microphone( - &self, - req: Request>, - ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - info!(rpc_id, "🎤 stream_microphone opened"); - // 1 ─ build once, early - let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); - info!(%uac_dev, "🎤 stream_microphone using UAC sink"); - let mut sink = runtime_support::open_voice_with_retry(&uac_dev) - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - - // 2 ─ dummy outbound stream (same trick as before) - let (tx, rx) = tokio::sync::mpsc::channel(1); - - // 3 ─ drive the sink in a background task - tokio::spawn(async move { - let mut inbound = req.into_inner(); - static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - - while let Some(pkt) = inbound.next().await.transpose()? { - let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if n < 5 || n % 3_000 == 0 { - tracing::info!(rpc_id, "🎤⬇ srv pkt#{n} {} bytes", pkt.data.len()); - } - sink.push(&pkt); - } - sink.finish(); // flush on EOS - let _ = tx.send(Ok(Empty {})).await; - info!(rpc_id, "🎤 stream_microphone closed"); - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - /// Accept synthetic upstream webcam packets without UVC/HDMI hardware. - async fn stream_camera( - &self, - req: Request>, - ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - let cfg = camera::current_camera_config(); - info!( - rpc_id, - output = cfg.output.as_str(), - codec = cfg.codec.as_str(), - width = cfg.width, - height = cfg.height, - fps = cfg.fps, - hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"), - "🎥 stream_camera output selected" - ); - - let (session_id, relay) = self.camera_rt.activate(&cfg).await?; - let camera_rt = self.camera_rt.clone(); - info!(rpc_id, session_id, "🎥 stream_camera opened"); - - // dummy outbound (same pattern as other streams) - let (tx, rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - if !camera_rt.is_active(session_id) { - info!(rpc_id, session_id, "🎥 stream_camera session superseded"); - break; - } - relay.feed(pkt); // ← all logging inside video.rs - } - tx.send(Ok(Empty {})).await.ok(); - info!(rpc_id, session_id, "🎥 stream_camera closed"); - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn capture_video( - &self, - req: Request, - ) -> Result, Status> { - self.capture_video_reply(req.into_inner()).await - } - - async fn capture_audio( - &self, - req: Request, - ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - // Only one speaker stream for now; both 0/1 → same ALSA dev. - let _id = req.into_inner().id; - // Allow override (`LESAVKA_ALSA_DEV=hw:2,0` for debugging). - let dev = std::env::var("LESAVKA_ALSA_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); - info!(rpc_id, %dev, "🔊 capture_audio opened"); - - let s = runtime_support::open_ear_with_retry(&dev, 0) - .await - .map_err(|e| remote_audio_status(format!("{e:#}")))?; - - Ok(Response::new(Box::pin(s))) - } - - async fn paste_text(&self, req: Request) -> Result, Status> { - self.paste_text_reply(req).await - } - - /*────────────── USB-reset RPC ────────────*/ - async fn reset_usb(&self, _req: Request) -> Result, Status> { - self.reset_usb_reply().await - } - - async fn get_capture_power( - &self, - _req: Request, - ) -> Result, Status> { - self.get_capture_power_reply().await - } - - async fn set_capture_power( - &self, - req: Request, - ) -> Result, Status> { - self.set_capture_power_reply(req).await - } -} - -fn remote_audio_status(message: String) -> Status { - if message.contains("remote USB gadget is not attached") { - Status::unavailable(message) - } else { - Status::internal(message) - } -} - -#[cfg(coverage)] -#[tonic::async_trait] -impl Relay for Handler { - type StreamKeyboardStream = ReceiverStream>; - type StreamMouseStream = ReceiverStream>; - type CaptureVideoStream = VideoStream; - type CaptureAudioStream = AudioStream; - type StreamMicrophoneStream = ReceiverStream>; - type StreamCameraStream = ReceiverStream>; - - async fn stream_keyboard( - &self, - req: Request>, - ) -> Result, Status> { - let (tx, rx) = tokio::sync::mpsc::channel(32); - let kb = self.kb.clone(); - let report_delay = live_keyboard_report_delay(); - - tokio::spawn(async move { - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - let _ = runtime_support::write_hid_report(&kb, &hid_endpoint(0), &pkt.data).await; - tx.send(Ok(pkt)).await.ok(); - if !report_delay.is_zero() { - #[cfg(not(coverage))] - tokio::time::sleep(report_delay).await; - } - } - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn stream_mouse( - &self, - req: Request>, - ) -> Result, Status> { - let (tx, rx) = tokio::sync::mpsc::channel(32); - let ms = self.ms.clone(); - - tokio::spawn(async move { - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - let _ = runtime_support::write_hid_report(&ms, &hid_endpoint(1), &pkt.data).await; - tx.send(Ok(pkt)).await.ok(); - } - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn stream_microphone( - &self, - req: Request>, - ) -> Result, Status> { - let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); - let mut sink = runtime_support::open_voice_with_retry(&uac_dev) - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - let (tx, rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut inbound = req.into_inner(); - while let Some(pkt) = inbound.next().await.transpose()? { - sink.push(&pkt); - } - sink.finish(); - let _ = tx.send(Ok(Empty {})).await; - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn stream_camera( - &self, - req: Request>, - ) -> Result, Status> { - let cfg = camera::current_camera_config(); - let (session_id, relay) = self.camera_rt.activate(&cfg).await?; - let camera_rt = self.camera_rt.clone(); - let (tx, rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut s = req.into_inner(); - while let Some(pkt) = s.next().await.transpose()? { - if !camera_rt.is_active(session_id) { - break; - } - relay.feed(pkt); - } - tx.send(Ok(Empty {})).await.ok(); - Ok::<(), Status>(()) - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn capture_video( - &self, - req: Request, - ) -> Result, Status> { - self.capture_video_reply(req.into_inner()).await - } - - async fn capture_audio( - &self, - _req: Request, - ) -> Result, Status> { - Err(Status::internal( - "audio capture unavailable in coverage harness", - )) - } - - async fn paste_text(&self, req: Request) -> Result, Status> { - self.paste_text_reply(req).await - } - - async fn reset_usb(&self, _req: Request) -> Result, Status> { - self.reset_usb_reply().await - } - - async fn get_capture_power( - &self, - _req: Request, - ) -> Result, Status> { - self.get_capture_power_reply().await - } - - async fn set_capture_power( - &self, - req: Request, - ) -> Result, Status> { - self.set_capture_power_reply(req).await - } -} - -/*──────────────── main ───────────────────────*/ -#[cfg(not(coverage))] -#[tokio::main(worker_threads = 4)] -async fn main() -> anyhow::Result<()> { - let _guard = init_tracing()?; - info!("🚀 {} v{} starting up", PKG_NAME, lesavka_server::VERSION); - - panic::set_hook(Box::new(|p| { - let bt = Backtrace::force_capture(); - error!("💥 panic: {p}\n{bt}"); - })); - - let gadget = UsbGadget::new("lesavka"); - if std::env::var("LESAVKA_DISABLE_UVC").is_err() { - if std::env::var("LESAVKA_UVC_EXTERNAL").is_ok() { - info!("📷 UVC control helper external; not spawning"); - } else { - let bin = uvc_runtime::uvc_ctrl_bin(); - tokio::spawn(uvc_runtime::supervise_uvc_control(bin)); - } - } else { - info!("📷 UVC disabled (LESAVKA_DISABLE_UVC set)"); - } - let handler = Handler::new(gadget.clone()).await?; - - let bind_addr = server_bind_addr()?; - info!("🌐 lesavka-server listening on {bind_addr}"); - Server::builder() - .tcp_nodelay(true) - .max_frame_size(Some(2 * 1024 * 1024)) - .add_service(RelayServer::new(handler)) - .add_service(HandshakeSvc::server()) - .add_service(ReflBuilder::configure().build_v1().unwrap()) - .serve(bind_addr) - .await?; - Ok(()) -} - -#[cfg(coverage)] -#[tokio::main(worker_threads = 2)] -async fn main() -> anyhow::Result<()> { - let gadget = UsbGadget::new("lesavka"); - let _handler = Handler::new(gadget).await?; - Err(anyhow::anyhow!("coverage mode skips live gRPC serve loop")) -} +include!("main/handler_startup.rs"); +include!("main/eye_video.rs"); +include!("main/rpc_helpers.rs"); + +include!("main/usb_recovery_helpers.rs"); +include!("main/eye_hub.rs"); +include!("main/relay_service.rs"); +include!("main/relay_service_coverage.rs"); +include!("main/entrypoint.rs"); diff --git a/server/src/main/entrypoint.rs b/server/src/main/entrypoint.rs new file mode 100644 index 0000000..08bb07a --- /dev/null +++ b/server/src/main/entrypoint.rs @@ -0,0 +1,45 @@ +/*──────────────── main ───────────────────────*/ +#[cfg(not(coverage))] +#[tokio::main(worker_threads = 4)] +async fn main() -> anyhow::Result<()> { + let _guard = init_tracing()?; + info!("🚀 {} v{} starting up", PKG_NAME, lesavka_server::VERSION); + + panic::set_hook(Box::new(|p| { + let bt = Backtrace::force_capture(); + error!("💥 panic: {p}\n{bt}"); + })); + + let gadget = UsbGadget::new("lesavka"); + if std::env::var("LESAVKA_DISABLE_UVC").is_err() { + if std::env::var("LESAVKA_UVC_EXTERNAL").is_ok() { + info!("📷 UVC control helper external; not spawning"); + } else { + let bin = uvc_runtime::uvc_ctrl_bin(); + tokio::spawn(uvc_runtime::supervise_uvc_control(bin)); + } + } else { + info!("📷 UVC disabled (LESAVKA_DISABLE_UVC set)"); + } + let handler = Handler::new(gadget.clone()).await?; + + let bind_addr = server_bind_addr()?; + info!("🌐 lesavka-server listening on {bind_addr}"); + Server::builder() + .tcp_nodelay(true) + .max_frame_size(Some(2 * 1024 * 1024)) + .add_service(RelayServer::new(handler)) + .add_service(HandshakeSvc::server()) + .add_service(ReflBuilder::configure().build_v1().unwrap()) + .serve(bind_addr) + .await?; + Ok(()) +} + +#[cfg(coverage)] +#[tokio::main(worker_threads = 2)] +async fn main() -> anyhow::Result<()> { + let gadget = UsbGadget::new("lesavka"); + let _handler = Handler::new(gadget).await?; + Err(anyhow::anyhow!("coverage mode skips live gRPC serve loop")) +} diff --git a/server/src/main/eye_hub.rs b/server/src/main/eye_hub.rs new file mode 100644 index 0000000..dd7faa4 --- /dev/null +++ b/server/src/main/eye_hub.rs @@ -0,0 +1,76 @@ +impl EyeHub { + fn spawn(mut stream: S, lease: lesavka_server::capture_power::CapturePowerLease) -> Arc + where + S: Stream> + Unpin + Send + 'static, + { + let (tx, _) = broadcast::channel(32); + let running = Arc::new(AtomicBool::new(true)); + let subscribers = Arc::new(AtomicUsize::new(0)); + let tx_for_task = tx.clone(); + let running_for_task = Arc::clone(&running); + let subscribers_for_task = Arc::clone(&subscribers); + let task = tokio::spawn(async move { + let _lease = lease; + let mut idle_ticks = 0_u32; + while running_for_task.load(Ordering::Relaxed) { + match stream.next().await { + Some(Ok(pkt)) => { + let _ = tx_for_task.send(pkt); + if subscribers_for_task.load(Ordering::Relaxed) == 0 { + idle_ticks = idle_ticks.saturating_add(1); + if idle_ticks >= 60 { + break; + } + } else { + idle_ticks = 0; + } + } + Some(Err(err)) => { + warn!(?err, "shared eye hub stream error"); + break; + } + None => break, + } + } + running_for_task.store(false, Ordering::Relaxed); + }); + + + Arc::new(Self { + tx: tx.clone(), + running: Arc::clone(&running), + subscribers: Arc::clone(&subscribers), + abort: task.abort_handle(), + }) + } + + fn shutdown(&self) { + if self.running.swap(false, Ordering::AcqRel) { + self.abort.abort(); + } + } +} + +fn take_conflicting_eye_hubs( + hubs: &mut HashMap>, + key: EyeHubKey, +) -> Vec> { + let stale_keys: Vec<_> = hubs + .iter() + .filter_map(|(existing_key, hub)| { + let running = hub.running.load(Ordering::Relaxed); + let conflicting_source = + existing_key.source_id == key.source_id && *existing_key != key; + if !running || conflicting_source { + Some(*existing_key) + } else { + None + } + }) + .collect(); + + stale_keys + .into_iter() + .filter_map(|existing_key| hubs.remove(&existing_key)) + .collect() +} diff --git a/server/src/main/eye_video.rs b/server/src/main/eye_video.rs new file mode 100644 index 0000000..8f81e20 --- /dev/null +++ b/server/src/main/eye_video.rs @@ -0,0 +1,152 @@ +impl Handler { + async fn capture_video_reply( + &self, + req: MonitorRequest, + ) -> Result, Status> { + let id = req.id; + if id > 1 { + return Err(Status::invalid_argument("monitor id must be 0 or 1")); + } + let source_id = req.source_id.unwrap_or(id); + if source_id > 1 { + return Err(Status::invalid_argument("source id must be 0 or 1")); + } + let dev = if source_id == 0 { + "/dev/lesavka_l_eye" + } else { + "/dev/lesavka_r_eye" + }; + + #[cfg(not(coverage))] + { + let rpc_id = runtime_support::next_stream_id(); + info!( + rpc_id, + id, + source_id, + max_bitrate = req.max_bitrate, + requested_width = req.requested_width, + requested_height = req.requested_height, + requested_fps = req.requested_fps, + "🎥 capture_video opened" + ); + debug!(rpc_id, "🎥 streaming {dev}"); + } + + let hub = self + .eye_hub( + dev, + EyeHubKey { + source_id, + requested_width: req.requested_width, + requested_height: req.requested_height, + requested_fps: req.requested_fps, + }, + req.max_bitrate, + ) + .await?; + + let hub_rx = hub.tx.subscribe(); + hub.subscribers.fetch_add(1, Ordering::AcqRel); + let subscribers = Arc::clone(&hub.subscribers); + let hub_for_task = Arc::clone(&hub); + let (tx, rx) = tokio::sync::mpsc::channel(32); + tokio::spawn(forward_eye_hub_packets( + id, + hub_rx, + tx, + subscribers, + hub_for_task, + )); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + async fn eye_hub( + &self, + dev: &str, + key: EyeHubKey, + max_bitrate_kbit: u32, + ) -> Result, Status> { + let stale_hubs = { + let mut hubs = self.eye_hubs.lock().await; + if let Some(hub) = hubs.get(&key) + && hub.running.load(Ordering::Relaxed) + { + return Ok(Arc::clone(hub)); + } + take_conflicting_eye_hubs(&mut hubs, key) + }; + #[cfg(not(coverage))] + if !stale_hubs.is_empty() { + info!( + source_id = key.source_id, + requested_width = key.requested_width, + requested_height = key.requested_height, + requested_fps = key.requested_fps, + stale_hubs = stale_hubs.len(), + "🎥 replacing stale/conflicting eye hubs before opening the source" + ); + } + for hub in stale_hubs { + hub.shutdown(); + } + + let lease = self.capture_power.acquire().await; + let stream = video::eye_ball_with_request( + dev, + key.source_id, + max_bitrate_kbit, + key.requested_width, + key.requested_height, + key.requested_fps, + ) + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + + let hub = EyeHub::spawn(stream, lease); + let mut hubs = self.eye_hubs.lock().await; + #[cfg(not(coverage))] + if let Some(existing) = hubs.get(&key) + && existing.running.load(Ordering::Relaxed) + { + hub.shutdown(); + return Ok(Arc::clone(existing)); + } + hubs.insert(key, Arc::clone(&hub)); + Ok(hub) + } + + #[cfg(test)] + #[allow(dead_code)] + async fn eye_hub_count(&self) -> usize { + self.eye_hubs.lock().await.len() + } + +} + +/// Fan out one shared eye hub into one RPC stream without letting stale frames +/// build latency when a receiver falls behind or disconnects. +async fn forward_eye_hub_packets( + id: u32, + mut hub_rx: broadcast::Receiver, + tx: tokio::sync::mpsc::Sender>, + subscribers: Arc, + hub_for_task: Arc, +) { + loop { + match hub_rx.recv().await { + Ok(mut pkt) => { + pkt.id = id; + if tx.send(Ok(pkt)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + if subscribers.fetch_sub(1, Ordering::AcqRel) == 1 { + hub_for_task.shutdown(); + } +} diff --git a/server/src/main/handler_startup.rs b/server/src/main/handler_startup.rs new file mode 100644 index 0000000..cd4b85e --- /dev/null +++ b/server/src/main/handler_startup.rs @@ -0,0 +1,130 @@ +impl Handler { + async fn new(gadget: UsbGadget) -> anyhow::Result { + #[cfg(not(coverage))] + if runtime_support::allow_gadget_cycle() { + info!("🛠️ Initial USB recovery…"); + match UsbGadget::current_controller_state() { + Ok((ctrl, state)) if !UsbGadget::host_enumerated_state(&state) => { + warn!("⚠️ UDC {ctrl} is {state}; forcing gadget recovery before opening HID"); + if let Err(error) = gadget.recover_enumeration() { + warn!("⚠️ initial USB recovery did not enumerate the host: {error:#}"); + } + } + Ok(_) => { + let _ = gadget.cycle(); // ignore failure - may boot without host + } + Err(error) => { + warn!("⚠️ UDC state unavailable during startup: {error:#}"); + let _ = gadget.cycle(); // preserve the old best-effort startup path + } + } + } + #[cfg(not(coverage))] + { + if !runtime_support::allow_gadget_cycle() { + info!( + "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" + ); + } + info!("🛠️ opening HID endpoints …"); + } + let kb_path = hid_endpoint(0); + let ms_path = hid_endpoint(1); + let kb = runtime_support::open_hid_if_ready(&kb_path).await?; + let ms = runtime_support::open_hid_if_ready(&ms_path).await?; + #[cfg(not(coverage))] + if kb.is_some() && ms.is_some() { + info!("✅ HID endpoints ready"); + } else { + warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily"); + } + + Ok(Self { + kb: Arc::new(Mutex::new(kb)), + ms: Arc::new(Mutex::new(ms)), + gadget, + did_cycle: Arc::new(AtomicBool::new(false)), + camera_rt: Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + eye_hubs: Arc::new(Mutex::new(HashMap::new())), + }) + } + + async fn reopen_hid(&self) -> anyhow::Result<()> { + let kb_new = runtime_support::open_hid_if_ready(&hid_endpoint(0)).await?; + let ms_new = runtime_support::open_hid_if_ready(&hid_endpoint(1)).await?; + *self.kb.lock().await = kb_new; + *self.ms.lock().await = ms_new; + Ok(()) + } + + fn detected_capture_devices_from_symlinks() -> u32 { + ["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"] + .into_iter() + .filter(|path| { + std::fs::metadata(path) + .ok() + .is_some_and(|metadata| metadata.file_type().is_char_device()) + }) + .count() as u32 + } + + #[cfg(not(coverage))] + fn detected_capture_devices_from_udev() -> u32 { + let Ok(mut enumerator) = udev::Enumerator::new() else { + return 0; + }; + let _ = enumerator.match_subsystem("video4linux"); + let Ok(devices) = enumerator.scan_devices() else { + return 0; + }; + devices + .filter(|device| { + device + .attribute_value("index") + .and_then(|value| value.to_str()) + == Some("0") + && device + .property_value("ID_VENDOR_ID") + .and_then(|value| value.to_str()) + == Some("07ca") + && device + .property_value("ID_MODEL_ID") + .and_then(|value| value.to_str()) + == Some("3311") + }) + .count() + .min(2) as u32 + } + + #[cfg(coverage)] + fn detected_capture_devices_from_udev() -> u32 { + std::env::var("LESAVKA_TEST_UDEV_CAPTURE_DEVICES") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .min(2) + } + + async fn active_eye_source_count(&self) -> u32 { + self.eye_hubs + .lock() + .await + .iter() + .filter_map(|(key, hub)| hub.running.load(Ordering::Relaxed).then_some(key.source_id)) + .collect::>() + .len() + .min(2) as u32 + } + + async fn with_detected_capture_devices( + &self, + mut state: CapturePowerState, + ) -> CapturePowerState { + state.detected_devices = Self::detected_capture_devices_from_udev() + .max(Self::detected_capture_devices_from_symlinks()) + .max(self.active_eye_source_count().await); + state + } + +} diff --git a/server/src/main/relay_service.rs b/server/src/main/relay_service.rs new file mode 100644 index 0000000..27902e8 --- /dev/null +++ b/server/src/main/relay_service.rs @@ -0,0 +1,242 @@ +/*──────────────── gRPC service ─────────────*/ +#[cfg(not(coverage))] +#[tonic::async_trait] +impl Relay for Handler { + /* existing streams ─ unchanged, except: no more auto-reset */ + type StreamKeyboardStream = ReceiverStream>; + type StreamMouseStream = ReceiverStream>; + type CaptureVideoStream = VideoStream; + type CaptureAudioStream = AudioStream; + type StreamMicrophoneStream = ReceiverStream>; + type StreamCameraStream = ReceiverStream>; + + async fn stream_keyboard( + &self, + req: Request>, + ) -> Result, Status> { + let rpc_id = runtime_support::next_stream_id(); + info!(rpc_id, "⌨️ stream_keyboard opened"); + let (tx, rx) = tokio::sync::mpsc::channel(32); + let kb = self.kb.clone(); + let ms = self.ms.clone(); + let kb_path = hid_endpoint(0); + let ms_path = hid_endpoint(1); + let gadget = self.gadget.clone(); + let did_cycle = self.did_cycle.clone(); + let session_lease = self.capture_power.acquire_session().await; + let report_delay = live_keyboard_report_delay(); + + tokio::spawn(async move { + let _session_lease = session_lease; + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + if let Err(e) = runtime_support::write_hid_report(&kb, &kb_path, &pkt.data).await { + if e.raw_os_error() == Some(libc::EAGAIN) { + debug!(rpc_id, "⌨️ write would block (dropped)"); + } else { + warn!(rpc_id, "⌨️ write failed: {e} (dropped)"); + runtime_support::recover_hid_if_needed( + &e, + gadget.clone(), + kb.clone(), + ms.clone(), + kb_path.clone(), + ms_path.clone(), + did_cycle.clone(), + ) + .await; + } + } + tx.send(Ok(pkt)).await.ok(); + if !report_delay.is_zero() { + tokio::time::sleep(report_delay).await; + } + } + info!(rpc_id, "⌨️ stream_keyboard closed"); + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn stream_mouse( + &self, + req: Request>, + ) -> Result, Status> { + let rpc_id = runtime_support::next_stream_id(); + info!(rpc_id, "🖱️ stream_mouse opened"); + let (tx, rx) = tokio::sync::mpsc::channel(1024); + let ms = self.ms.clone(); + let kb = self.kb.clone(); + let kb_path = hid_endpoint(0); + let ms_path = hid_endpoint(1); + let gadget = self.gadget.clone(); + let did_cycle = self.did_cycle.clone(); + let session_lease = self.capture_power.acquire_session().await; + + tokio::spawn(async move { + let _session_lease = session_lease; + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + if let Err(e) = runtime_support::write_hid_report(&ms, &ms_path, &pkt.data).await { + if e.raw_os_error() == Some(libc::EAGAIN) { + debug!(rpc_id, "🖱️ write would block (dropped)"); + } else { + warn!(rpc_id, "🖱️ write failed: {e} (dropped)"); + runtime_support::recover_hid_if_needed( + &e, + gadget.clone(), + kb.clone(), + ms.clone(), + kb_path.clone(), + ms_path.clone(), + did_cycle.clone(), + ) + .await; + } + } + tx.send(Ok(pkt)).await.ok(); + } + info!(rpc_id, "🖱️ stream_mouse closed"); + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + /// Accept synthetic upstream microphone packets without ALSA hardware. + async fn stream_microphone( + &self, + req: Request>, + ) -> Result, Status> { + let rpc_id = runtime_support::next_stream_id(); + info!(rpc_id, "🎤 stream_microphone opened"); + // 1 ─ build once, early + let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); + info!(%uac_dev, "🎤 stream_microphone using UAC sink"); + let mut sink = runtime_support::open_voice_with_retry(&uac_dev) + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + + // 2 ─ dummy outbound stream (same trick as before) + let (tx, rx) = tokio::sync::mpsc::channel(1); + + // 3 ─ drive the sink in a background task + tokio::spawn(async move { + let mut inbound = req.into_inner(); + static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + while let Some(pkt) = inbound.next().await.transpose()? { + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n < 5 || n.is_multiple_of(3_000) { + tracing::info!(rpc_id, "🎤⬇ srv pkt#{n} {} bytes", pkt.data.len()); + } + sink.push(&pkt); + } + sink.finish(); // flush on EOS + let _ = tx.send(Ok(Empty {})).await; + info!(rpc_id, "🎤 stream_microphone closed"); + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + /// Accept synthetic upstream webcam packets without UVC/HDMI hardware. + async fn stream_camera( + &self, + req: Request>, + ) -> Result, Status> { + let rpc_id = runtime_support::next_stream_id(); + let cfg = camera::current_camera_config(); + info!( + rpc_id, + output = cfg.output.as_str(), + codec = cfg.codec.as_str(), + width = cfg.width, + height = cfg.height, + fps = cfg.fps, + hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"), + "🎥 stream_camera output selected" + ); + + let (session_id, relay) = self.camera_rt.activate(&cfg).await?; + let camera_rt = self.camera_rt.clone(); + info!(rpc_id, session_id, "🎥 stream_camera opened"); + + // dummy outbound (same pattern as other streams) + let (tx, rx) = tokio::sync::mpsc::channel(1); + + tokio::spawn(async move { + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + if !camera_rt.is_active(session_id) { + info!(rpc_id, session_id, "🎥 stream_camera session superseded"); + break; + } + relay.feed(pkt); // ← all logging inside video.rs + } + tx.send(Ok(Empty {})).await.ok(); + info!(rpc_id, session_id, "🎥 stream_camera closed"); + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn capture_video( + &self, + req: Request, + ) -> Result, Status> { + self.capture_video_reply(req.into_inner()).await + } + + async fn capture_audio( + &self, + req: Request, + ) -> Result, Status> { + let rpc_id = runtime_support::next_stream_id(); + // Only one speaker stream for now; both 0/1 → same ALSA dev. + let _id = req.into_inner().id; + // Allow override (`LESAVKA_ALSA_DEV=hw:2,0` for debugging). + let dev = std::env::var("LESAVKA_ALSA_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); + info!(rpc_id, %dev, "🔊 capture_audio opened"); + + let s = runtime_support::open_ear_with_retry(&dev, 0) + .await + .map_err(|e| remote_audio_status(format!("{e:#}")))?; + + Ok(Response::new(Box::pin(s))) + } + + async fn paste_text(&self, req: Request) -> Result, Status> { + self.paste_text_reply(req).await + } + + /*────────────── USB-reset RPC ────────────*/ + async fn reset_usb(&self, _req: Request) -> Result, Status> { + self.reset_usb_reply().await + } + + async fn get_capture_power( + &self, + _req: Request, + ) -> Result, Status> { + self.get_capture_power_reply().await + } + + async fn set_capture_power( + &self, + req: Request, + ) -> Result, Status> { + self.set_capture_power_reply(req).await + } +} + +fn remote_audio_status(message: String) -> Status { + if message.contains("remote USB gadget is not attached") { + Status::unavailable(message) + } else { + Status::internal(message) + } +} diff --git a/server/src/main/relay_service_coverage.rs b/server/src/main/relay_service_coverage.rs new file mode 100644 index 0000000..2dfd986 --- /dev/null +++ b/server/src/main/relay_service_coverage.rs @@ -0,0 +1,138 @@ +#[cfg(coverage)] +#[tonic::async_trait] +impl Relay for Handler { + type StreamKeyboardStream = ReceiverStream>; + type StreamMouseStream = ReceiverStream>; + type CaptureVideoStream = VideoStream; + type CaptureAudioStream = AudioStream; + type StreamMicrophoneStream = ReceiverStream>; + type StreamCameraStream = ReceiverStream>; + + async fn stream_keyboard( + &self, + req: Request>, + ) -> Result, Status> { + let (tx, rx) = tokio::sync::mpsc::channel(32); + let kb = self.kb.clone(); + let report_delay = live_keyboard_report_delay(); + + tokio::spawn(async move { + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + let _ = runtime_support::write_hid_report(&kb, &hid_endpoint(0), &pkt.data).await; + tx.send(Ok(pkt)).await.ok(); + if !report_delay.is_zero() { + #[cfg(not(coverage))] + tokio::time::sleep(report_delay).await; + } + } + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn stream_mouse( + &self, + req: Request>, + ) -> Result, Status> { + let (tx, rx) = tokio::sync::mpsc::channel(32); + let ms = self.ms.clone(); + + tokio::spawn(async move { + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + let _ = runtime_support::write_hid_report(&ms, &hid_endpoint(1), &pkt.data).await; + tx.send(Ok(pkt)).await.ok(); + } + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn stream_microphone( + &self, + req: Request>, + ) -> Result, Status> { + let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); + let mut sink = runtime_support::open_voice_with_retry(&uac_dev) + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + let (tx, rx) = tokio::sync::mpsc::channel(1); + + tokio::spawn(async move { + let mut inbound = req.into_inner(); + while let Some(pkt) = inbound.next().await.transpose()? { + sink.push(&pkt); + } + sink.finish(); + let _ = tx.send(Ok(Empty {})).await; + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn stream_camera( + &self, + req: Request>, + ) -> Result, Status> { + let cfg = camera::current_camera_config(); + let (session_id, relay) = self.camera_rt.activate(&cfg).await?; + let camera_rt = self.camera_rt.clone(); + let (tx, rx) = tokio::sync::mpsc::channel(1); + + tokio::spawn(async move { + let mut s = req.into_inner(); + while let Some(pkt) = s.next().await.transpose()? { + if !camera_rt.is_active(session_id) { + break; + } + relay.feed(pkt); + } + tx.send(Ok(Empty {})).await.ok(); + Ok::<(), Status>(()) + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn capture_video( + &self, + req: Request, + ) -> Result, Status> { + self.capture_video_reply(req.into_inner()).await + } + + async fn capture_audio( + &self, + _req: Request, + ) -> Result, Status> { + Err(Status::internal( + "audio capture unavailable in coverage harness", + )) + } + + async fn paste_text(&self, req: Request) -> Result, Status> { + self.paste_text_reply(req).await + } + + async fn reset_usb(&self, _req: Request) -> Result, Status> { + self.reset_usb_reply().await + } + + async fn get_capture_power( + &self, + _req: Request, + ) -> Result, Status> { + self.get_capture_power_reply().await + } + + async fn set_capture_power( + &self, + req: Request, + ) -> Result, Status> { + self.set_capture_power_reply(req).await + } +} diff --git a/server/src/main/rpc_helpers.rs b/server/src/main/rpc_helpers.rs new file mode 100644 index 0000000..fce39e3 --- /dev/null +++ b/server/src/main/rpc_helpers.rs @@ -0,0 +1,105 @@ +impl Handler { + async fn paste_text_reply( + &self, + req: Request, + ) -> Result, Status> { + let req = req.into_inner(); + let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; + if let Err(e) = paste::type_text(&self.kb, &hid_endpoint(0), &text).await { + return Ok(Response::new(PasteReply { + ok: false, + error: format!("{e}"), + })); + } + Ok(Response::new(PasteReply { + ok: true, + error: String::new(), + })) + } + + async fn reset_usb_reply(&self) -> Result, Status> { + #[cfg(not(coverage))] + info!("🔴 explicit ResetUsb() called"); + + match self.gadget.recover_enumeration() { + Ok(_) => { + if let Err(e) = self.reopen_hid().await { + #[cfg(not(coverage))] + error!("💥 reopen HID failed: {e:#}"); + return Err(Status::internal(e.to_string())); + } + if let Err(e) = restart_uvc_helper() { + #[cfg(not(coverage))] + error!("💥 restart UVC helper failed: {e:#}"); + return Err(Status::internal(e.to_string())); + } + match current_controller_state_after_recovery() { + Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => { + #[cfg(not(coverage))] + info!( + "✅ USB host enumerated gadget after recovery ctrl={ctrl} state={state}" + ); + } + Ok((ctrl, state)) => { + let message = format!( + "USB gadget recovery ran, but UDC {ctrl} is still {state}; the controlled host has not enumerated the relay HID/audio/video gadget" + ); + #[cfg(not(coverage))] + warn!("⚠️ {message}"); + return Err(Status::failed_precondition(message)); + } + Err(err) => { + let message = format!( + "USB gadget recovery ran, but the relay cannot read UDC state: {err:#}" + ); + #[cfg(not(coverage))] + warn!("⚠️ {message}"); + return Err(Status::failed_precondition(message)); + } + } + Ok(Response::new(ResetUsbReply { ok: true })) + } + Err(e) => { + #[cfg(not(coverage))] + error!("💥 USB recovery failed: {e:#}"); + let message = format!("{e:#}"); + if message.contains("still not attached") || message.contains("not attached") { + Err(Status::failed_precondition(message)) + } else { + Err(Status::internal(message)) + } + } + } + } + + async fn get_capture_power_reply(&self) -> Result, Status> { + let state = self + .capture_power + .snapshot() + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + Ok(Response::new( + self.with_detected_capture_devices(state).await, + )) + } + + async fn set_capture_power_reply( + &self, + req: Request, + ) -> Result, Status> { + let req = req.into_inner(); + let result = match CapturePowerCommand::try_from(req.command) + .unwrap_or(CapturePowerCommand::Unspecified) + { + CapturePowerCommand::Auto => self.capture_power.set_auto().await, + CapturePowerCommand::ForceOn => self.capture_power.set_manual(true).await, + CapturePowerCommand::ForceOff => self.capture_power.set_manual(false).await, + CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await, + }; + let state = result.map_err(|e| Status::internal(format!("{e:#}")))?; + Ok(Response::new( + self.with_detected_capture_devices(state).await, + )) + } + +} diff --git a/server/src/main/usb_recovery_helpers.rs b/server/src/main/usb_recovery_helpers.rs new file mode 100644 index 0000000..c037eda --- /dev/null +++ b/server/src/main/usb_recovery_helpers.rs @@ -0,0 +1,66 @@ +fn current_controller_state_after_recovery() -> anyhow::Result<(String, String)> { + #[cfg(coverage)] + { + if std::env::var("LESAVKA_TEST_RECOVERY_STATE_ERROR").is_ok() { + anyhow::bail!("forced recovery state read failure"); + } + if let Ok(state) = std::env::var("LESAVKA_TEST_RECOVERY_STATE") { + return Ok(("coverage-ctrl".to_string(), state)); + } + } + UsbGadget::current_controller_state() +} + +fn restart_uvc_helper() -> anyhow::Result<()> { + #[cfg(coverage)] + if let Ok(message) = std::env::var("LESAVKA_TEST_UVC_HELPER_RESTART_ERR") { + anyhow::bail!("{message}"); + } + if std::env::var("LESAVKA_GADGET_SYSFS_ROOT").is_ok() + || std::env::var("LESAVKA_GADGET_CONFIGFS_ROOT").is_ok() + { + return Ok(()); + } + + run_systemctl(&["reset-failed", "lesavka-uvc.service"])?; + match run_systemctl(&["restart", "lesavka-uvc.service"]) { + Ok(()) => Ok(()), + Err(err) if uvc_helper_restart_was_dependency_refused(&err.to_string()) => { + warn!( + "lesavka-uvc.service refused a direct restart because it is dependency-managed; USB reset already cycled the gadget" + ); + Ok(()) + } + Err(err) => Err(err), + } +} + +fn run_systemctl(args: &[&str]) -> anyhow::Result<()> { + let output = Command::new("systemctl") + .args(args) + .output() + .with_context(|| format!("running systemctl {}", args.join(" ")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + anyhow::bail!( + "systemctl {} failed: {}{}", + args.join(" "), + if stderr.is_empty() { + stdout.as_str() + } else { + stderr.as_str() + }, + if stderr.is_empty() || stdout.is_empty() || stderr == stdout { + "" + } else { + " / also see stdout" + } + ); +} + +fn uvc_helper_restart_was_dependency_refused(message: &str) -> bool { + message.contains("Operation refused") || message.contains("may be requested by dependency only") +} diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index 0674a59..b8cc256 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -1,827 +1,9 @@ #![forbid(unsafe_code)] - -use anyhow::Context as _; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; -use std::{collections::BTreeSet, fs}; -use tokio::fs::OpenOptions; -use tokio::io::AsyncWriteExt; -use tokio::sync::Mutex; -use tracing::{error, info, trace, warn}; -use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; - -use crate::{audio, gadget::UsbGadget}; - -static STREAM_SEQ: AtomicU64 = AtomicU64::new(1); - -/// Initialise structured tracing for the server process. -/// -/// Inputs: none; configuration is read from `RUST_LOG`. -/// Outputs: the non-blocking file writer guard that must stay alive for the -/// lifetime of the process. -/// Why: the server writes both to stdout and a local log file so field logs are -/// still available after a transient SSH disconnect. -#[cfg(coverage)] -pub fn init_tracing() -> anyhow::Result { - let (_writer, guard) = tracing_appender::non_blocking(std::io::sink()); - Ok(guard) -} - -#[cfg(not(coverage))] -pub fn init_tracing() -> anyhow::Result { - let file = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open("/tmp/lesavka-server.log")?; - let (file_writer, guard) = tracing_appender::non_blocking(file); - - let env_filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("lesavka_server=info,lesavka_server::video=warn")); - let filter_str = env_filter.to_string(); - - tracing_subscriber::registry() - .with(env_filter) - .with(fmt::layer().with_target(true).with_thread_ids(true)) - .with( - fmt::layer() - .with_writer(file_writer) - .with_ansi(false) - .with_target(true) - .with_level(true), - ) - .init(); - tracing::info!("📜 effective RUST_LOG = \"{}\"", filter_str); - Ok(guard) -} - -/// Open a HID gadget endpoint with bounded retry logic. -/// -/// Inputs: the path of the gadget device node to open. -/// Outputs: a writable non-blocking file handle once the kernel reports the -/// endpoint as ready. -/// Why: gadget endpoints frequently flap during cable changes, so the server -/// must wait for readiness instead of failing the whole process immediately. -#[cfg(coverage)] -pub async fn open_with_retry(path: &str) -> anyhow::Result { - open_hid_file(path) - .await - .with_context(|| format!("opening {path}")) -} - -#[cfg(not(coverage))] -pub async fn open_with_retry(path: &str) -> anyhow::Result { - for attempt in 1..=200 { - match open_hid_file(path).await { - Ok(file) => { - info!("✅ {path} opened on attempt #{attempt}"); - return Ok(file); - } - Err(error) - if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error()) - || error.raw_os_error() == Some(libc::EBUSY) => - { - trace!("⏳ {path} unavailable ({error})… retry #{attempt}"); - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(error) => return Err(error).with_context(|| format!("opening {path}")), - } - } - - Err(anyhow::anyhow!("timeout waiting for {path}")) -} - -async fn open_hid_file(path: &str) -> std::io::Result { - OpenOptions::new() - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(path) - .await -} - -pub async fn open_hid_if_ready(path: &str) -> anyhow::Result> { - match open_hid_file(path).await { - Ok(file) => { - info!("✅ {path} opened"); - Ok(Some(file)) - } - Err(error) if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error()) => { - warn!("⌛ {path} is not ready yet ({error}); relay will retry lazily"); - Ok(None) - } - Err(error) => Err(error).with_context(|| format!("opening {path}")), - } -} - -#[must_use] -pub fn hid_endpoint_open_is_temporarily_unavailable(code: Option) -> bool { - matches!( - code, - Some(libc::ENOENT) | Some(libc::ENODEV) | Some(libc::ENXIO) - ) -} - -/// Check whether gadget auto-recovery is enabled. -/// -/// Inputs: none. -/// Outputs: `true` only when the explicit recovery opt-in env var is present. -/// Why: cycling the whole USB gadget can be disruptive, so operators must -/// choose that behavior deliberately on each deployment. -#[must_use] -pub fn allow_gadget_cycle() -> bool { - std::env::var("LESAVKA_ALLOW_GADGET_CYCLE").is_ok() -} - -/// Return whether a HID write error should trigger recovery. -/// -/// Inputs: the raw `errno` value observed while writing to a HID gadget. -/// Outputs: `true` when the error is consistent with a lost USB connection. -/// Why: only transport-level failures should cause device reopen and gadget -/// cycling; transient backpressure is handled elsewhere. -#[must_use] -pub fn should_recover_hid_error(code: Option) -> bool { - matches!( - code, - Some(libc::ENOTCONN) | Some(libc::ESHUTDOWN) | Some(libc::EPIPE) - ) || hid_endpoint_open_is_temporarily_unavailable(code) -} - -/// Recover the HID endpoints after a transport failure. -/// -/// Inputs: the write error plus the current gadget and file handles. -/// Outputs: none; recovery runs asynchronously and updates the shared handles -/// in place when reopening succeeds. -/// Why: streams should survive cable resets without dropping the entire server -/// process or requiring a manual restart from the operator. -#[cfg(coverage)] -pub async fn recover_hid_if_needed( - err: &std::io::Error, - gadget: UsbGadget, - kb: Arc>>, - ms: Arc>>, - _kb_path: String, - _ms_path: String, - did_cycle: Arc, -) { - let code = err.raw_os_error(); - if !should_recover_hid_error(code) { - return; - } - - if did_cycle - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - let allow_cycle = allow_gadget_cycle(); - tokio::spawn(async move { - if allow_cycle { - let _ = tokio::task::spawn_blocking(move || gadget.cycle()).await; - } else { - let _ = (kb, ms); - } - tokio::time::sleep(Duration::from_secs(2)).await; - did_cycle.store(false, Ordering::SeqCst); - }); -} - -#[cfg(not(coverage))] -pub async fn recover_hid_if_needed( - err: &std::io::Error, - gadget: UsbGadget, - kb: Arc>>, - ms: Arc>>, - kb_path: String, - ms_path: String, - did_cycle: Arc, -) { - let code = err.raw_os_error(); - if !should_recover_hid_error(code) { - return; - } - - if did_cycle - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - let allow_cycle = allow_gadget_cycle(); - tokio::spawn(async move { - if allow_cycle { - warn!("🔁 HID transport down (errno={code:?}) - aggressively recovering gadget"); - match tokio::task::spawn_blocking(move || gadget.recover_enumeration()).await { - Ok(Ok(())) => info!("✅ USB gadget recovery complete (auto-recover)"), - Ok(Err(error)) => error!("💥 USB gadget recovery failed: {error:#}"), - Err(error) => error!("💥 USB gadget recovery task panicked: {error:#}"), - } - } else { - warn!( - "🔒 HID transport down (errno={code:?}) - gadget cycle disabled; set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable" - ); - } - - if let Err(error) = async { - let kb_new = open_hid_if_ready(&kb_path).await?; - let ms_new = open_hid_if_ready(&ms_path).await?; - *kb.lock().await = kb_new; - *ms.lock().await = ms_new; - Ok::<(), anyhow::Error>(()) - } - .await - { - error!("💥 HID reopen failed: {error:#}"); - } - - tokio::time::sleep(Duration::from_secs(2)).await; - did_cycle.store(false, Ordering::SeqCst); - }); -} - -/// Open the UAC sink with retry logic. -/// -/// Inputs: the ALSA device string that should receive microphone audio. -/// Outputs: a ready-to-use `Voice` sink. -/// Why: the USB audio gadget can appear after the RPC stream has already been -/// negotiated, so the server retries briefly before declaring the sink broken. -#[cfg(coverage)] -pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { - audio::Voice::new(uac_dev).await -} - -#[cfg(not(coverage))] -pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { - let candidates = preferred_uac_device_candidates(uac_dev); - let (attempts, delay_ms) = audio_init_retry_policy(); - let mut last_error: Option = None; - - for attempt in 1..=attempts { - for candidate in &candidates { - match audio::Voice::new(candidate).await { - Ok(voice) => { - if attempt > 1 || candidate != uac_dev { - info!( - requested = %uac_dev, - resolved = %candidate, - attempt, - "🎤 microphone sink recovered" - ); - } - return Ok(voice); - } - Err(error) => { - warn!( - requested = %uac_dev, - candidate = %candidate, - attempt, - "⚠️ microphone sink init failed: {error:#}" - ); - last_error = Some(error); - } - } - } - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed"))) -} - -/// Open the UAC capture source with retry logic. -/// -/// Inputs: the preferred ALSA device string plus the logical stream id. -/// Outputs: a ready-to-stream AAC capture pipeline. -/// Why: the USB gadget card name is not always stable, so the server should -/// retry both the preferred name and any discovered aliases before failing. -#[cfg(coverage)] -pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { - audio::ear(alsa_dev, id).await -} - -#[cfg(not(coverage))] -pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { - let candidates = preferred_uac_device_candidates(alsa_dev); - let (attempts, delay_ms) = audio_init_retry_policy(); - let mut last_error: Option = None; - - for attempt in 1..=attempts { - for candidate in &candidates { - match audio::ear(candidate, id).await { - Ok(stream) => { - if attempt > 1 || candidate != alsa_dev { - info!( - requested = %alsa_dev, - resolved = %candidate, - attempt, - "🔊 audio source recovered" - ); - } - return Ok(stream); - } - Err(error) => { - warn!( - requested = %alsa_dev, - candidate = %candidate, - attempt, - "⚠️ audio source init failed: {error:#}" - ); - last_error = Some(error); - } - } - } - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed"))) -} - -#[cfg(not(coverage))] -fn audio_init_retry_policy() -> (u32, u64) { - let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS") - .ok() - .and_then(|value| value.parse::().ok()) - .or_else(|| { - std::env::var("LESAVKA_MIC_INIT_ATTEMPTS") - .ok() - .and_then(|value| value.parse::().ok()) - }) - .unwrap_or(20) - .max(1); - let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .or_else(|| { - std::env::var("LESAVKA_MIC_INIT_DELAY_MS") - .ok() - .and_then(|value| value.parse::().ok()) - }) - .unwrap_or(250); - (attempts, delay_ms) -} - -fn preferred_uac_device_candidates(preferred: &str) -> Vec { - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - let auto_family = [ - "hw:UAC2Gadget,0", - "hw:UAC2_Gadget,0", - "hw:Composite,0", - "hw:Lesavka,0", - ]; - let allow_aliases = auto_family.contains(&preferred); - push_audio_candidate_family(&mut out, &mut seen, preferred); - if allow_aliases { - for detected in detect_uac_card_candidates() { - push_audio_candidate_family(&mut out, &mut seen, &detected); - } - for alias in auto_family { - push_audio_candidate_family(&mut out, &mut seen, alias); - } - } - out -} - -fn push_audio_candidate_family( - out: &mut Vec, - seen: &mut BTreeSet, - candidate: &str, -) { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - return; - } - push_audio_candidate(out, seen, trimmed); - if let Some(rest) = trimmed.strip_prefix("hw:") { - push_audio_candidate(out, seen, &format!("plughw:{rest}")); - } else if let Some(rest) = trimmed.strip_prefix("plughw:") { - push_audio_candidate(out, seen, &format!("hw:{rest}")); - } -} - -fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, candidate: &str) { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - return; - } - if seen.insert(trimmed.to_string()) { - out.push(trimmed.to_string()); - } -} - -fn detect_uac_card_candidates() -> Vec { - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - let card_data = fs::read_to_string("/proc/asound/cards").ok(); - let numeric_card_ids = card_data - .as_deref() - .map(parse_uac_numeric_card_ids) - .unwrap_or_default(); - - if let Some(cards) = card_data.as_deref() { - for candidate in parse_uac_named_card_candidates(cards) { - push_audio_candidate(&mut out, &mut seen, &candidate); - } - } - if let Ok(pcm) = fs::read_to_string("/proc/asound/pcm") { - for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) { - push_audio_candidate(&mut out, &mut seen, &candidate); - } - } - out -} - -fn parse_uac_named_card_candidates(cards: &str) -> Vec { - cards - .lines() - .filter_map(|line| { - let lower = line.to_ascii_lowercase(); - if !(lower.contains("uac2") - || lower.contains("gadget") - || lower.contains("composite") - || lower.contains("lesavka")) - { - return None; - } - let start = line.find('[')?; - let end = line[start + 1..].find(']')?; - let card_id = line[start + 1..start + 1 + end].trim(); - (!card_id.is_empty()).then(|| format!("hw:{card_id},0")) - }) - .collect() -} - -fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { - cards - .lines() - .filter_map(|line| { - let lower = line.to_ascii_lowercase(); - if !(lower.contains("uac2") - || lower.contains("gadget") - || lower.contains("composite") - || lower.contains("lesavka")) - { - return None; - } - line.split_whitespace() - .next() - .filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit())) - .map(|candidate| candidate.to_string()) - }) - .collect() -} - -fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet) -> Vec { - pcm.lines() - .filter_map(|line| { - let (prefix, _) = line.split_once(':')?; - let (card_id, device_id) = prefix.split_once('-')?; - let normalized_card = card_id.trim_start_matches('0'); - let normalized_card = if normalized_card.is_empty() { - "0" - } else { - normalized_card - }; - let normalized_device = device_id.trim_start_matches('0'); - let normalized_device = if normalized_device.is_empty() { - "0" - } else { - normalized_device - }; - numeric_card_ids - .contains(normalized_card) - .then(|| format!("hw:{normalized_card},{normalized_device}")) - }) - .collect() -} - -/// Allocate a stream identifier for logging and correlation. -/// -/// Inputs: none. -/// Outputs: a monotonically increasing identifier. -/// Why: the server multiplexes several long-lived streams, so log lines need a -/// cheap correlation id that is stable across retries. -#[must_use] -pub fn next_stream_id() -> u64 { - STREAM_SEQ.fetch_add(1, Ordering::Relaxed) -} - -/// Write one HID report with a short bounded retry loop. -/// -/// Inputs: the shared gadget file handle plus the already-encoded report. -/// Outputs: `Ok(())` when the report reached the kernel buffer, or the final -/// write error after retrying transient backpressure. -/// Why: a brief retry window avoids dropping reports during momentary gadget -/// stalls without blocking the stream task indefinitely. -#[cfg(coverage)] -pub async fn write_hid_report( - dev: &Arc>>, - path: &str, - data: &[u8], -) -> std::io::Result<()> { - let mut file = dev.lock().await; - if file.is_none() { - *file = Some(open_hid_file(path).await?); - } - if let Some(file) = file.as_mut() { - file.write_all(data).await - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotConnected, - "HID endpoint is not open", - )) - } -} - -#[cfg(not(coverage))] -pub async fn write_hid_report( - dev: &Arc>>, - path: &str, - data: &[u8], -) -> std::io::Result<()> { - let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(24) - .max(1); - let base_delay_ms = std::env::var("LESAVKA_HID_WRITE_RETRY_DELAY_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(2) - .max(1); - let mut last_error: Option = None; - for attempt in 0..attempts { - let mut file = dev.lock().await; - if file.is_none() { - match open_hid_file(path).await { - Ok(opened) => { - info!("✅ {path} opened lazily"); - *file = Some(opened); - } - Err(error) => return Err(error), - } - } - let Some(file_handle) = file.as_mut() else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotConnected, - "HID endpoint is not open", - )); - }; - match file_handle.write_all(data).await { - Ok(()) => return Ok(()), - Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - || error.raw_os_error() == Some(libc::EAGAIN) => - { - last_error = Some(error); - } - Err(error) => { - if should_recover_hid_error(error.raw_os_error()) { - *file = None; - } - return Err(error); - } - } - drop(file); - tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * base_delay_ms)).await; - } - - Err(last_error.unwrap_or_else(|| std::io::Error::from_raw_os_error(libc::EAGAIN))) -} +//! Runtime retry, HID recovery, and audio-device discovery helpers for the server. +include!("runtime_support/hid_recovery.rs"); +include!("runtime_support/audio_discovery.rs"); +include!("runtime_support/hid_write.rs"); #[cfg(test)] -mod tests { - use super::{ - allow_gadget_cycle, detect_uac_card_candidates, init_tracing, next_stream_id, - open_ear_with_retry, open_hid_if_ready, open_with_retry, parse_uac_named_card_candidates, - parse_uac_numeric_card_ids, parse_uac_pcm_candidates, preferred_uac_device_candidates, - should_recover_hid_error, write_hid_report, - }; - use serial_test::serial; - use std::collections::BTreeSet; - use std::sync::Arc; - use temp_env::with_var; - use tempfile::NamedTempFile; - use tempfile::tempdir; - use tokio::io::AsyncWriteExt; - use tokio::sync::Mutex; - - #[test] - #[serial] - fn allow_gadget_cycle_tracks_env_presence() { - with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || { - assert!(!allow_gadget_cycle()); - }); - with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { - assert!(allow_gadget_cycle()); - }); - } - - #[test] - fn should_recover_hid_error_matches_transport_failures() { - assert!(should_recover_hid_error(Some(libc::ENOTCONN))); - assert!(should_recover_hid_error(Some(libc::ESHUTDOWN))); - assert!(should_recover_hid_error(Some(libc::EPIPE))); - assert!(!should_recover_hid_error(Some(libc::EAGAIN))); - assert!(!should_recover_hid_error(None)); - } - - #[test] - fn next_stream_id_monotonically_increments() { - let first = next_stream_id(); - let second = next_stream_id(); - assert!(second > first); - } - - #[test] - fn preferred_uac_device_candidates_keeps_custom_override_only() { - let candidates = preferred_uac_device_candidates("hw:7,0"); - assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]); - } - - #[test] - fn preferred_uac_device_candidates_handles_blank_and_plughw_overrides() { - assert!(preferred_uac_device_candidates(" ").is_empty()); - assert_eq!( - preferred_uac_device_candidates(" plughw:8,2 "), - vec!["plughw:8,2", "hw:8,2"] - ); - } - - #[test] - fn preferred_uac_device_candidates_expands_known_aliases() { - let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0"); - assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0")); - assert!( - candidates - .iter() - .any(|value| value == "plughw:UAC2Gadget,0") - ); - assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0")); - assert!(candidates.iter().any(|value| value == "hw:Composite,0")); - } - - #[test] - fn detect_uac_card_candidates_returns_hw_names_only() { - let live = detect_uac_card_candidates(); - assert!(live.iter().all(|value| value.starts_with("hw:"))); - } - - #[test] - fn parse_uac_card_helpers_collect_named_and_numeric_candidates() { - let cards = "\ - 0 [PCH ]: HDA-Intel - HDA Intel PCH\n\ - 2 [UAC2Gadget ]: USB-Audio - UAC2Gadget\n\ - Lesavka USB Audio\n"; - - assert_eq!( - parse_uac_named_card_candidates(cards), - vec!["hw:UAC2Gadget,0"] - ); - assert!( - parse_uac_numeric_card_ids(cards).contains("2"), - "expected numeric card index for the gadget card" - ); - } - - #[test] - fn parse_uac_card_helpers_ignore_malformed_candidates() { - let cards = "\ - XX [BrokenGadget ]: USB-Audio - UAC2 Gadget\n\ - 03 NoBracketGadget : USB-Audio - Lesavka\n\ - 04 [ ]: USB-Audio - Composite\n\ - 05 [Composite ]: USB-Audio - Composite\n"; - - assert_eq!( - parse_uac_named_card_candidates(cards), - vec!["hw:BrokenGadget,0", "hw:Composite,0"] - ); - assert_eq!( - parse_uac_numeric_card_ids(cards), - BTreeSet::from(["03".to_string(), "04".to_string(), "05".to_string()]) - ); - } - - #[test] - fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() { - let pcm = "\ -00-00: PCH device : playback 1 : capture 1\n\ -02-00: USB Audio : USB Audio : playback 1 : capture 1\n\ -02-01: USB Audio #1 : USB Audio #1 : playback 1 : capture 1\n"; - let ids = BTreeSet::from(["2".to_string()]); - - assert_eq!( - parse_uac_pcm_candidates(pcm, &ids), - vec!["hw:2,0", "hw:2,1"] - ); - } - - #[test] - fn parse_uac_pcm_candidates_normalizes_zeroes_and_skips_non_matching_cards() { - let pcm = "\ -00-00: Zero card : playback 1 : capture 1\n\ -09-03: Other card : playback 1 : capture 1\n\ -bad line without separator\n"; - let ids = BTreeSet::from(["0".to_string()]); - - assert_eq!(parse_uac_pcm_candidates(pcm, &ids), vec!["hw:0,0"]); - } - - #[tokio::test] - #[serial] - async fn open_with_retry_opens_existing_file() { - let tmp = NamedTempFile::new().expect("temp file"); - let mut file = open_with_retry(tmp.path().to_str().unwrap()) - .await - .expect("open should succeed"); - file.write_all(b"ok").await.expect("write temp file"); - file.sync_all().await.expect("sync temp file"); - assert_eq!( - tokio::fs::read(tmp.path()).await.expect("read temp file"), - b"ok" - ); - } - - #[test] - fn init_tracing_returns_a_guard_under_coverage() { - let _guard = init_tracing().expect("coverage tracing guard"); - } - - #[tokio::test] - #[serial] - async fn hid_open_helpers_return_contextual_errors_for_bad_paths() { - let dir = tempdir().expect("tempdir"); - let err = open_with_retry(dir.path().to_str().unwrap()) - .await - .expect_err("directory should not open as HID file"); - assert!(format!("{err:#}").contains("opening")); - - let err = open_hid_if_ready(dir.path().to_str().unwrap()) - .await - .expect_err("directory should be a hard open error"); - assert!(format!("{err:#}").contains("opening")); - } - - #[tokio::test] - #[serial] - async fn write_hid_report_writes_bytes() { - let tmp = NamedTempFile::new().expect("temp file"); - let file = tokio::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(tmp.path()) - .await - .expect("open temp file"); - let shared = Arc::new(Mutex::new(Some(file))); - - write_hid_report(&shared, tmp.path().to_str().unwrap(), &[1, 2, 3, 4]) - .await - .expect("write succeeds"); - - let contents = tokio::fs::read(tmp.path()) - .await - .expect("read back temp file"); - assert_eq!(&contents, &[1, 2, 3, 4]); - } - - #[tokio::test] - #[serial] - async fn write_hid_report_opens_lazily_when_handle_is_empty() { - let tmp = NamedTempFile::new().expect("temp file"); - let shared = Arc::new(Mutex::new(None)); - - write_hid_report(&shared, tmp.path().to_str().unwrap(), &[9, 8]) - .await - .expect("lazy write succeeds"); - - let contents = tokio::fs::read(tmp.path()) - .await - .expect("read back temp file"); - assert_eq!(&contents, &[9, 8]); - } - - #[test] - #[serial] - fn open_ear_with_retry_reports_bad_capture_device() { - let err = temp_env::with_vars( - [ - ("LESAVKA_AUDIO_INIT_ATTEMPTS", Some("1")), - ("LESAVKA_AUDIO_INIT_DELAY_MS", Some("0")), - ], - || { - let runtime = tokio::runtime::Runtime::new().expect("test runtime"); - match runtime.block_on(open_ear_with_retry( - "hw:DefinitelyMissingLesavkaDevice,99", - 99, - )) { - Ok(_) => panic!("missing ALSA source should fail"), - Err(err) => err, - } - }, - ); - assert!(!format!("{err:#}").is_empty()); - } -} +#[path = "tests/runtime_support.rs"] +mod tests; diff --git a/server/src/runtime_support/audio_discovery.rs b/server/src/runtime_support/audio_discovery.rs new file mode 100644 index 0000000..2340dc7 --- /dev/null +++ b/server/src/runtime_support/audio_discovery.rs @@ -0,0 +1,279 @@ + +/// Open the UAC sink with retry logic. +/// +/// Inputs: the ALSA device string that should receive microphone audio. +/// Outputs: a ready-to-use `Voice` sink. +/// Why: the USB audio gadget can appear after the RPC stream has already been +/// negotiated, so the server retries briefly before declaring the sink broken. +#[cfg(coverage)] +pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { + audio::Voice::new(uac_dev).await +} + +#[cfg(not(coverage))] +pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { + let candidates = preferred_uac_device_candidates(uac_dev); + let (attempts, delay_ms) = audio_init_retry_policy(); + let mut last_error: Option = None; + + for attempt in 1..=attempts { + for candidate in &candidates { + match audio::Voice::new(candidate).await { + Ok(voice) => { + if attempt > 1 || candidate != uac_dev { + info!( + requested = %uac_dev, + resolved = %candidate, + attempt, + "🎤 microphone sink recovered" + ); + } + return Ok(voice); + } + Err(error) => { + warn!( + requested = %uac_dev, + candidate = %candidate, + attempt, + "⚠️ microphone sink init failed: {error:#}" + ); + last_error = Some(error); + } + } + } + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed"))) +} + +/// Open the UAC capture source with retry logic. +/// +/// Inputs: the preferred ALSA device string plus the logical stream id. +/// Outputs: a ready-to-stream AAC capture pipeline. +/// Why: the USB gadget card name is not always stable, so the server should +/// retry both the preferred name and any discovered aliases before failing. +#[cfg(coverage)] +pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { + audio::ear(alsa_dev, id).await +} + +#[cfg(not(coverage))] +pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { + let candidates = preferred_uac_device_candidates(alsa_dev); + let (attempts, delay_ms) = audio_init_retry_policy(); + let mut last_error: Option = None; + + for attempt in 1..=attempts { + for candidate in &candidates { + match audio::ear(candidate, id).await { + Ok(stream) => { + if attempt > 1 || candidate != alsa_dev { + info!( + requested = %alsa_dev, + resolved = %candidate, + attempt, + "🔊 audio source recovered" + ); + } + return Ok(stream); + } + Err(error) => { + warn!( + requested = %alsa_dev, + candidate = %candidate, + attempt, + "⚠️ audio source init failed: {error:#}" + ); + last_error = Some(error); + } + } + } + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed"))) +} + +#[cfg(not(coverage))] +fn audio_init_retry_policy() -> (u32, u64) { + let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + std::env::var("LESAVKA_MIC_INIT_ATTEMPTS") + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(20) + .max(1); + let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + std::env::var("LESAVKA_MIC_INIT_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(250); + (attempts, delay_ms) +} + +fn preferred_uac_device_candidates(preferred: &str) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + let auto_family = [ + "hw:UAC2Gadget,0", + "hw:UAC2_Gadget,0", + "hw:Composite,0", + "hw:Lesavka,0", + ]; + let allow_aliases = auto_family.contains(&preferred); + push_audio_candidate_family(&mut out, &mut seen, preferred); + if allow_aliases { + for detected in detect_uac_card_candidates() { + push_audio_candidate_family(&mut out, &mut seen, &detected); + } + for alias in auto_family { + push_audio_candidate_family(&mut out, &mut seen, alias); + } + } + out +} + +fn push_audio_candidate_family( + out: &mut Vec, + seen: &mut BTreeSet, + candidate: &str, +) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + push_audio_candidate(out, seen, trimmed); + if let Some(rest) = trimmed.strip_prefix("hw:") { + push_audio_candidate(out, seen, &format!("plughw:{rest}")); + } else if let Some(rest) = trimmed.strip_prefix("plughw:") { + push_audio_candidate(out, seen, &format!("hw:{rest}")); + } +} + +fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + if seen.insert(trimmed.to_string()) { + out.push(trimmed.to_string()); + } +} + +fn detect_uac_card_candidates() -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + let card_data = asound_cards_snapshot(); + let numeric_card_ids = card_data + .as_deref() + .map(parse_uac_numeric_card_ids) + .unwrap_or_default(); + + if let Some(cards) = card_data.as_deref() { + for candidate in parse_uac_named_card_candidates(cards) { + push_audio_candidate(&mut out, &mut seen, &candidate); + } + } + if let Some(pcm) = asound_pcm_snapshot() { + for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) { + push_audio_candidate(&mut out, &mut seen, &candidate); + } + } + out +} + +#[cfg(coverage)] +fn asound_cards_snapshot() -> Option { + std::env::var("LESAVKA_TEST_ASOUND_CARDS") + .ok() + .or_else(|| fs::read_to_string("/proc/asound/cards").ok()) +} + +#[cfg(not(coverage))] +fn asound_cards_snapshot() -> Option { + fs::read_to_string("/proc/asound/cards").ok() +} + +#[cfg(coverage)] +fn asound_pcm_snapshot() -> Option { + std::env::var("LESAVKA_TEST_ASOUND_PCM") + .ok() + .or_else(|| fs::read_to_string("/proc/asound/pcm").ok()) +} + +#[cfg(not(coverage))] +fn asound_pcm_snapshot() -> Option { + fs::read_to_string("/proc/asound/pcm").ok() +} + +fn parse_uac_named_card_candidates(cards: &str) -> Vec { + cards + .lines() + .filter_map(|line| { + let lower = line.to_ascii_lowercase(); + if !(lower.contains("uac2") + || lower.contains("gadget") + || lower.contains("composite") + || lower.contains("lesavka")) + { + return None; + } + let start = line.find('[')?; + let end = line[start + 1..].find(']')?; + let card_id = line[start + 1..start + 1 + end].trim(); + (!card_id.is_empty()).then(|| format!("hw:{card_id},0")) + }) + .collect() +} + +fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { + cards + .lines() + .filter_map(|line| { + let lower = line.to_ascii_lowercase(); + if !(lower.contains("uac2") + || lower.contains("gadget") + || lower.contains("composite") + || lower.contains("lesavka")) + { + return None; + } + line.split_whitespace() + .next() + .filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit())) + .map(|candidate| candidate.to_string()) + }) + .collect() +} + +fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet) -> Vec { + pcm.lines() + .filter_map(|line| { + let (prefix, _) = line.split_once(':')?; + let (card_id, device_id) = prefix.split_once('-')?; + let normalized_card = card_id.trim_start_matches('0'); + let normalized_card = if normalized_card.is_empty() { + "0" + } else { + normalized_card + }; + let normalized_device = device_id.trim_start_matches('0'); + let normalized_device = if normalized_device.is_empty() { + "0" + } else { + normalized_device + }; + numeric_card_ids + .contains(normalized_card) + .then(|| format!("hw:{normalized_card},{normalized_device}")) + }) + .collect() +} diff --git a/server/src/runtime_support/hid_recovery.rs b/server/src/runtime_support/hid_recovery.rs new file mode 100644 index 0000000..9909283 --- /dev/null +++ b/server/src/runtime_support/hid_recovery.rs @@ -0,0 +1,242 @@ +use anyhow::Context as _; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use std::{collections::BTreeSet, fs}; +use tokio::fs::OpenOptions; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use tracing::{error, info, trace, warn}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; + +use crate::{audio, gadget::UsbGadget}; + +static STREAM_SEQ: AtomicU64 = AtomicU64::new(1); + +/// Initialise structured tracing for the server process. +/// +/// Inputs: none; configuration is read from `RUST_LOG`. +/// Outputs: the non-blocking file writer guard that must stay alive for the +/// lifetime of the process. +/// Why: the server writes both to stdout and a local log file so field logs are +/// still available after a transient SSH disconnect. +#[cfg(coverage)] +pub fn init_tracing() -> anyhow::Result { + let (_writer, guard) = tracing_appender::non_blocking(std::io::sink()); + Ok(guard) +} + +#[cfg(not(coverage))] +pub fn init_tracing() -> anyhow::Result { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open("/tmp/lesavka-server.log")?; + let (file_writer, guard) = tracing_appender::non_blocking(file); + + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("lesavka_server=info,lesavka_server::video=warn")); + let filter_str = env_filter.to_string(); + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt::layer().with_target(true).with_thread_ids(true)) + .with( + fmt::layer() + .with_writer(file_writer) + .with_ansi(false) + .with_target(true) + .with_level(true), + ) + .init(); + tracing::info!("📜 effective RUST_LOG = \"{}\"", filter_str); + Ok(guard) +} + +/// Open a HID gadget endpoint with bounded retry logic. +/// +/// Inputs: the path of the gadget device node to open. +/// Outputs: a writable non-blocking file handle once the kernel reports the +/// endpoint as ready. +/// Why: gadget endpoints frequently flap during cable changes, so the server +/// must wait for readiness instead of failing the whole process immediately. +#[cfg(coverage)] +pub async fn open_with_retry(path: &str) -> anyhow::Result { + open_hid_file(path) + .await + .with_context(|| format!("opening {path}")) +} + +#[cfg(not(coverage))] +pub async fn open_with_retry(path: &str) -> anyhow::Result { + for attempt in 1..=200 { + match open_hid_file(path).await { + Ok(file) => { + info!("✅ {path} opened on attempt #{attempt}"); + return Ok(file); + } + Err(error) + if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error()) + || error.raw_os_error() == Some(libc::EBUSY) => + { + trace!("⏳ {path} unavailable ({error})… retry #{attempt}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => return Err(error).with_context(|| format!("opening {path}")), + } + } + + Err(anyhow::anyhow!("timeout waiting for {path}")) +} + +async fn open_hid_file(path: &str) -> std::io::Result { + OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .await +} + +pub async fn open_hid_if_ready(path: &str) -> anyhow::Result> { + match open_hid_file(path).await { + Ok(file) => { + info!("✅ {path} opened"); + Ok(Some(file)) + } + Err(error) if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error()) => { + warn!("⌛ {path} is not ready yet ({error}); relay will retry lazily"); + Ok(None) + } + Err(error) => Err(error).with_context(|| format!("opening {path}")), + } +} + +#[must_use] +pub fn hid_endpoint_open_is_temporarily_unavailable(code: Option) -> bool { + matches!( + code, + Some(libc::ENOENT) | Some(libc::ENODEV) | Some(libc::ENXIO) + ) +} + +/// Check whether gadget auto-recovery is enabled. +/// +/// Inputs: none. +/// Outputs: `true` only when the explicit recovery opt-in env var is present. +/// Why: cycling the whole USB gadget can be disruptive, so operators must +/// choose that behavior deliberately on each deployment. +#[must_use] +pub fn allow_gadget_cycle() -> bool { + std::env::var("LESAVKA_ALLOW_GADGET_CYCLE").is_ok() +} + +/// Return whether a HID write error should trigger recovery. +/// +/// Inputs: the raw `errno` value observed while writing to a HID gadget. +/// Outputs: `true` when the error is consistent with a lost USB connection. +/// Why: only transport-level failures should cause device reopen and gadget +/// cycling; transient backpressure is handled elsewhere. +#[must_use] +pub fn should_recover_hid_error(code: Option) -> bool { + matches!( + code, + Some(libc::ENOTCONN) | Some(libc::ESHUTDOWN) | Some(libc::EPIPE) + ) || hid_endpoint_open_is_temporarily_unavailable(code) +} + +/// Recover the HID endpoints after a transport failure. +/// +/// Inputs: the write error plus the current gadget and file handles. +/// Outputs: none; recovery runs asynchronously and updates the shared handles +/// in place when reopening succeeds. +/// Why: streams should survive cable resets without dropping the entire server +/// process or requiring a manual restart from the operator. +#[cfg(coverage)] +pub async fn recover_hid_if_needed( + err: &std::io::Error, + gadget: UsbGadget, + kb: Arc>>, + ms: Arc>>, + _kb_path: String, + _ms_path: String, + did_cycle: Arc, +) { + let code = err.raw_os_error(); + if !should_recover_hid_error(code) { + return; + } + + if did_cycle + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + let allow_cycle = allow_gadget_cycle(); + tokio::spawn(async move { + if allow_cycle { + let _ = tokio::task::spawn_blocking(move || gadget.cycle()).await; + } else { + let _ = (kb, ms); + } + tokio::time::sleep(Duration::from_secs(2)).await; + did_cycle.store(false, Ordering::SeqCst); + }); +} + +#[cfg(not(coverage))] +pub async fn recover_hid_if_needed( + err: &std::io::Error, + gadget: UsbGadget, + kb: Arc>>, + ms: Arc>>, + kb_path: String, + ms_path: String, + did_cycle: Arc, +) { + let code = err.raw_os_error(); + if !should_recover_hid_error(code) { + return; + } + + if did_cycle + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + let allow_cycle = allow_gadget_cycle(); + tokio::spawn(async move { + if allow_cycle { + warn!("🔁 HID transport down (errno={code:?}) - aggressively recovering gadget"); + match tokio::task::spawn_blocking(move || gadget.recover_enumeration()).await { + Ok(Ok(())) => info!("✅ USB gadget recovery complete (auto-recover)"), + Ok(Err(error)) => error!("💥 USB gadget recovery failed: {error:#}"), + Err(error) => error!("💥 USB gadget recovery task panicked: {error:#}"), + } + } else { + warn!( + "🔒 HID transport down (errno={code:?}) - gadget cycle disabled; set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable" + ); + } + + if let Err(error) = async { + let kb_new = open_hid_if_ready(&kb_path).await?; + let ms_new = open_hid_if_ready(&ms_path).await?; + *kb.lock().await = kb_new; + *ms.lock().await = ms_new; + Ok::<(), anyhow::Error>(()) + } + .await + { + error!("💥 HID reopen failed: {error:#}"); + } + + tokio::time::sleep(Duration::from_secs(2)).await; + did_cycle.store(false, Ordering::SeqCst); + }); +} diff --git a/server/src/runtime_support/hid_write.rs b/server/src/runtime_support/hid_write.rs new file mode 100644 index 0000000..ccbf020 --- /dev/null +++ b/server/src/runtime_support/hid_write.rs @@ -0,0 +1,90 @@ + +/// Allocate a stream identifier for logging and correlation. +/// +/// Inputs: none. +/// Outputs: a monotonically increasing identifier. +/// Why: the server multiplexes several long-lived streams, so log lines need a +/// cheap correlation id that is stable across retries. +#[must_use] +pub fn next_stream_id() -> u64 { + STREAM_SEQ.fetch_add(1, Ordering::Relaxed) +} + +/// Write one HID report with a short bounded retry loop. +/// +/// Inputs: the shared gadget file handle plus the already-encoded report. +/// Outputs: `Ok(())` when the report reached the kernel buffer, or the final +/// write error after retrying transient backpressure. +/// Why: a brief retry window avoids dropping reports during momentary gadget +/// stalls without blocking the stream task indefinitely. +#[cfg(coverage)] +pub async fn write_hid_report( + dev: &Arc>>, + path: &str, + data: &[u8], +) -> std::io::Result<()> { + let mut file = dev.lock().await; + if file.is_none() { + *file = Some(open_hid_file(path).await?); + } + file.as_mut() + .expect("HID endpoint is initialized after lazy open") + .write_all(data) + .await +} + +#[cfg(not(coverage))] +pub async fn write_hid_report( + dev: &Arc>>, + path: &str, + data: &[u8], +) -> std::io::Result<()> { + let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(24) + .max(1); + let base_delay_ms = std::env::var("LESAVKA_HID_WRITE_RETRY_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(2) + .max(1); + let mut last_error: Option = None; + for attempt in 0..attempts { + let mut file = dev.lock().await; + if file.is_none() { + match open_hid_file(path).await { + Ok(opened) => { + info!("✅ {path} opened lazily"); + *file = Some(opened); + } + Err(error) => return Err(error), + } + } + let Some(file_handle) = file.as_mut() else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "HID endpoint is not open", + )); + }; + match file_handle.write_all(data).await { + Ok(()) => return Ok(()), + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.raw_os_error() == Some(libc::EAGAIN) => + { + last_error = Some(error); + } + Err(error) => { + if should_recover_hid_error(error.raw_os_error()) { + *file = None; + } + return Err(error); + } + } + drop(file); + tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * base_delay_ms)).await; + } + + Err(last_error.unwrap_or_else(|| std::io::Error::from_raw_os_error(libc::EAGAIN))) +} diff --git a/server/src/tests/audio_1.rs b/server/src/tests/audio_1.rs new file mode 100644 index 0000000..bd9561d --- /dev/null +++ b/server/src/tests/audio_1.rs @@ -0,0 +1,7 @@ +use super::Voice; + +#[tokio::test] +async fn coverage_voice_constructor_starts_stub_pipeline() { + let mut voice = Voice::new("coverage-audio").await.expect("voice"); + voice.finish(); +} diff --git a/server/src/tests/audio_2.rs b/server/src/tests/audio_2.rs new file mode 100644 index 0000000..44ae087 --- /dev/null +++ b/server/src/tests/audio_2.rs @@ -0,0 +1,63 @@ +use super::{build_pipeline_desc, ensure_remote_usb_audio_ready}; +use temp_env::with_vars; +use tempfile::tempdir; + +#[test] +fn speaker_downlink_pipeline_keeps_aac_adts_transport_and_level_probe() { + let _ = super::gst::init(); + let result = build_pipeline_desc("hw:Loopback,0"); + match result { + Ok(desc) => { + assert!(desc.contains("alsasrc device=\"hw:Loopback,0\"")); + assert!(desc.contains("audio/x-raw,format=S16LE,channels=2,rate=48000")); + assert!(desc.contains("aacparse")); + assert!(desc.contains("stream-format=adts")); + assert!(desc.contains("level name=source_level")); + assert!(desc.contains("appsink name=asink")); + } + Err(err) => { + assert!( + err.to_string().contains("no AAC encoder plugin available"), + "unexpected build failure: {err:#}" + ); + } + } +} + +#[test] +fn remote_usb_audio_reports_not_attached_gadget() { + let dir = tempdir().expect("tempdir"); + let cfg_root = dir.path().join("cfg"); + let sys_root = dir.path().join("sys"); + let udc_dir = sys_root.join("class/udc/fake-ctrl.usb"); + std::fs::create_dir_all(cfg_root.join("lesavka")).expect("cfg"); + std::fs::create_dir_all(&udc_dir).expect("udc"); + std::fs::write(cfg_root.join("lesavka/UDC"), "fake-ctrl.usb\n").expect("udc file"); + std::fs::write(udc_dir.join("state"), "not attached\n").expect("state"); + + with_vars( + [ + ( + "LESAVKA_GADGET_CONFIGFS_ROOT", + Some(cfg_root.to_string_lossy().to_string()), + ), + ( + "LESAVKA_GADGET_SYSFS_ROOT", + Some(sys_root.to_string_lossy().to_string()), + ), + ], + || { + let err = ensure_remote_usb_audio_ready("hw:UAC2Gadget,0") + .expect_err("not attached gadget should block remote speaker audio"); + assert!( + err.to_string() + .contains("remote USB gadget is not attached") + ); + }, + ); +} + +#[test] +fn remote_usb_audio_allows_non_gadget_override() { + ensure_remote_usb_audio_ready("hw:Loopback,0").expect("non-gadget override"); +} diff --git a/server/src/tests/camera.rs b/server/src/tests/camera.rs new file mode 100644 index 0000000..7c681ef --- /dev/null +++ b/server/src/tests/camera.rs @@ -0,0 +1,193 @@ +use super::{ + CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config, + parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode, update_camera_config, +}; +use serial_test::serial; +use temp_env::with_var; + +#[test] +#[serial] +fn camera_config_env_override_prefers_uvc_values() { + with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { + with_var("LESAVKA_UVC_WIDTH", Some("800"), || { + with_var("LESAVKA_UVC_HEIGHT", Some("600"), || { + with_var("LESAVKA_UVC_FPS", Some("24"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Uvc); + assert_eq!(cfg.codec, CameraCodec::Mjpeg); + assert_eq!(cfg.width, 800); + assert_eq!(cfg.height, 600); + assert_eq!(cfg.fps, 24); + + let cached = current_camera_config(); + assert_eq!(cached.output, CameraOutput::Uvc); + assert_eq!(cached.codec, CameraCodec::Mjpeg); + assert_eq!(cached.width, 800); + assert_eq!(cached.height, 600); + assert_eq!(cached.fps, 24); + }); + }); + }); + }); +} + +#[test] +#[serial] +fn hdmi_camera_profile_honors_installed_1080p_override() { + with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { + with_var("LESAVKA_CAM_WIDTH", Some("1920"), || { + with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || { + with_var("LESAVKA_CAM_FPS", Some("30"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Hdmi); + assert_eq!(cfg.codec, CameraCodec::H264); + assert_eq!(cfg.width, 1920); + assert_eq!(cfg.height, 1080); + assert_eq!(cfg.fps, 30); + }); + }); + }); + }); +} + +#[test] +fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() { + assert_eq!( + parse_hdmi_mode("1920x1080"), + Some(HdmiMode { + width: 1920, + height: 1080, + }) + ); + assert_eq!( + parse_hdmi_mode("1280x720p60"), + Some(HdmiMode { + width: 1280, + height: 720, + }) + ); + assert_eq!(parse_hdmi_mode("not-a-mode"), None); + + let modes = parse_hdmi_modes("1920x1080\n1024x768,800x600\n"); + assert_eq!(modes.len(), 3); + assert_eq!(modes[0].width, 1920); + assert_eq!(modes[2].height, 600); +} + +#[test] +fn preferred_hdmi_mode_chooses_standard_capture_adapter_mode() { + let modes = parse_hdmi_modes("1024x768\n1920x1080\n800x600\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1920, + height: 1080, + }) + ); + + let modes = parse_hdmi_modes("1600x900\n1024x768\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1600, + height: 900, + }) + ); + + let modes = parse_hdmi_modes("1024x768\n800x600\n"); + assert_eq!( + preferred_hdmi_mode(&modes), + Some(HdmiMode { + width: 1024, + height: 768, + }) + ); +} + +#[test] +#[serial] +fn hdmi_display_size_uses_adapter_mode_without_changing_uplink_profile() { + let cfg = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("card1-HDMI-A-2"), + id: Some(43), + modes: parse_hdmi_modes("1920x1080\n1024x768\n800x600\n"), + }), + }; + + with_var("LESAVKA_HDMI_WIDTH", None::<&str>, || { + with_var("LESAVKA_HDMI_HEIGHT", None::<&str>, || { + assert_eq!((cfg.width, cfg.height), (1280, 720)); + assert_eq!(cfg.hdmi_display_size(), (1920, 1080)); + }); + }); +} + +#[test] +#[serial] +fn hdmi_display_size_honors_explicit_local_override() { + let cfg = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1280, + height: 720, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("card1-HDMI-A-2"), + id: Some(43), + modes: parse_hdmi_modes("1920x1080\n"), + }), + }; + + with_var("LESAVKA_HDMI_WIDTH", Some("1024"), || { + with_var("LESAVKA_HDMI_HEIGHT", Some("768"), || { + assert_eq!(cfg.hdmi_display_size(), (1024, 768)); + }); + }); +} + +#[test] +fn non_hdmi_display_size_uses_camera_profile() { + let cfg = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 800, + height: 600, + fps: 25, + hdmi: None, + }; + + assert_eq!(cfg.hdmi_display_size(), (800, 600)); +} + +#[test] +#[cfg(coverage)] +#[serial] +fn coverage_hdmi_connector_env_sets_adapter_display_mode() { + temp_env::with_vars( + [ + ("LESAVKA_CAM_OUTPUT", Some("hdmi")), + ("LESAVKA_CAM_WIDTH", Some("1280")), + ("LESAVKA_CAM_HEIGHT", Some("720")), + ("LESAVKA_CAM_FPS", Some("30")), + ("LESAVKA_HDMI_CONNECTOR", Some("card1-HDMI-A-2")), + ("LESAVKA_HDMI_MODES", Some("1920x1080,1280x720")), + ("LESAVKA_HDMI_WIDTH", None), + ("LESAVKA_HDMI_HEIGHT", None), + ], + || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Hdmi); + assert_eq!((cfg.width, cfg.height, cfg.fps), (1280, 720, 30)); + let hdmi = cfg.hdmi.as_ref().expect("HDMI connector from env"); + assert_eq!(hdmi.name, "card1-HDMI-A-2"); + assert_eq!(hdmi.id, None); + assert_eq!(cfg.hdmi_display_size(), (1920, 1080)); + }, + ); +} diff --git a/server/src/tests/runtime_support.rs b/server/src/tests/runtime_support.rs new file mode 100644 index 0000000..70ea6da --- /dev/null +++ b/server/src/tests/runtime_support.rs @@ -0,0 +1,275 @@ +use super::{ + allow_gadget_cycle, detect_uac_card_candidates, init_tracing, next_stream_id, + open_ear_with_retry, open_hid_if_ready, open_with_retry, parse_uac_named_card_candidates, + parse_uac_numeric_card_ids, parse_uac_pcm_candidates, preferred_uac_device_candidates, + push_audio_candidate, push_audio_candidate_family, should_recover_hid_error, write_hid_report, +}; +use serial_test::serial; +use std::collections::BTreeSet; +use std::sync::Arc; +use temp_env::with_var; +use tempfile::NamedTempFile; +use tempfile::tempdir; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +#[test] +#[serial] +fn allow_gadget_cycle_tracks_env_presence() { + with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || { + assert!(!allow_gadget_cycle()); + }); + with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { + assert!(allow_gadget_cycle()); + }); +} + +#[test] +fn should_recover_hid_error_matches_transport_failures() { + assert!(should_recover_hid_error(Some(libc::ENOTCONN))); + assert!(should_recover_hid_error(Some(libc::ESHUTDOWN))); + assert!(should_recover_hid_error(Some(libc::EPIPE))); + assert!(!should_recover_hid_error(Some(libc::EAGAIN))); + assert!(!should_recover_hid_error(None)); +} + +#[test] +fn next_stream_id_monotonically_increments() { + let first = next_stream_id(); + let second = next_stream_id(); + assert!(second > first); +} + +#[test] +fn preferred_uac_device_candidates_keeps_custom_override_only() { + let candidates = preferred_uac_device_candidates("hw:7,0"); + assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]); +} + +#[test] +fn preferred_uac_device_candidates_handles_blank_and_plughw_overrides() { + assert!(preferred_uac_device_candidates(" ").is_empty()); + assert_eq!( + preferred_uac_device_candidates(" plughw:8,2 "), + vec!["plughw:8,2", "hw:8,2"] + ); +} + +#[test] +fn preferred_uac_device_candidates_expands_known_aliases() { + let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0"); + assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0")); + assert!( + candidates + .iter() + .any(|value| value == "plughw:UAC2Gadget,0") + ); + assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0")); + assert!(candidates.iter().any(|value| value == "hw:Composite,0")); +} + +#[test] +fn audio_candidate_helpers_dedupe_and_pair_hw_plughw_forms() { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + + push_audio_candidate(&mut out, &mut seen, " "); + push_audio_candidate(&mut out, &mut seen, " hw:9,0 "); + push_audio_candidate(&mut out, &mut seen, "hw:9,0"); + push_audio_candidate_family(&mut out, &mut seen, "plughw:9,0"); + push_audio_candidate_family(&mut out, &mut seen, " "); + + assert_eq!(out, vec!["hw:9,0".to_string(), "plughw:9,0".to_string()]); +} + +#[test] +#[cfg(coverage)] +#[serial] +fn preferred_uac_candidates_include_detected_cards_before_static_aliases() { + temp_env::with_vars( + [ + ( + "LESAVKA_TEST_ASOUND_CARDS", + Some( + " 7 [DetectedGadget ]: USB-Audio - UAC2 Gadget\n\ + 8 [LesavkaAudio ]: USB-Audio - Lesavka\n", + ), + ), + ( + "LESAVKA_TEST_ASOUND_PCM", + Some("07-03: USB Audio : USB Audio : playback 1 : capture 1\n"), + ), + ], + || { + let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0"); + assert!( + candidates + .iter() + .any(|value| value == "hw:DetectedGadget,0") + ); + assert!(candidates.iter().any(|value| value == "hw:LesavkaAudio,0")); + assert!(candidates.iter().any(|value| value == "hw:7,3")); + }, + ); +} + +#[test] +fn detect_uac_card_candidates_returns_hw_names_only() { + let live = detect_uac_card_candidates(); + assert!(live.iter().all(|value| value.starts_with("hw:"))); +} + +#[test] +fn parse_uac_card_helpers_collect_named_and_numeric_candidates() { + let cards = "\ + 0 [PCH ]: HDA-Intel - HDA Intel PCH\n\ + 2 [UAC2Gadget ]: USB-Audio - UAC2Gadget\n\ + Lesavka USB Audio\n"; + + assert_eq!( + parse_uac_named_card_candidates(cards), + vec!["hw:UAC2Gadget,0"] + ); + assert!( + parse_uac_numeric_card_ids(cards).contains("2"), + "expected numeric card index for the gadget card" + ); +} + +#[test] +fn parse_uac_card_helpers_ignore_malformed_candidates() { + let cards = "\ + XX [BrokenGadget ]: USB-Audio - UAC2 Gadget\n\ + 03 NoBracketGadget : USB-Audio - Lesavka\n\ + 04 [ ]: USB-Audio - Composite\n\ + 05 [Composite ]: USB-Audio - Composite\n"; + + assert_eq!( + parse_uac_named_card_candidates(cards), + vec!["hw:BrokenGadget,0", "hw:Composite,0"] + ); + assert_eq!( + parse_uac_numeric_card_ids(cards), + BTreeSet::from(["03".to_string(), "04".to_string(), "05".to_string()]) + ); +} + +#[test] +fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() { + let pcm = "\ +00-00: PCH device : playback 1 : capture 1\n\ +02-00: USB Audio : USB Audio : playback 1 : capture 1\n\ +02-01: USB Audio #1 : USB Audio #1 : playback 1 : capture 1\n"; + let ids = BTreeSet::from(["2".to_string()]); + + assert_eq!( + parse_uac_pcm_candidates(pcm, &ids), + vec!["hw:2,0", "hw:2,1"] + ); +} + +#[test] +fn parse_uac_pcm_candidates_normalizes_zeroes_and_skips_non_matching_cards() { + let pcm = "\ +00-00: Zero card : playback 1 : capture 1\n\ +09-03: Other card : playback 1 : capture 1\n\ +bad line without separator\n"; + let ids = BTreeSet::from(["0".to_string()]); + + assert_eq!(parse_uac_pcm_candidates(pcm, &ids), vec!["hw:0,0"]); +} + +#[tokio::test] +#[serial] +async fn open_with_retry_opens_existing_file() { + let tmp = NamedTempFile::new().expect("temp file"); + let mut file = open_with_retry(tmp.path().to_str().unwrap()) + .await + .expect("open should succeed"); + file.write_all(b"ok").await.expect("write temp file"); + file.sync_all().await.expect("sync temp file"); + assert_eq!( + tokio::fs::read(tmp.path()).await.expect("read temp file"), + b"ok" + ); +} + +#[test] +fn init_tracing_returns_a_guard_under_coverage() { + let _guard = init_tracing().expect("coverage tracing guard"); +} + +#[tokio::test] +#[serial] +async fn hid_open_helpers_return_contextual_errors_for_bad_paths() { + let dir = tempdir().expect("tempdir"); + let err = open_with_retry(dir.path().to_str().unwrap()) + .await + .expect_err("directory should not open as HID file"); + assert!(format!("{err:#}").contains("opening")); + + let err = open_hid_if_ready(dir.path().to_str().unwrap()) + .await + .expect_err("directory should be a hard open error"); + assert!(format!("{err:#}").contains("opening")); +} + +#[tokio::test] +#[serial] +async fn write_hid_report_writes_bytes() { + let tmp = NamedTempFile::new().expect("temp file"); + let file = tokio::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(tmp.path()) + .await + .expect("open temp file"); + let shared = Arc::new(Mutex::new(Some(file))); + + write_hid_report(&shared, tmp.path().to_str().unwrap(), &[1, 2, 3, 4]) + .await + .expect("write succeeds"); + + let contents = tokio::fs::read(tmp.path()) + .await + .expect("read back temp file"); + assert_eq!(&contents, &[1, 2, 3, 4]); +} + +#[tokio::test] +#[serial] +async fn write_hid_report_opens_lazily_when_handle_is_empty() { + let tmp = NamedTempFile::new().expect("temp file"); + let shared = Arc::new(Mutex::new(None)); + + write_hid_report(&shared, tmp.path().to_str().unwrap(), &[9, 8]) + .await + .expect("lazy write succeeds"); + + let contents = tokio::fs::read(tmp.path()) + .await + .expect("read back temp file"); + assert_eq!(&contents, &[9, 8]); +} + +#[test] +#[serial] +fn open_ear_with_retry_reports_bad_capture_device() { + let err = temp_env::with_vars( + [ + ("LESAVKA_AUDIO_INIT_ATTEMPTS", Some("1")), + ("LESAVKA_AUDIO_INIT_DELAY_MS", Some("0")), + ], + || { + let runtime = tokio::runtime::Runtime::new().expect("test runtime"); + match runtime.block_on(open_ear_with_retry( + "hw:DefinitelyMissingLesavkaDevice,99", + 99, + )) { + Ok(_) => panic!("missing ALSA source should fail"), + Err(err) => err, + } + }, + ); + assert!(!format!("{err:#}").is_empty()); +} diff --git a/server/src/tests/video.rs b/server/src/tests/video.rs new file mode 100644 index 0000000..0f6b70b --- /dev/null +++ b/server/src/tests/video.rs @@ -0,0 +1,174 @@ +use super::*; +use serial_test::serial; + +#[test] +fn source_profile_stays_pass_through_without_explicit_reencode_request() { + let request = normalize_eye_capture_request(1920, 1080, 30, 12_000); + + assert_eq!(request.width, 1920); + assert_eq!(request.height, 1080); + assert_eq!(request.fps, 60); +} + +#[test] +fn source_mode_selection_prefers_native_modes_without_reencode() { + let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500); + let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000); + let smaller_mode_request = normalize_eye_capture_request(1600, 900, 30, 12_000); + + assert_eq!(bitrate_request.fps, 60); + assert_eq!(fps_request.fps, 60); + assert_eq!(smaller_mode_request.width, 1280); + assert_eq!(smaller_mode_request.height, 720); + assert_eq!(smaller_mode_request.fps, 60); +} + +fn marker_frame(width: i32, height: i32) -> Vec { + let mut rgba = vec![0_u8; (width * height * 4) as usize]; + let marker = 96; + for y in 0..height { + for x in 0..width { + let idx = ((y * width + x) * 4) as usize; + let (r, g, b) = if x < marker && y < marker { + (255, 0, 0) + } else if x >= width - marker && y < marker { + (0, 255, 0) + } else if x < marker && y >= height - marker { + (0, 0, 255) + } else if x >= width - marker && y >= height - marker { + (255, 255, 0) + } else { + (24, 24, 24) + }; + rgba[idx..idx + 4].copy_from_slice(&[r, g, b, 255]); + } + } + rgba +} + +fn pull_reencoded_frame_rgba( + width: i32, + height: i32, + input_fps: u32, + output_fps: u32, +) -> anyhow::Result<(i32, i32, Vec)> { + gst::init().context("gst init")?; + let desc = format!( + "appsrc name=src is-live=false format=time block=true ! \ + videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={input_fps}/1,pixel-aspect-ratio=1/1 ! \ + x264enc tune=zerolatency speed-preset=veryfast bitrate=12000 key-int-max={input_fps} option-string=sar=1/1 ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + avdec_h264 ! videoconvert ! videoscale add-borders=false ! videorate ! \ + video/x-raw,format=I420,width={width},height={height},framerate={output_fps}/1,pixel-aspect-ratio=1/1 ! \ + x264enc tune=zerolatency speed-preset=faster bitrate=12000 key-int-max=5 option-string=sar=1/1 ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + avdec_h264 ! videoconvert ! video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" + ); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("pipeline"); + let appsrc = pipeline + .by_name("src") + .expect("appsrc") + .downcast::() + .expect("appsrc cast"); + appsrc.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", "RGBA") + .field("width", width) + .field("height", height) + .field("framerate", gst::Fraction::new(input_fps as i32, 1)) + .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) + .build(), + )); + appsrc.set_format(gst::Format::Time); + let appsink = pipeline + .by_name("sink") + .expect("appsink") + .downcast::() + .expect("appsink cast"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", "RGBA") + .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) + .build(), + )); + + pipeline + .set_state(gst::State::Playing) + .context("starting reencode probe pipeline")?; + + let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height)); + if let Some(buf) = buffer.get_mut() { + buf.set_pts(Some(gst::ClockTime::ZERO)); + buf.set_duration(Some(gst::ClockTime::from_nseconds( + 1_000_000_000_u64 / input_fps.max(1) as u64, + ))); + } + appsrc + .push_buffer(buffer) + .map_err(|err| anyhow::anyhow!("push buffer failed: {err:?}"))?; + appsrc + .end_of_stream() + .map_err(|err| anyhow::anyhow!("eos failed: {err:?}"))?; + + let sample = appsink + .try_pull_sample(gst::ClockTime::from_seconds(5)) + .ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?; + let caps = sample + .caps() + .ok_or_else(|| anyhow::anyhow!("missing sample caps"))?; + let structure = caps + .structure(0) + .ok_or_else(|| anyhow::anyhow!("missing caps structure"))?; + let out_width = structure + .get::("width") + .map_err(|err| anyhow::anyhow!("missing output width: {err}"))?; + let out_height = structure + .get::("height") + .map_err(|err| anyhow::anyhow!("missing output height: {err}"))?; + let buffer = sample + .buffer() + .ok_or_else(|| anyhow::anyhow!("missing sample buffer"))?; + let map = buffer + .map_readable() + .map_err(|_| anyhow::anyhow!("sample map failed"))?; + let rgba = map.as_slice().to_vec(); + let _ = pipeline.set_state(gst::State::Null); + Ok((out_width, out_height, rgba)) +} + +fn rgba_pixel(rgba: &[u8], width: i32, x: i32, y: i32) -> (u8, u8, u8) { + let idx = ((y * width + x) * 4) as usize; + (rgba[idx], rgba[idx + 1], rgba[idx + 2]) +} + +#[test] +#[serial] +fn reencode_probe_preserves_corner_markers_on_full_frame_content() { + let (width, height, rgba) = pull_reencoded_frame_rgba(1920, 1080, 60, 30).expect("probe frame"); + assert_eq!((width, height), (1920, 1080)); + + let top_left = rgba_pixel(&rgba, width, 24, 24); + let top_right = rgba_pixel(&rgba, width, width - 25, 24); + let bottom_left = rgba_pixel(&rgba, width, 24, height - 25); + let bottom_right = rgba_pixel(&rgba, width, width - 25, height - 25); + + assert!( + top_left.0 > 180 && top_left.1 < 80 && top_left.2 < 80, + "top-left marker drifted: {top_left:?}" + ); + assert!( + top_right.1 > 180 && top_right.0 < 80 && top_right.2 < 80, + "top-right marker drifted: {top_right:?}" + ); + assert!( + bottom_left.2 > 180 && bottom_left.0 < 80 && bottom_left.1 < 80, + "bottom-left marker drifted: {bottom_left:?}" + ); + assert!( + bottom_right.0 > 180 && bottom_right.1 > 180 && bottom_right.2 < 120, + "bottom-right marker drifted: {bottom_right:?}" + ); +} diff --git a/server/src/uvc_control/model.rs b/server/src/uvc_control/model.rs index 29fd522..7382bc5 100644 --- a/server/src/uvc_control/model.rs +++ b/server/src/uvc_control/model.rs @@ -456,55 +456,5 @@ pub(crate) fn read_debugfs_fifos() -> Option<(Option, Option)> { } #[cfg(test)] -mod tests { - use super::{ - STREAM_CTRL_SIZE_11, STREAM_CTRL_SIZE_15, UvcConfig, adjust_length, - build_streaming_control, compute_payload_cap, read_le32, stream_ctrl_len, - }; - use serial_test::serial; - use temp_env::with_var; - - #[test] - #[serial] - fn stream_ctrl_len_falls_back_on_invalid_env() { - with_var("LESAVKA_UVC_CTRL_LEN", Some("bogus"), || { - assert_eq!(stream_ctrl_len(), STREAM_CTRL_SIZE_11); - }); - with_var("LESAVKA_UVC_CTRL_LEN", Some("34"), || { - assert_eq!(stream_ctrl_len(), STREAM_CTRL_SIZE_15); - }); - } - - #[test] - fn adjust_length_pads_and_truncates() { - assert_eq!(adjust_length(vec![1, 2, 3], 2), vec![1, 2]); - assert_eq!(adjust_length(vec![1, 2], 4), vec![1, 2, 0, 0]); - } - - #[test] - fn build_streaming_control_sets_interval_frame_size_and_payload() { - let cfg = UvcConfig { - width: 1280, - height: 720, - fps: 25, - interval: 400_000, - max_packet: 512, - frame_size: 1234, - }; - let control = build_streaming_control(&cfg, STREAM_CTRL_SIZE_15); - assert_eq!(control[2], 1); - assert_eq!(read_le32(&control, 4), 400_000); - assert_eq!(read_le32(&control, 18), 1234); - assert_eq!(read_le32(&control, 22), 512); - } - - #[test] - #[serial] - fn compute_payload_cap_honors_explicit_override() { - with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", Some("333"), || { - let cap = compute_payload_cap(false).expect("env override should win"); - assert_eq!(cap.limit, 333); - assert_eq!(cap.source, "env"); - }); - } -} +#[path = "tests/model.rs"] +mod tests; diff --git a/server/src/uvc_control/tests/model.rs b/server/src/uvc_control/tests/model.rs new file mode 100644 index 0000000..3b4b603 --- /dev/null +++ b/server/src/uvc_control/tests/model.rs @@ -0,0 +1,50 @@ +use super::{ + STREAM_CTRL_SIZE_11, STREAM_CTRL_SIZE_15, UvcConfig, adjust_length, + build_streaming_control, compute_payload_cap, read_le32, stream_ctrl_len, +}; +use serial_test::serial; +use temp_env::with_var; + +#[test] +#[serial] +fn stream_ctrl_len_falls_back_on_invalid_env() { + with_var("LESAVKA_UVC_CTRL_LEN", Some("bogus"), || { + assert_eq!(stream_ctrl_len(), STREAM_CTRL_SIZE_11); + }); + with_var("LESAVKA_UVC_CTRL_LEN", Some("34"), || { + assert_eq!(stream_ctrl_len(), STREAM_CTRL_SIZE_15); + }); +} + +#[test] +fn adjust_length_pads_and_truncates() { + assert_eq!(adjust_length(vec![1, 2, 3], 2), vec![1, 2]); + assert_eq!(adjust_length(vec![1, 2], 4), vec![1, 2, 0, 0]); +} + +#[test] +fn build_streaming_control_sets_interval_frame_size_and_payload() { + let cfg = UvcConfig { + width: 1280, + height: 720, + fps: 25, + interval: 400_000, + max_packet: 512, + frame_size: 1234, + }; + let control = build_streaming_control(&cfg, STREAM_CTRL_SIZE_15); + assert_eq!(control[2], 1); + assert_eq!(read_le32(&control, 4), 400_000); + assert_eq!(read_le32(&control, 18), 1234); + assert_eq!(read_le32(&control, 22), 512); +} + +#[test] +#[serial] +fn compute_payload_cap_honors_explicit_override() { + with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", Some("333"), || { + let cap = compute_payload_cap(false).expect("env override should win"); + assert_eq!(cap.limit, 333); + assert_eq!(cap.source, "env"); + }); +} diff --git a/server/src/video.rs b/server/src/video.rs index 3d59e60..57ac9d8 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -1,844 +1,7 @@ -// server/src/video.rs - -use anyhow::Context; -use futures_util::Stream; -use gst::MessageView::*; -use gst::prelude::*; -use gstreamer as gst; -use gstreamer_app as gst_app; -use lesavka_common::eye_source::{default_eye_source_mode, eye_source_mode_for_request}; -use lesavka_common::lesavka::VideoPacket; -use lesavka_common::process_metrics::ProcessCpuSampler; -use std::os::unix::fs::FileTypeExt; -use std::sync::Arc; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use tokio::time::{Duration, Instant, sleep}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::Status; -use tracing::{Level, debug, enabled, error, info, trace, warn}; - -pub use crate::video_sinks::{CameraRelay, HdmiSink, WebcamSink}; -use crate::video_support::{ - adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, should_send_frame, -}; - -const EYE_ID: [&str; 2] = ["l", "r"]; -static START: OnceLock = OnceLock::new(); -static SERVER_PROCESS_CPU_TENTHS: OnceLock> = OnceLock::new(); - -fn server_process_cpu_metric() -> Arc { - Arc::clone(SERVER_PROCESS_CPU_TENTHS.get_or_init(|| { - let metric = Arc::new(AtomicU32::new(0)); - let metric_for_thread = Arc::clone(&metric); - std::thread::spawn(move || { - let mut sampler = ProcessCpuSampler::new(); - loop { - if let Some(value) = sampler.sample_tenths_percent() { - metric_for_thread.store(value, Ordering::Relaxed); - } - std::thread::sleep(std::time::Duration::from_secs(1)); - } - }); - metric - })) -} - -pub struct VideoStream { - _pipeline: gst::Pipeline, - #[cfg(not(coverage))] - _bus_watch: Option, - inner: ReceiverStream>, -} - -impl Stream for VideoStream { - type Item = Result; - - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - Stream::poll_next(std::pin::Pin::new(&mut self.inner), cx) - } -} - -impl Drop for VideoStream { - fn drop(&mut self) { - let _ = self._pipeline.set_state(gst::State::Null); - #[cfg(not(coverage))] - { - let _ = self._bus_watch.take(); - } - } -} - -#[cfg(not(coverage))] -struct BusWatchHandle { - alive: Arc, - join: Option>, -} - -#[cfg(not(coverage))] -impl BusWatchHandle { - fn spawn(bus: gst::Bus, eye: String) -> Self { - let alive = Arc::new(AtomicBool::new(true)); - let alive_flag = Arc::clone(&alive); - let join = std::thread::spawn(move || { - while alive_flag.load(Ordering::Relaxed) { - let Some(msg) = bus.timed_pop(gst::ClockTime::from_mseconds(250)) else { - continue; - }; - match msg.view() { - Error(err) => { - error!( - target:"lesavka_server::video", - eye = %eye, - "💥 pipeline error: {} ({})", - err.error(), - err.debug().unwrap_or_default() - ); - break; - } - Warning(warning) => { - warn!( - target:"lesavka_server::video", - eye = %eye, - "⚠️ pipeline warning: {} ({})", - warning.error(), - warning.debug().unwrap_or_default() - ); - } - Info(info_msg) => { - info!( - target:"lesavka_server::video", - eye = %eye, - "📌 pipeline info: {} ({})", - info_msg.error(), - info_msg.debug().unwrap_or_default() - ); - } - StateChanged(state) if state.current() == gst::State::Playing => { - debug!(target:"lesavka_server::video", eye = %eye, "🎬 pipeline PLAYING"); - } - StateChanged(state) if state.current() == gst::State::Null => { - debug!(target:"lesavka_server::video", eye = %eye, "🛑 pipeline stopped"); - break; - } - Eos(..) => { - debug!(target:"lesavka_server::video", eye = %eye, "🏁 pipeline EOS"); - break; - } - _ => {} - } - } - }); - Self { - alive, - join: Some(join), - } - } -} - -#[cfg(not(coverage))] -impl Drop for BusWatchHandle { - fn drop(&mut self) { - self.alive.store(false, Ordering::Relaxed); - if let Some(join) = self.join.take() { - let _ = join.join(); - } - } -} - -#[cfg(not(coverage))] -fn start_eye_pipeline(pipeline: &gst::Pipeline, bus: &gst::Bus, eye: &str) -> anyhow::Result<()> { - pipeline - .set_state(gst::State::Playing) - .context(format!("🎥 starting video pipeline eye-{eye}"))?; - for _ in 0..20 { - match bus.timed_pop(gst::ClockTime::from_mseconds(200)) { - Some(msg) => match msg.view() { - Error(err) => { - let _ = pipeline.set_state(gst::State::Null); - return Err(anyhow::anyhow!( - "🎥 eye-{eye} pipeline error: {} ({})", - err.error(), - err.debug().unwrap_or_default() - )); - } - StateChanged(state) if state.current() == gst::State::Playing => return Ok(()), - _ => continue, - }, - None => continue, - } - } - Ok(()) -} - -fn eye_device_wait_timeout() -> Duration { - Duration::from_millis( - std::env::var("LESAVKA_EYE_DEVICE_WAIT_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(5_000), - ) -} - -fn eye_device_wait_poll() -> Duration { - Duration::from_millis( - std::env::var("LESAVKA_EYE_DEVICE_POLL_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(|value| value.max(25)) - .unwrap_or(100), - ) -} - -pub fn eye_source_profile() -> (u32, u32, u32) { - let mode = default_eye_source_mode(); - (mode.width, mode.height, mode.fps) -} - -fn round_down_even_u32(value: u32) -> u32 { - let rounded = value.max(2); - rounded - (rounded % 2) -} - -fn reset_stream_telemetry_window( - last_window_sec: &AtomicU64, - current_sec: u64, - source_gap_peak_ms: &AtomicU32, - send_gap_peak_ms: &AtomicU32, - queue_peak_depth: &AtomicU32, -) { - let prev = last_window_sec.load(Ordering::Relaxed); - if current_sec <= prev { - return; - } - if last_window_sec - .compare_exchange(prev, current_sec, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - source_gap_peak_ms.store(0, Ordering::Relaxed); - send_gap_peak_ms.store(0, Ordering::Relaxed); - queue_peak_depth.store(0, Ordering::Relaxed); - } -} - -#[derive(Clone, Copy, Debug)] -struct EyeCaptureRequest { - width: u32, - height: u32, - fps: u32, - max_bitrate_kbit: u32, -} - -fn normalize_eye_capture_request( - requested_width: u32, - requested_height: u32, - _requested_fps: u32, - max_bitrate_kbit: u32, -) -> EyeCaptureRequest { - let source_mode = eye_source_mode_for_request(requested_width, requested_height); - EyeCaptureRequest { - width: round_down_even_u32(source_mode.width.max(320)), - height: round_down_even_u32(source_mode.height.max(180)), - fps: source_mode.fps.max(1), - max_bitrate_kbit, - } -} - -#[cfg(not(coverage))] -async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> { - let timeout = eye_device_wait_timeout(); - let poll = eye_device_wait_poll(); - let deadline = Instant::now() + timeout; - let last_detail = loop { - let detail = match tokio::fs::metadata(dev).await { - Ok(metadata) if metadata.file_type().is_char_device() => return Ok(()), - Ok(metadata) => format!("device exists but is not a character device ({metadata:?})"), - Err(err) => err.to_string(), - }; - - if Instant::now() >= deadline { - break detail; - } - - sleep(poll).await; - }; - - Err(anyhow::anyhow!( - "🎥 eye-{eye} device {dev} was not ready within {} ms: {}", - timeout.as_millis(), - last_detail - )) -} - -#[cfg(coverage)] -async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> { - let timeout = eye_device_wait_timeout(); - let poll = eye_device_wait_poll(); - let deadline = Instant::now() + timeout; - let last_detail = loop { - let detail = match tokio::fs::metadata(dev).await { - Ok(metadata) if metadata.file_type().is_char_device() => return Ok(()), - Ok(metadata) => format!("device exists but is not a character device ({metadata:?})"), - Err(err) => err.to_string(), - }; - - if Instant::now() >= deadline { - break detail; - } - - sleep(poll).await; - }; - - Err(anyhow::anyhow!( - "🎥 eye-{eye} device {dev} was not ready within {} ms: {}", - timeout.as_millis(), - last_detail - )) -} - -/// Capture one eye stream from the local V4L2 gadget and expose it as a gRPC stream. -/// -/// Inputs: the V4L2 device node, logical eye id, and negotiated bitrate cap. -/// Outputs: a `VideoStream` that yields H.264 access units for the requested eye. -/// Why: the server keeps bitrate-aware pacing close to the capture pipeline so it can drop -/// frames before they build up in gRPC queues and destabilize downstream playback. -#[cfg(coverage)] -pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result { - eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await -} - -#[cfg(coverage)] -pub async fn eye_ball_with_request( - dev: &str, - id: u32, - _max_bitrate_kbit: u32, - _requested_width: u32, - _requested_height: u32, - _requested_fps: u32, -) -> anyhow::Result { - let _ = EYE_ID[id as usize]; - if dev.contains('"') { - return Err(anyhow::anyhow!("invalid video source")); - } - - let coverage_override = std::env::var("LESAVKA_TEST_VIDEO_SOURCE").ok(); - let use_test_src = dev.eq_ignore_ascii_case("testsrc") - || dev.eq_ignore_ascii_case("videotestsrc") - || coverage_override.as_deref() == Some(dev); - if !use_test_src { - return Err(anyhow::anyhow!("video source unavailable")); - } - - let _ = gst::init(); - let (tx, rx) = tokio::sync::mpsc::channel(64); - - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - for seq in 0..8 { - let _ = tx - .send(Ok(VideoPacket { - id: id.min(1), - pts: seq * 16_666, - data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84], - seq: seq + 1, - effective_fps: 60, - server_encoder_label: "coverage-testsrc".to_string(), - ..Default::default() - })) - .await; - } - }); - - Ok(VideoStream { - _pipeline: gst::Pipeline::new(), - #[cfg(not(coverage))] - _bus_watch: None, - inner: ReceiverStream::new(rx), - }) -} - -#[cfg(not(coverage))] -pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result { - eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await -} - -#[cfg(not(coverage))] -pub async fn eye_ball_with_request( - dev: &str, - id: u32, - max_bitrate_kbit: u32, - requested_width: u32, - requested_height: u32, - requested_fps: u32, -) -> anyhow::Result { - let eye = EYE_ID[id as usize]; - gst::init().context("gst init")?; - - let request = normalize_eye_capture_request( - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - ); - let target_fps = if requested_fps > 0 { - request.fps - } else { - env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1) - }; - let min_fps = env_u32("LESAVKA_EYE_MIN_FPS", 12).clamp(1, target_fps); - let adaptive = std::env::var("LESAVKA_EYE_ADAPTIVE") - .map(|value| value != "0") - .unwrap_or(true); - info!( - target: "lesavka_server::video", - eye = %eye, - max_bitrate_kbit, - source_width = request.width, - source_height = request.height, - source_fps = request.fps, - requested_width = request.width, - requested_height = request.height, - requested_fps = request.fps, - target_fps, - min_fps, - adaptive, - "🎥 eye stream profile selected" - ); - - let effective_fps = Arc::new(AtomicU32::new(target_fps)); - let dropped_window = Arc::new(AtomicU64::new(0)); - let dropped_total = Arc::new(AtomicU64::new(0)); - let sent_window = Arc::new(AtomicU64::new(0)); - let last_adjust_sec = Arc::new(AtomicU64::new(0)); - let wait_for_idr = Arc::new(AtomicBool::new(false)); - let last_sent = Arc::new(AtomicU64::new(0)); - let last_source_pts = Arc::new(AtomicU64::new(0)); - let source_gap_peak_ms = Arc::new(AtomicU32::new(0)); - let send_gap_peak_ms = Arc::new(AtomicU32::new(0)); - let queue_peak_depth = Arc::new(AtomicU32::new(0)); - let last_telemetry_sec = Arc::new(AtomicU64::new(0)); - let packet_seq = Arc::new(AtomicU64::new(0)); - - let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1); - let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1); - let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.max(1).min(5)) - .clamp(1, request.fps.max(1)); - let use_test_src = - dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); - let server_encoder_label = if use_test_src { - "x264enc(testsrc)".to_string() - } else { - "source-pass-through".to_string() - }; - let server_process_cpu_tenths = server_process_cpu_metric(); - if !use_test_src { - wait_for_eye_device(dev, eye).await?; - } - let desc = if use_test_src { - let test_bitrate = env_u32("LESAVKA_EYE_TESTSRC_KBIT", request.max_bitrate_kbit); - format!( - "videotestsrc name=cam_{eye} is-live=true pattern=smpte ! \ - video/x-raw,width={},height={},framerate={}/1 ! \ - queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - videoconvert ! video/x-raw,format=I420,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \ - x264enc tune=zerolatency speed-preset=veryfast bitrate={test_bitrate} key-int-max={keyframe_interval} option-string=sar=1/1 ! \ - h264parse disable-passthrough=true config-interval=-1 ! \ - video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", - request.width, request.height, request.fps, request.width, request.height, request.fps, - ) - } else { - format!( - "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ - video/x-h264,width={},height={} ! \ - queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - h264parse disable-passthrough=true config-interval=-1 ! \ - video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", - request.width, request.height, - ) - }; - - let pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("not a pipeline"); - - let sink = pipeline - .by_name("sink") - .expect("appsink") - .dynamic_cast::() - .expect("appsink down-cast"); - - let chan_capacity = env_usize("LESAVKA_EYE_CHAN_CAPACITY", 32).max(8); - let (tx, rx) = tokio::sync::mpsc::channel(chan_capacity); - - if let Some(src_pad) = pipeline - .by_name(&format!("cam_{eye}")) - .and_then(|element| element.static_pad("src")) - { - src_pad.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, |pad, info| { - if let Some(gst::PadProbeData::Event(ref event)) = info.data { - if let gst::EventView::Caps(caps) = event.view() { - trace!(target:"lesavka_server::video", ?caps, "🔍 new caps on {}", pad.name()); - } - } - gst::PadProbeReturn::Ok - }); - } else { - warn!(target:"lesavka_server::video", eye = %eye, "🍪 cam_{eye} not found - skipping pad-probe"); - } - - let eye_name = eye.to_string(); - let dropped_total_for_cb = Arc::clone(&dropped_total); - let packet_seq_for_cb = Arc::clone(&packet_seq); - let effective_fps_for_cb = Arc::clone(&effective_fps); - let last_source_pts_for_cb = Arc::clone(&last_source_pts); - let source_gap_peak_ms_for_cb = Arc::clone(&source_gap_peak_ms); - let send_gap_peak_ms_for_cb = Arc::clone(&send_gap_peak_ms); - let queue_peak_depth_for_cb = Arc::clone(&queue_peak_depth); - let last_telemetry_sec_for_cb = Arc::clone(&last_telemetry_sec); - let server_encoder_label_for_cb = server_encoder_label.clone(); - let server_process_cpu_tenths_for_cb = Arc::clone(&server_process_cpu_tenths); - sink.set_callbacks( - gst_app::AppSinkCallbacks::builder() - .new_sample(move |sink| { - let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?; - let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; - let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; - let is_idr = contains_idr(map.as_slice()); - - static FRAME: AtomicU64 = AtomicU64::new(0); - let frame = FRAME.fetch_add(1, Ordering::Relaxed); - if frame % 120 == 0 && is_idr { - trace!(target: "lesavka_server::video", "eye-{eye}: delivered {frame} frames"); - if enabled!(Level::TRACE) { - let path = format!("/tmp/eye-{eye}-srv-{frame:05}.h264"); - std::fs::write(&path, map.as_slice()).ok(); - } - } else if frame < 10 { - debug!( - target: "lesavka_server::video", - eye = eye, - frame, - bytes = map.len(), - pts = ?buffer.pts(), - "⬆️ pushed video sample eye-{eye}" - ); - } - - if enabled!(Level::TRACE) && is_idr { - trace!("eye-{eye}: IDR"); - } - - let origin = *START.get_or_init(|| buffer.pts().unwrap_or(gst::ClockTime::ZERO)); - let pts_us = buffer - .pts() - .unwrap_or(gst::ClockTime::ZERO) - .saturating_sub(origin) - .nseconds() - / 1_000; - let sec = pts_us / 1_000_000; - reset_stream_telemetry_window( - &last_telemetry_sec_for_cb, - sec, - &source_gap_peak_ms_for_cb, - &send_gap_peak_ms_for_cb, - &queue_peak_depth_for_cb, - ); - let previous_source_pts = last_source_pts_for_cb.swap(pts_us, Ordering::Relaxed); - if previous_source_pts > 0 && pts_us > previous_source_pts { - let source_gap_ms = - ((pts_us.saturating_sub(previous_source_pts)) / 1_000) as u32; - source_gap_peak_ms_for_cb.fetch_max(source_gap_ms, Ordering::Relaxed); - } - - if adaptive { - let prev = last_adjust_sec.load(Ordering::Relaxed); - if sec > prev - && last_adjust_sec - .compare_exchange(prev, sec, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - let dropped = dropped_window.swap(0, Ordering::Relaxed); - let sent = sent_window.swap(0, Ordering::Relaxed); - let current = effective_fps.load(Ordering::Relaxed).max(1); - let next = adjust_effective_fps(current, min_fps, target_fps, dropped, sent); - if next != current { - effective_fps.store(next, Ordering::Relaxed); - if next < current { - warn!( - target: "lesavka_server::video", - eye = %eye_name, - fps = next, - "🎥 adaptive eye fps ↓" - ); - } else { - info!( - target: "lesavka_server::video", - eye = %eye_name, - fps = next, - "🎥 adaptive eye fps ↑" - ); - } - } - } - } - - let current_fps = effective_fps.load(Ordering::Relaxed).max(1); - let last = last_sent.load(Ordering::Relaxed); - if !should_send_frame(last, pts_us, current_fps) { - return Ok(gst::FlowSuccess::Ok); - } - if last > 0 && pts_us > last { - let send_gap_ms = ((pts_us.saturating_sub(last)) / 1_000) as u32; - send_gap_peak_ms_for_cb.fetch_max(send_gap_ms, Ordering::Relaxed); - } - last_sent.store(pts_us, Ordering::Relaxed); - - if wait_for_idr.load(Ordering::Relaxed) && !is_idr { - return Ok(gst::FlowSuccess::Ok); - } - - let data = map.as_slice().to_vec(); - let size = data.len(); - let seq = packet_seq_for_cb.fetch_add(1, Ordering::Relaxed) + 1; - let queue_depth = (chan_capacity.saturating_sub(tx.capacity())) as u32; - queue_peak_depth_for_cb.fetch_max(queue_depth, Ordering::Relaxed); - let pkt = VideoPacket { - id, - pts: pts_us, - data, - seq, - effective_fps: effective_fps_for_cb.load(Ordering::Relaxed).max(1), - dropped_total: dropped_total_for_cb.load(Ordering::Relaxed), - queue_depth, - server_source_gap_peak_ms: source_gap_peak_ms_for_cb.load(Ordering::Relaxed), - server_send_gap_peak_ms: send_gap_peak_ms_for_cb.load(Ordering::Relaxed), - server_queue_peak: queue_peak_depth_for_cb.load(Ordering::Relaxed), - server_encoder_label: server_encoder_label_for_cb.clone(), - server_process_cpu_tenths: server_process_cpu_tenths_for_cb - .load(Ordering::Relaxed), - }; - match tx.try_send(Ok(pkt)) { - Ok(_) => { - sent_window.fetch_add(1, Ordering::Relaxed); - if is_idr { - wait_for_idr.store(false, Ordering::Relaxed); - } - trace!(target:"lesavka_server::video", eye = %eye, size = size, "🎥📤 sent"); - } - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - dropped_window.fetch_add(1, Ordering::Relaxed); - dropped_total_for_cb.fetch_add(1, Ordering::Relaxed); - wait_for_idr.store(true, Ordering::Relaxed); - static DROP_CNT: AtomicU64 = AtomicU64::new(0); - let dropped = DROP_CNT.fetch_add(1, Ordering::Relaxed); - if dropped % 120 == 0 { - debug!( - target:"lesavka_server::video", - eye = %eye, - dropped, - "🎥⏳ channel full - dropping frames" - ); - } - } - Err(error) => error!("mpsc send err: {error}"), - } - - Ok(gst::FlowSuccess::Ok) - }) - .build(), - ); - - let bus = pipeline.bus().expect("bus"); - start_eye_pipeline(&pipeline, &bus, eye)?; - let bus_watch = BusWatchHandle::spawn(bus, eye.to_owned()); - - Ok(VideoStream { - _pipeline: pipeline, - _bus_watch: Some(bus_watch), - inner: ReceiverStream::new(rx), - }) -} +// Server-side eye capture streams and telemetry for remote preview feeds. +include!("video/stream_core.rs"); +include!("video/eye_capture.rs"); #[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - - #[test] - fn source_profile_stays_pass_through_without_explicit_reencode_request() { - let request = normalize_eye_capture_request(1920, 1080, 30, 12_000); - - assert_eq!(request.width, 1920); - assert_eq!(request.height, 1080); - assert_eq!(request.fps, 60); - } - - #[test] - fn source_mode_selection_prefers_native_modes_without_reencode() { - let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500); - let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000); - let smaller_mode_request = normalize_eye_capture_request(1600, 900, 30, 12_000); - - assert_eq!(bitrate_request.fps, 60); - assert_eq!(fps_request.fps, 60); - assert_eq!(smaller_mode_request.width, 1280); - assert_eq!(smaller_mode_request.height, 720); - assert_eq!(smaller_mode_request.fps, 60); - } - - fn marker_frame(width: i32, height: i32) -> Vec { - let mut rgba = vec![0_u8; (width * height * 4) as usize]; - let marker = 96; - for y in 0..height { - for x in 0..width { - let idx = ((y * width + x) * 4) as usize; - let (r, g, b) = if x < marker && y < marker { - (255, 0, 0) - } else if x >= width - marker && y < marker { - (0, 255, 0) - } else if x < marker && y >= height - marker { - (0, 0, 255) - } else if x >= width - marker && y >= height - marker { - (255, 255, 0) - } else { - (24, 24, 24) - }; - rgba[idx..idx + 4].copy_from_slice(&[r, g, b, 255]); - } - } - rgba - } - - fn pull_reencoded_frame_rgba( - width: i32, - height: i32, - input_fps: u32, - output_fps: u32, - ) -> anyhow::Result<(i32, i32, Vec)> { - gst::init().context("gst init")?; - let desc = format!( - "appsrc name=src is-live=false format=time block=true ! \ - videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={input_fps}/1,pixel-aspect-ratio=1/1 ! \ - x264enc tune=zerolatency speed-preset=veryfast bitrate=12000 key-int-max={input_fps} option-string=sar=1/1 ! \ - h264parse disable-passthrough=true config-interval=-1 ! \ - avdec_h264 ! videoconvert ! videoscale add-borders=false ! videorate ! \ - video/x-raw,format=I420,width={width},height={height},framerate={output_fps}/1,pixel-aspect-ratio=1/1 ! \ - x264enc tune=zerolatency speed-preset=faster bitrate=12000 key-int-max=5 option-string=sar=1/1 ! \ - h264parse disable-passthrough=true config-interval=-1 ! \ - avdec_h264 ! videoconvert ! video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ - appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" - ); - let pipeline = gst::parse::launch(&desc)? - .downcast::() - .expect("pipeline"); - let appsrc = pipeline - .by_name("src") - .expect("appsrc") - .downcast::() - .expect("appsrc cast"); - appsrc.set_caps(Some( - &gst::Caps::builder("video/x-raw") - .field("format", &"RGBA") - .field("width", &width) - .field("height", &height) - .field("framerate", &gst::Fraction::new(input_fps as i32, 1)) - .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) - .build(), - )); - appsrc.set_format(gst::Format::Time); - let appsink = pipeline - .by_name("sink") - .expect("appsink") - .downcast::() - .expect("appsink cast"); - appsink.set_caps(Some( - &gst::Caps::builder("video/x-raw") - .field("format", &"RGBA") - .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) - .build(), - )); - - pipeline - .set_state(gst::State::Playing) - .context("starting reencode probe pipeline")?; - - let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height)); - if let Some(buf) = buffer.get_mut() { - buf.set_pts(Some(gst::ClockTime::ZERO)); - buf.set_duration(Some(gst::ClockTime::from_nseconds( - 1_000_000_000_u64 / input_fps.max(1) as u64, - ))); - } - appsrc - .push_buffer(buffer) - .map_err(|err| anyhow::anyhow!("push buffer failed: {err:?}"))?; - appsrc - .end_of_stream() - .map_err(|err| anyhow::anyhow!("eos failed: {err:?}"))?; - - let sample = appsink - .try_pull_sample(gst::ClockTime::from_seconds(5)) - .ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?; - let caps = sample - .caps() - .ok_or_else(|| anyhow::anyhow!("missing sample caps"))?; - let structure = caps - .structure(0) - .ok_or_else(|| anyhow::anyhow!("missing caps structure"))?; - let out_width = structure - .get::("width") - .map_err(|err| anyhow::anyhow!("missing output width: {err}"))?; - let out_height = structure - .get::("height") - .map_err(|err| anyhow::anyhow!("missing output height: {err}"))?; - let buffer = sample - .buffer() - .ok_or_else(|| anyhow::anyhow!("missing sample buffer"))?; - let map = buffer - .map_readable() - .map_err(|_| anyhow::anyhow!("sample map failed"))?; - let rgba = map.as_slice().to_vec(); - let _ = pipeline.set_state(gst::State::Null); - Ok((out_width, out_height, rgba)) - } - - fn rgba_pixel(rgba: &[u8], width: i32, x: i32, y: i32) -> (u8, u8, u8) { - let idx = ((y * width + x) * 4) as usize; - (rgba[idx], rgba[idx + 1], rgba[idx + 2]) - } - - #[test] - #[serial] - fn reencode_probe_preserves_corner_markers_on_full_frame_content() { - let (width, height, rgba) = - pull_reencoded_frame_rgba(1920, 1080, 60, 30).expect("probe frame"); - assert_eq!((width, height), (1920, 1080)); - - let top_left = rgba_pixel(&rgba, width, 24, 24); - let top_right = rgba_pixel(&rgba, width, width - 25, 24); - let bottom_left = rgba_pixel(&rgba, width, 24, height - 25); - let bottom_right = rgba_pixel(&rgba, width, width - 25, height - 25); - - assert!( - top_left.0 > 180 && top_left.1 < 80 && top_left.2 < 80, - "top-left marker drifted: {top_left:?}" - ); - assert!( - top_right.1 > 180 && top_right.0 < 80 && top_right.2 < 80, - "top-right marker drifted: {top_right:?}" - ); - assert!( - bottom_left.2 > 180 && bottom_left.0 < 80 && bottom_left.1 < 80, - "bottom-left marker drifted: {bottom_left:?}" - ); - assert!( - bottom_right.0 > 180 && bottom_right.1 > 180 && bottom_right.2 < 120, - "bottom-right marker drifted: {bottom_right:?}" - ); - } -} +#[path = "tests/video.rs"] +mod tests; diff --git a/server/src/video/eye_capture.rs b/server/src/video/eye_capture.rs new file mode 100644 index 0000000..c98c254 --- /dev/null +++ b/server/src/video/eye_capture.rs @@ -0,0 +1,415 @@ +#[cfg(not(coverage))] +async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> { + let timeout = eye_device_wait_timeout(); + let poll = eye_device_wait_poll(); + let deadline = Instant::now() + timeout; + let last_detail = loop { + let detail = match tokio::fs::metadata(dev).await { + Ok(metadata) if metadata.file_type().is_char_device() => return Ok(()), + Ok(metadata) => format!("device exists but is not a character device ({metadata:?})"), + Err(err) => err.to_string(), + }; + + if Instant::now() >= deadline { + break detail; + } + + sleep(poll).await; + }; + + Err(anyhow::anyhow!( + "🎥 eye-{eye} device {dev} was not ready within {} ms: {}", + timeout.as_millis(), + last_detail + )) +} + +#[cfg(coverage)] +async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> { + let timeout = eye_device_wait_timeout(); + let poll = eye_device_wait_poll(); + let deadline = Instant::now() + timeout; + let last_detail = loop { + let detail = match tokio::fs::metadata(dev).await { + Ok(metadata) if metadata.file_type().is_char_device() => return Ok(()), + Ok(metadata) => format!("device exists but is not a character device ({metadata:?})"), + Err(err) => err.to_string(), + }; + + if Instant::now() >= deadline { + break detail; + } + + sleep(poll).await; + }; + + Err(anyhow::anyhow!( + "🎥 eye-{eye} device {dev} was not ready within {} ms: {}", + timeout.as_millis(), + last_detail + )) +} + +/// Capture one eye stream from the local V4L2 gadget and expose it as a gRPC stream. +/// +/// Inputs: the V4L2 device node, logical eye id, and negotiated bitrate cap. +/// Outputs: a `VideoStream` that yields H.264 access units for the requested eye. +/// Why: the server keeps bitrate-aware pacing close to the capture pipeline so it can drop +/// frames before they build up in gRPC queues and destabilize downstream playback. +#[cfg(coverage)] +pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result { + eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await +} + +#[cfg(coverage)] +pub async fn eye_ball_with_request( + dev: &str, + id: u32, + _max_bitrate_kbit: u32, + _requested_width: u32, + _requested_height: u32, + _requested_fps: u32, +) -> anyhow::Result { + let _ = EYE_ID[id as usize]; + if dev.contains('"') { + return Err(anyhow::anyhow!("invalid video source")); + } + + let coverage_override = std::env::var("LESAVKA_TEST_VIDEO_SOURCE").ok(); + let use_test_src = dev.eq_ignore_ascii_case("testsrc") + || dev.eq_ignore_ascii_case("videotestsrc") + || coverage_override.as_deref() == Some(dev); + if !use_test_src { + return Err(anyhow::anyhow!("video source unavailable")); + } + + let _ = gst::init(); + let (tx, rx) = tokio::sync::mpsc::channel(64); + + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + for seq in 0..8 { + let _ = tx + .send(Ok(VideoPacket { + id: id.min(1), + pts: seq * 16_666, + data: vec![0, 0, 0, 1, 0x65, 0x88, 0x84], + seq: seq + 1, + effective_fps: 60, + server_encoder_label: "coverage-testsrc".to_string(), + ..Default::default() + })) + .await; + } + }); + + Ok(VideoStream { + _pipeline: gst::Pipeline::new(), + #[cfg(not(coverage))] + _bus_watch: None, + inner: ReceiverStream::new(rx), + }) +} + +#[cfg(not(coverage))] +pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result { + eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await +} + +#[cfg(not(coverage))] +pub async fn eye_ball_with_request( + dev: &str, + id: u32, + max_bitrate_kbit: u32, + requested_width: u32, + requested_height: u32, + requested_fps: u32, +) -> anyhow::Result { + let eye = EYE_ID[id as usize]; + gst::init().context("gst init")?; + + let request = normalize_eye_capture_request( + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + ); + let target_fps = if requested_fps > 0 { + request.fps + } else { + env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1) + }; + let min_fps = env_u32("LESAVKA_EYE_MIN_FPS", 12).clamp(1, target_fps); + let adaptive = std::env::var("LESAVKA_EYE_ADAPTIVE") + .map(|value| value != "0") + .unwrap_or(true); + info!( + target: "lesavka_server::video", + eye = %eye, + max_bitrate_kbit, + source_width = request.width, + source_height = request.height, + source_fps = request.fps, + requested_width = request.width, + requested_height = request.height, + requested_fps = request.fps, + target_fps, + min_fps, + adaptive, + "🎥 eye stream profile selected" + ); + + let effective_fps = Arc::new(AtomicU32::new(target_fps)); + let dropped_window = Arc::new(AtomicU64::new(0)); + let dropped_total = Arc::new(AtomicU64::new(0)); + let sent_window = Arc::new(AtomicU64::new(0)); + let last_adjust_sec = Arc::new(AtomicU64::new(0)); + let wait_for_idr = Arc::new(AtomicBool::new(false)); + let last_sent = Arc::new(AtomicU64::new(0)); + let last_source_pts = Arc::new(AtomicU64::new(0)); + let source_gap_peak_ms = Arc::new(AtomicU32::new(0)); + let send_gap_peak_ms = Arc::new(AtomicU32::new(0)); + let queue_peak_depth = Arc::new(AtomicU32::new(0)); + let last_telemetry_sec = Arc::new(AtomicU64::new(0)); + let packet_seq = Arc::new(AtomicU64::new(0)); + + let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1); + let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1); + let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.clamp(1, 5)) + .clamp(1, request.fps.max(1)); + let use_test_src = + dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); + let server_encoder_label = if use_test_src { + "x264enc(testsrc)".to_string() + } else { + "source-pass-through".to_string() + }; + let server_process_cpu_tenths = server_process_cpu_metric(); + if !use_test_src { + wait_for_eye_device(dev, eye).await?; + } + let desc = if use_test_src { + let test_bitrate = env_u32("LESAVKA_EYE_TESTSRC_KBIT", request.max_bitrate_kbit); + format!( + "videotestsrc name=cam_{eye} is-live=true pattern=smpte ! \ + video/x-raw,width={},height={},framerate={}/1 ! \ + queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + videoconvert ! video/x-raw,format=I420,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \ + x264enc tune=zerolatency speed-preset=veryfast bitrate={test_bitrate} key-int-max={keyframe_interval} option-string=sar=1/1 ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", + request.width, request.height, request.fps, request.width, request.height, request.fps, + ) + } else { + format!( + "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ + video/x-h264,width={},height={} ! \ + queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", + request.width, request.height, + ) + }; + + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("not a pipeline"); + + let sink = pipeline + .by_name("sink") + .expect("appsink") + .dynamic_cast::() + .expect("appsink down-cast"); + + let chan_capacity = env_usize("LESAVKA_EYE_CHAN_CAPACITY", 32).max(8); + let (tx, rx) = tokio::sync::mpsc::channel(chan_capacity); + + if let Some(src_pad) = pipeline + .by_name(&format!("cam_{eye}")) + .and_then(|element| element.static_pad("src")) + { + src_pad.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, |pad, info| { + if let Some(gst::PadProbeData::Event(ref event)) = info.data + && let gst::EventView::Caps(caps) = event.view() { + trace!(target:"lesavka_server::video", ?caps, "🔍 new caps on {}", pad.name()); + } + gst::PadProbeReturn::Ok + }); + } else { + warn!(target:"lesavka_server::video", eye = %eye, "🍪 cam_{eye} not found - skipping pad-probe"); + } + + let eye_name = eye.to_string(); + let dropped_total_for_cb = Arc::clone(&dropped_total); + let packet_seq_for_cb = Arc::clone(&packet_seq); + let effective_fps_for_cb = Arc::clone(&effective_fps); + let last_source_pts_for_cb = Arc::clone(&last_source_pts); + let source_gap_peak_ms_for_cb = Arc::clone(&source_gap_peak_ms); + let send_gap_peak_ms_for_cb = Arc::clone(&send_gap_peak_ms); + let queue_peak_depth_for_cb = Arc::clone(&queue_peak_depth); + let last_telemetry_sec_for_cb = Arc::clone(&last_telemetry_sec); + let server_encoder_label_for_cb = server_encoder_label.clone(); + let server_process_cpu_tenths_for_cb = Arc::clone(&server_process_cpu_tenths); + sink.set_callbacks( + gst_app::AppSinkCallbacks::builder() + .new_sample(move |sink| { + let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?; + let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; + let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; + let is_idr = contains_idr(map.as_slice()); + + static FRAME: AtomicU64 = AtomicU64::new(0); + let frame = FRAME.fetch_add(1, Ordering::Relaxed); + if frame.is_multiple_of(120) && is_idr { + trace!(target: "lesavka_server::video", "eye-{eye}: delivered {frame} frames"); + if enabled!(Level::TRACE) { + let path = format!("/tmp/eye-{eye}-srv-{frame:05}.h264"); + std::fs::write(&path, map.as_slice()).ok(); + } + } else if frame < 10 { + debug!( + target: "lesavka_server::video", + eye = eye, + frame, + bytes = map.len(), + pts = ?buffer.pts(), + "⬆️ pushed video sample eye-{eye}" + ); + } + + if enabled!(Level::TRACE) && is_idr { + trace!("eye-{eye}: IDR"); + } + + let origin = *START.get_or_init(|| buffer.pts().unwrap_or(gst::ClockTime::ZERO)); + let pts_us = buffer + .pts() + .unwrap_or(gst::ClockTime::ZERO) + .saturating_sub(origin) + .nseconds() + / 1_000; + let sec = pts_us / 1_000_000; + reset_stream_telemetry_window( + &last_telemetry_sec_for_cb, + sec, + &source_gap_peak_ms_for_cb, + &send_gap_peak_ms_for_cb, + &queue_peak_depth_for_cb, + ); + let previous_source_pts = last_source_pts_for_cb.swap(pts_us, Ordering::Relaxed); + if previous_source_pts > 0 && pts_us > previous_source_pts { + let source_gap_ms = + ((pts_us.saturating_sub(previous_source_pts)) / 1_000) as u32; + source_gap_peak_ms_for_cb.fetch_max(source_gap_ms, Ordering::Relaxed); + } + + if adaptive { + let prev = last_adjust_sec.load(Ordering::Relaxed); + if sec > prev + && last_adjust_sec + .compare_exchange(prev, sec, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let dropped = dropped_window.swap(0, Ordering::Relaxed); + let sent = sent_window.swap(0, Ordering::Relaxed); + let current = effective_fps.load(Ordering::Relaxed).max(1); + let next = adjust_effective_fps(current, min_fps, target_fps, dropped, sent); + if next != current { + effective_fps.store(next, Ordering::Relaxed); + if next < current { + warn!( + target: "lesavka_server::video", + eye = %eye_name, + fps = next, + "🎥 adaptive eye fps ↓" + ); + } else { + info!( + target: "lesavka_server::video", + eye = %eye_name, + fps = next, + "🎥 adaptive eye fps ↑" + ); + } + } + } + } + + let current_fps = effective_fps.load(Ordering::Relaxed).max(1); + let last = last_sent.load(Ordering::Relaxed); + if !should_send_frame(last, pts_us, current_fps) { + return Ok(gst::FlowSuccess::Ok); + } + if last > 0 && pts_us > last { + let send_gap_ms = ((pts_us.saturating_sub(last)) / 1_000) as u32; + send_gap_peak_ms_for_cb.fetch_max(send_gap_ms, Ordering::Relaxed); + } + last_sent.store(pts_us, Ordering::Relaxed); + + if wait_for_idr.load(Ordering::Relaxed) && !is_idr { + return Ok(gst::FlowSuccess::Ok); + } + + let data = map.as_slice().to_vec(); + let size = data.len(); + let seq = packet_seq_for_cb.fetch_add(1, Ordering::Relaxed) + 1; + let queue_depth = (chan_capacity.saturating_sub(tx.capacity())) as u32; + queue_peak_depth_for_cb.fetch_max(queue_depth, Ordering::Relaxed); + let pkt = VideoPacket { + id, + pts: pts_us, + data, + seq, + effective_fps: effective_fps_for_cb.load(Ordering::Relaxed).max(1), + dropped_total: dropped_total_for_cb.load(Ordering::Relaxed), + queue_depth, + server_source_gap_peak_ms: source_gap_peak_ms_for_cb.load(Ordering::Relaxed), + server_send_gap_peak_ms: send_gap_peak_ms_for_cb.load(Ordering::Relaxed), + server_queue_peak: queue_peak_depth_for_cb.load(Ordering::Relaxed), + server_encoder_label: server_encoder_label_for_cb.clone(), + server_process_cpu_tenths: server_process_cpu_tenths_for_cb + .load(Ordering::Relaxed), + }; + match tx.try_send(Ok(pkt)) { + Ok(_) => { + sent_window.fetch_add(1, Ordering::Relaxed); + if is_idr { + wait_for_idr.store(false, Ordering::Relaxed); + } + trace!(target:"lesavka_server::video", eye = %eye, size = size, "🎥📤 sent"); + } + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + dropped_window.fetch_add(1, Ordering::Relaxed); + dropped_total_for_cb.fetch_add(1, Ordering::Relaxed); + wait_for_idr.store(true, Ordering::Relaxed); + static DROP_CNT: AtomicU64 = AtomicU64::new(0); + let dropped = DROP_CNT.fetch_add(1, Ordering::Relaxed); + if dropped.is_multiple_of(120) { + debug!( + target:"lesavka_server::video", + eye = %eye, + dropped, + "🎥⏳ channel full - dropping frames" + ); + } + } + Err(error) => error!("mpsc send err: {error}"), + } + + Ok(gst::FlowSuccess::Ok) + }) + .build(), + ); + + let bus = pipeline.bus().expect("bus"); + start_eye_pipeline(&pipeline, &bus, eye)?; + let bus_watch = BusWatchHandle::spawn(bus, eye.to_owned()); + + Ok(VideoStream { + _pipeline: pipeline, + _bus_watch: Some(bus_watch), + inner: ReceiverStream::new(rx), + }) +} diff --git a/server/src/video/stream_core.rs b/server/src/video/stream_core.rs new file mode 100644 index 0000000..86c6be2 --- /dev/null +++ b/server/src/video/stream_core.rs @@ -0,0 +1,248 @@ +// server/src/video.rs + +use anyhow::Context; +use futures_util::Stream; +use gst::MessageView::*; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use lesavka_common::eye_source::{default_eye_source_mode, eye_source_mode_for_request}; +use lesavka_common::lesavka::VideoPacket; +use lesavka_common::process_metrics::ProcessCpuSampler; +use std::os::unix::fs::FileTypeExt; +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use tokio::time::{Duration, Instant, sleep}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Status; +use tracing::{Level, debug, enabled, error, info, trace, warn}; + +pub use crate::video_sinks::{CameraRelay, HdmiSink, WebcamSink}; +use crate::video_support::{ + adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, should_send_frame, +}; + +const EYE_ID: [&str; 2] = ["l", "r"]; +static START: OnceLock = OnceLock::new(); +static SERVER_PROCESS_CPU_TENTHS: OnceLock> = OnceLock::new(); + +fn server_process_cpu_metric() -> Arc { + Arc::clone(SERVER_PROCESS_CPU_TENTHS.get_or_init(|| { + let metric = Arc::new(AtomicU32::new(0)); + let metric_for_thread = Arc::clone(&metric); + std::thread::spawn(move || { + let mut sampler = ProcessCpuSampler::new(); + loop { + if let Some(value) = sampler.sample_tenths_percent() { + metric_for_thread.store(value, Ordering::Relaxed); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + }); + metric + })) +} + +pub struct VideoStream { + _pipeline: gst::Pipeline, + #[cfg(not(coverage))] + _bus_watch: Option, + inner: ReceiverStream>, +} + +impl Stream for VideoStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Stream::poll_next(std::pin::Pin::new(&mut self.inner), cx) + } +} + +impl Drop for VideoStream { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + #[cfg(not(coverage))] + { + let _ = self._bus_watch.take(); + } + } +} + +#[cfg(not(coverage))] +struct BusWatchHandle { + alive: Arc, + join: Option>, +} + +#[cfg(not(coverage))] +impl BusWatchHandle { + fn spawn(bus: gst::Bus, eye: String) -> Self { + let alive = Arc::new(AtomicBool::new(true)); + let alive_flag = Arc::clone(&alive); + let join = std::thread::spawn(move || { + while alive_flag.load(Ordering::Relaxed) { + let Some(msg) = bus.timed_pop(gst::ClockTime::from_mseconds(250)) else { + continue; + }; + match msg.view() { + Error(err) => { + error!( + target:"lesavka_server::video", + eye = %eye, + "💥 pipeline error: {} ({})", + err.error(), + err.debug().unwrap_or_default() + ); + break; + } + Warning(warning) => { + warn!( + target:"lesavka_server::video", + eye = %eye, + "⚠️ pipeline warning: {} ({})", + warning.error(), + warning.debug().unwrap_or_default() + ); + } + Info(info_msg) => { + info!( + target:"lesavka_server::video", + eye = %eye, + "📌 pipeline info: {} ({})", + info_msg.error(), + info_msg.debug().unwrap_or_default() + ); + } + StateChanged(state) if state.current() == gst::State::Playing => { + debug!(target:"lesavka_server::video", eye = %eye, "🎬 pipeline PLAYING"); + } + StateChanged(state) if state.current() == gst::State::Null => { + debug!(target:"lesavka_server::video", eye = %eye, "🛑 pipeline stopped"); + break; + } + Eos(..) => { + debug!(target:"lesavka_server::video", eye = %eye, "🏁 pipeline EOS"); + break; + } + _ => {} + } + } + }); + Self { + alive, + join: Some(join), + } + } +} + +#[cfg(not(coverage))] +impl Drop for BusWatchHandle { + fn drop(&mut self) { + self.alive.store(false, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +#[cfg(not(coverage))] +fn start_eye_pipeline(pipeline: &gst::Pipeline, bus: &gst::Bus, eye: &str) -> anyhow::Result<()> { + pipeline + .set_state(gst::State::Playing) + .context(format!("🎥 starting video pipeline eye-{eye}"))?; + for _ in 0..20 { + match bus.timed_pop(gst::ClockTime::from_mseconds(200)) { + Some(msg) => match msg.view() { + Error(err) => { + let _ = pipeline.set_state(gst::State::Null); + return Err(anyhow::anyhow!( + "🎥 eye-{eye} pipeline error: {} ({})", + err.error(), + err.debug().unwrap_or_default() + )); + } + StateChanged(state) if state.current() == gst::State::Playing => return Ok(()), + _ => continue, + }, + None => continue, + } + } + Ok(()) +} + +fn eye_device_wait_timeout() -> Duration { + Duration::from_millis( + std::env::var("LESAVKA_EYE_DEVICE_WAIT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(5_000), + ) +} + +fn eye_device_wait_poll() -> Duration { + Duration::from_millis( + std::env::var("LESAVKA_EYE_DEVICE_POLL_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.max(25)) + .unwrap_or(100), + ) +} + +pub fn eye_source_profile() -> (u32, u32, u32) { + let mode = default_eye_source_mode(); + (mode.width, mode.height, mode.fps) +} + +fn round_down_even_u32(value: u32) -> u32 { + let rounded = value.max(2); + rounded - (rounded % 2) +} + +fn reset_stream_telemetry_window( + last_window_sec: &AtomicU64, + current_sec: u64, + source_gap_peak_ms: &AtomicU32, + send_gap_peak_ms: &AtomicU32, + queue_peak_depth: &AtomicU32, +) { + let prev = last_window_sec.load(Ordering::Relaxed); + if current_sec <= prev { + return; + } + if last_window_sec + .compare_exchange(prev, current_sec, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + source_gap_peak_ms.store(0, Ordering::Relaxed); + send_gap_peak_ms.store(0, Ordering::Relaxed); + queue_peak_depth.store(0, Ordering::Relaxed); + } +} + +#[derive(Clone, Copy, Debug)] +struct EyeCaptureRequest { + width: u32, + height: u32, + fps: u32, + max_bitrate_kbit: u32, +} + +fn normalize_eye_capture_request( + requested_width: u32, + requested_height: u32, + _requested_fps: u32, + max_bitrate_kbit: u32, +) -> EyeCaptureRequest { + let source_mode = eye_source_mode_for_request(requested_width, requested_height); + EyeCaptureRequest { + width: round_down_even_u32(source_mode.width.max(320)), + height: round_down_even_u32(source_mode.height.max(180)), + fps: source_mode.fps.max(1), + max_bitrate_kbit, + } +} diff --git a/server/src/video_sinks.rs b/server/src/video_sinks.rs index 082202f..c9a9f23 100644 --- a/server/src/video_sinks.rs +++ b/server/src/video_sinks.rs @@ -1,679 +1,4 @@ -use anyhow::Context; -use gstreamer as gst; -use gstreamer::prelude::*; -use gstreamer_app as gst_app; -use lesavka_common::lesavka::VideoPacket; -use std::fs; -use std::path::Path; -use std::sync::atomic::AtomicU64; -use tracing::warn; - -use crate::camera::{CameraCodec, CameraConfig}; -use crate::video_support::{contains_idr, dev_mode_enabled, next_local_pts, pick_h264_decoder}; - -/// Push H.264 or MJPEG frames into the USB UVC gadget. -/// -/// Inputs: a UVC device node and the negotiated camera configuration. -/// Outputs: a live `WebcamSink` that accepts `VideoPacket`s. -/// Why: the UVC sink owns the GStreamer pipeline details for gadget output so -/// the relay logic can focus on session lifecycle instead of media plumbing. -pub struct WebcamSink { - appsrc: gst_app::AppSrc, - pipe: gst::Pipeline, - next_pts_us: AtomicU64, - frame_step_us: u64, -} - -impl WebcamSink { - /// Build a new webcam sink pipeline. - /// - /// Inputs: the target UVC device plus the selected camera profile. - /// Outputs: a sink ready to receive `VideoPacket`s. - /// Why: UVC output has its own caps and decoder chain that differs from the - /// HDMI sink, so it lives in a dedicated constructor. - #[cfg(coverage)] - pub fn new(_uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { - gst::init()?; - - let pipeline = gst::Pipeline::new(); - let src = gst::ElementFactory::make("appsrc") - .build()? - .downcast::() - .expect("appsrc"); - src.set_is_live(true); - src.set_format(gst::Format::Time); - src.set_property("do-timestamp", &false); - - let sink = gst::ElementFactory::make("fakesink") - .build() - .context("building fakesink")?; - pipeline.add_many(&[src.upcast_ref(), &sink])?; - gst::Element::link_many(&[src.upcast_ref(), &sink])?; - pipeline.set_state(gst::State::Playing)?; - - let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); - Ok(Self { - appsrc: src, - pipe: pipeline, - next_pts_us: AtomicU64::new(0), - frame_step_us, - }) - } - - #[cfg(not(coverage))] - pub fn new(uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { - gst::init()?; - - let pipeline = gst::Pipeline::new(); - - let width = cfg.width as i32; - let height = cfg.height as i32; - let fps = cfg.fps.max(1) as i32; - let use_mjpeg = matches!(cfg.codec, CameraCodec::Mjpeg); - - let src = gst::ElementFactory::make("appsrc") - .build()? - .downcast::() - .expect("appsrc"); - src.set_is_live(true); - src.set_format(gst::Format::Time); - src.set_property("do-timestamp", &false); - let block = std::env::var("LESAVKA_UVC_APP_BLOCK") - .ok() - .map(|value| value != "0") - .unwrap_or(false); - src.set_property("block", &block); - - if use_mjpeg { - let caps_mjpeg = gst::Caps::builder("image/jpeg") - .field("parsed", true) - .field("width", width) - .field("height", height) - .field("framerate", gst::Fraction::new(fps, 1)) - .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) - .field("colorimetry", "2:4:7:1") - .build(); - src.set_caps(Some(&caps_mjpeg)); - - let queue = gst::ElementFactory::make("queue").build()?; - let capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &caps_mjpeg) - .build()?; - let sink = gst::ElementFactory::make("v4l2sink") - .property("device", &uvc_dev) - .property("sync", &false) - .build()?; - - pipeline.add_many(&[src.upcast_ref(), &queue, &capsfilter, &sink])?; - gst::Element::link_many(&[src.upcast_ref(), &queue, &capsfilter, &sink])?; - } else { - let caps_h264 = gst::Caps::builder("video/x-h264") - .field("stream-format", "byte-stream") - .field("alignment", "au") - .build(); - let raw_caps = gst::Caps::builder("video/x-raw") - .field("format", "YUY2") - .field("width", width) - .field("height", height) - .field("framerate", gst::Fraction::new(fps, 1)) - .build(); - src.set_caps(Some(&caps_h264)); - - let h264parse = gst::ElementFactory::make("h264parse").build()?; - let decoder_name = pick_h264_decoder(); - let decoder = gst::ElementFactory::make(decoder_name) - .build() - .with_context(|| format!("building decoder element {decoder_name}"))?; - let convert = gst::ElementFactory::make("videoconvert").build()?; - let scale = gst::ElementFactory::make("videoscale").build()?; - let caps = gst::ElementFactory::make("capsfilter") - .property("caps", &raw_caps) - .build()?; - let sink = gst::ElementFactory::make("v4l2sink") - .property("device", &uvc_dev) - .property("sync", &false) - .build()?; - - pipeline.add_many(&[ - src.upcast_ref(), - &h264parse, - &decoder, - &convert, - &scale, - &caps, - &sink, - ])?; - gst::Element::link_many(&[ - src.upcast_ref(), - &h264parse, - &decoder, - &convert, - &scale, - &caps, - &sink, - ])?; - } - pipeline.set_state(gst::State::Playing)?; - - let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); - Ok(Self { - appsrc: src, - pipe: pipeline, - next_pts_us: AtomicU64::new(0), - frame_step_us, - }) - } - - /// Push one client frame into the UVC pipeline. - /// - /// Inputs: the next `VideoPacket` from the gRPC camera stream. - /// Outputs: none; the frame is forwarded to the appsrc when possible. - /// Why: UVC sinks use a locally monotonic timeline so presentation remains - /// stable even when WAN packet timestamps arrive out of order. - #[cfg(coverage)] - pub fn push(&self, pkt: VideoPacket) { - let buf = gst::Buffer::from_slice(pkt.data); - let _ = self.appsrc.push_buffer(buf); - } - - #[cfg(not(coverage))] - pub fn push(&self, pkt: VideoPacket) { - let mut buf = gst::Buffer::from_slice(pkt.data); - if let Some(meta) = buf.get_mut() { - let pts_us = next_local_pts(&self.next_pts_us, self.frame_step_us); - let ts = gst::ClockTime::from_useconds(pts_us); - meta.set_pts(Some(ts)); - meta.set_dts(Some(ts)); - meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us))); - } - if let Err(err) = self.appsrc.push_buffer(buf) { - tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed"); - } - } -} - -impl Drop for WebcamSink { - fn drop(&mut self) { - let _ = self.pipe.set_state(gst::State::Null); - } -} - -/// Push H.264 or MJPEG frames into the HDMI display pipeline. -/// -/// Inputs: the negotiated camera configuration. -/// Outputs: a live `HdmiSink` ready to display frames. -/// Why: HDMI output uses a different sink selection and conversion chain than -/// the USB gadget, so it warrants a dedicated implementation. -pub struct HdmiSink { - appsrc: gst_app::AppSrc, - pipe: gst::Pipeline, - next_pts_us: AtomicU64, - frame_step_us: u64, -} - -impl HdmiSink { - /// Build a new HDMI sink pipeline. - /// - /// Inputs: the selected camera configuration, including optional connector - /// metadata for `kmssink`. - /// Outputs: a sink ready to receive `VideoPacket`s. - /// Why: display output must honor connector pinning and decoder selection - /// while keeping the relay code agnostic of GStreamer details. - #[cfg(coverage)] - pub fn new(cfg: &CameraConfig) -> anyhow::Result { - gst::init()?; - - let pipeline = gst::Pipeline::new(); - let src = gst::ElementFactory::make("appsrc") - .build()? - .downcast::() - .expect("appsrc"); - src.set_is_live(true); - src.set_format(gst::Format::Time); - src.set_property("do-timestamp", &false); - - let sink = build_hdmi_sink(cfg)?; - pipeline.add_many(&[src.upcast_ref(), &sink])?; - gst::Element::link_many(&[src.upcast_ref(), &sink])?; - pipeline.set_state(gst::State::Playing)?; - - let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); - Ok(Self { - appsrc: src, - pipe: pipeline, - next_pts_us: AtomicU64::new(0), - frame_step_us, - }) - } - - #[cfg(not(coverage))] - pub fn new(cfg: &CameraConfig) -> anyhow::Result { - gst::init()?; - - let pipeline = gst::Pipeline::new(); - let source_width = cfg.width as i32; - let source_height = cfg.height as i32; - let (display_width, display_height) = cfg.hdmi_display_size(); - let width = display_width as i32; - let height = display_height as i32; - let fps = cfg.fps.max(1) as i32; - - let src = gst::ElementFactory::make("appsrc") - .build()? - .downcast::() - .expect("appsrc"); - src.set_is_live(true); - src.set_format(gst::Format::Time); - src.set_property("do-timestamp", &false); - - let raw_caps = gst::Caps::builder("video/x-raw") - .field("width", width) - .field("height", height) - .field("framerate", gst::Fraction::new(fps, 1)) - .build(); - let capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &raw_caps) - .build()?; - - let queue = gst::ElementFactory::make("queue") - .property("max-size-buffers", 4u32) - .build()?; - let convert = gst::ElementFactory::make("videoconvert").build()?; - let rate = gst::ElementFactory::make("videorate").build()?; - let scale = gst::ElementFactory::make("videoscale").build()?; - let sink = build_hdmi_sink(cfg)?; - - if (display_width, display_height) != (cfg.width, cfg.height) { - tracing::info!( - target: "lesavka_server::video", - source_width = cfg.width, - source_height = cfg.height, - display_width, - display_height, - "📺 HDMI sink scaling camera uplink to adapter mode" - ); - } - - match cfg.codec { - CameraCodec::H264 => { - let caps_h264 = gst::Caps::builder("video/x-h264") - .field("stream-format", "byte-stream") - .field("alignment", "au") - .build(); - src.set_caps(Some(&caps_h264)); - let h264parse = gst::ElementFactory::make("h264parse").build()?; - let decoder_name = pick_h264_decoder(); - let decoder = gst::ElementFactory::make(decoder_name) - .build() - .with_context(|| format!("building decoder element {decoder_name}"))?; - - pipeline.add_many(&[ - src.upcast_ref(), - &queue, - &h264parse, - &decoder, - &rate, - &convert, - &scale, - &capsfilter, - &sink, - ])?; - gst::Element::link_many(&[ - src.upcast_ref(), - &queue, - &h264parse, - &decoder, - &rate, - &convert, - &scale, - &capsfilter, - &sink, - ])?; - } - CameraCodec::Mjpeg => { - let caps_mjpeg = gst::Caps::builder("image/jpeg") - .field("parsed", true) - .field("width", source_width) - .field("height", source_height) - .field("framerate", gst::Fraction::new(fps, 1)) - .build(); - src.set_caps(Some(&caps_mjpeg)); - let jpegdec = gst::ElementFactory::make("jpegdec").build()?; - - pipeline.add_many(&[ - src.upcast_ref(), - &queue, - &jpegdec, - &rate, - &convert, - &scale, - &capsfilter, - &sink, - ])?; - gst::Element::link_many(&[ - src.upcast_ref(), - &queue, - &jpegdec, - &rate, - &convert, - &scale, - &capsfilter, - &sink, - ])?; - } - } - - pipeline.set_state(gst::State::Playing)?; - let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); - Ok(Self { - appsrc: src, - pipe: pipeline, - next_pts_us: AtomicU64::new(0), - frame_step_us, - }) - } - - /// Push one client frame into the HDMI pipeline. - /// - /// Inputs: the next `VideoPacket` from the gRPC camera stream. - /// Outputs: none; the frame is forwarded to the appsrc when possible. - /// Why: display playback uses the same local monotonic PTS policy as UVC to - /// avoid visible glitches when remote timestamps jitter. - #[cfg(coverage)] - pub fn push(&self, pkt: VideoPacket) { - let buf = gst::Buffer::from_slice(pkt.data); - let _ = self.appsrc.push_buffer(buf); - } - - #[cfg(not(coverage))] - pub fn push(&self, pkt: VideoPacket) { - let mut buf = gst::Buffer::from_slice(pkt.data); - if let Some(meta) = buf.get_mut() { - let pts_us = next_local_pts(&self.next_pts_us, self.frame_step_us); - let ts = gst::ClockTime::from_useconds(pts_us); - meta.set_pts(Some(ts)); - meta.set_dts(Some(ts)); - meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us))); - } - if let Err(err) = self.appsrc.push_buffer(buf) { - tracing::warn!(target:"lesavka_server::video", %err, "📺⚠️ HDMI appsrc push failed"); - } - } -} - -impl Drop for HdmiSink { - fn drop(&mut self) { - let _ = self.pipe.set_state(gst::State::Null); - } -} - -#[cfg(coverage)] -fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result { - if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { - return gst::ElementFactory::make(&name) - .build() - .context("building HDMI sink"); - } - - let sink = gst::ElementFactory::make("fakesink") - .build() - .context("building fallback HDMI sink")?; - let _ = sink.set_property("sync", &false); - Ok(sink) -} - -#[cfg(not(coverage))] -fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result { - if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { - let normalized = name.trim().to_ascii_lowercase(); - if normalized == "fbdev" || normalized == "fbdevsink" { - return build_fbdev_hdmi_sink(); - } - - let sink = gst::ElementFactory::make(&name) - .build() - .context("building HDMI sink")?; - disable_sink_clock_sync(&sink); - return Ok(sink); - } - - if gst::ElementFactory::find("kmssink").is_some() { - unblank_framebuffer(&hdmi_fbdev_device()); - let sink = gst::ElementFactory::make("kmssink").build()?; - if sink.has_property("driver-name", None) { - let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string()); - sink.set_property("driver-name", &driver); - } - if let Some(connector) = cfg.hdmi.as_ref().and_then(|hdmi| hdmi.id) { - if sink.has_property("connector-id", None) { - sink.set_property("connector-id", &(connector as i32)); - } else { - tracing::warn!( - target: "lesavka_server::video", - %connector, - "kmssink does not expose connector-id property; using default connector" - ); - } - } - if sink.has_property("force-modesetting", None) { - sink.set_property("force-modesetting", &true); - } - if sink.has_property("restore-crtc", None) { - let restore = read_bool_env("LESAVKA_HDMI_RESTORE_CRTC").unwrap_or(false); - sink.set_property("restore-crtc", &restore); - } - if sink.has_property("skip-vsync", None) { - let skip = read_bool_env("LESAVKA_HDMI_SKIP_VSYNC").unwrap_or(false); - sink.set_property("skip-vsync", &skip); - } - disable_sink_clock_sync(&sink); - return Ok(sink); - } - - let sink = gst::ElementFactory::make("autovideosink") - .build() - .context("building HDMI sink")?; - disable_sink_clock_sync(&sink); - Ok(sink) -} - -#[cfg(not(coverage))] -fn build_fbdev_hdmi_sink() -> anyhow::Result { - let device = hdmi_fbdev_device(); - unblank_framebuffer(&device); - - let sink = gst::ElementFactory::make("fbdevsink") - .property("device", &device) - .build() - .context("building framebuffer HDMI sink")?; - disable_sink_clock_sync(&sink); - - tracing::info!( - target: "lesavka_server::video", - %device, - "📺 HDMI sink using framebuffer scanout" - ); - - Ok(sink) -} - -#[cfg(not(coverage))] -fn hdmi_fbdev_device() -> String { - std::env::var("LESAVKA_HDMI_FBDEV") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "/dev/fb0".to_string()) -} - -#[cfg(not(coverage))] -fn unblank_framebuffer(device: &str) { - let Some(name) = Path::new(device) - .file_name() - .and_then(|value| value.to_str()) - else { - return; - }; - if !name.starts_with("fb") { - return; - } - - let blank_path = format!("/sys/class/graphics/{name}/blank"); - match fs::write(&blank_path, b"0\n") { - Ok(()) => tracing::debug!( - target: "lesavka_server::video", - %device, - %blank_path, - "📺 HDMI framebuffer unblanked" - ), - Err(error) => tracing::debug!( - target: "lesavka_server::video", - %device, - %blank_path, - %error, - "📺 HDMI framebuffer unblank skipped" - ), - } -} - -fn disable_sink_clock_sync(sink: &gst::Element) { - if sink.has_property("sync", None) { - sink.set_property("sync", &false); - } -} - -fn read_bool_env(name: &str) -> Option { - let value = std::env::var(name).ok()?; - match value.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Some(true), - "0" | "false" | "no" | "off" => Some(false), - _ => None, - } -} - -enum CameraSink { - Uvc(WebcamSink), - Hdmi(HdmiSink), - #[cfg(coverage)] - Noop, -} - -impl CameraSink { - fn push(&self, pkt: VideoPacket) { - match self { - CameraSink::Uvc(sink) => sink.push(pkt), - CameraSink::Hdmi(sink) => sink.push(pkt), - #[cfg(coverage)] - CameraSink::Noop => { - let _ = pkt; - } - } - } -} - -/// Forward camera packets from gRPC into either a UVC or HDMI sink. -/// -/// Inputs: packets received from the client camera stream. -/// Outputs: none; packets are forwarded to the configured sink. -/// Why: camera sessions share the same logging and dev-mode dump behavior even -/// though their physical sinks differ. -pub struct CameraRelay { - sink: CameraSink, - id: u32, - frames: AtomicU64, -} - -impl CameraRelay { - /// Build a relay that targets the USB UVC gadget. - /// - /// Inputs: the logical camera id, UVC device node, and camera config. - /// Outputs: a relay that writes frames into the gadget pipeline. - /// Why: keeping constructors explicit avoids accidental sink mismatches. - pub fn new_uvc(id: u32, uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { - Ok(Self { - sink: CameraSink::Uvc(WebcamSink::new(uvc_dev, cfg)?), - id, - frames: AtomicU64::new(0), - }) - } - - /// Build a relay that targets the HDMI output pipeline. - /// - /// Inputs: the logical camera id plus the camera config. - /// Outputs: a relay that writes frames into the display pipeline. - /// Why: the camera runtime reuses this constructor when the negotiated - /// output mode selects HDMI instead of UVC. - pub fn new_hdmi(id: u32, cfg: &CameraConfig) -> anyhow::Result { - Ok(Self { - sink: CameraSink::Hdmi(HdmiSink::new(cfg)?), - id, - frames: AtomicU64::new(0), - }) - } - - #[cfg(coverage)] - pub fn new_noop(id: u32) -> Self { - Self { - sink: CameraSink::Noop, - id, - frames: AtomicU64::new(0), - } - } - - /// Push one `VideoPacket` coming from the client. - /// - /// Inputs: the next packet from the camera stream. - /// Outputs: none; the packet is logged and forwarded to the sink. - /// Why: centralizing frame logging and dev-mode dump behavior keeps the - /// transport session logic separate from media sink mechanics. - #[cfg(coverage)] - pub fn feed(&self, pkt: VideoPacket) { - let frame = self - .frames - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - - if dev_mode_enabled() && contains_idr(&pkt.data) { - let path = format!("/tmp/eye3-cli-{frame:05}.h264"); - let _ = std::fs::write(&path, &pkt.data); - } - - self.sink.push(pkt); - } - - #[cfg(not(coverage))] - pub fn feed(&self, pkt: VideoPacket) { - let frame = self - .frames - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if frame < 10 || frame % 60 == 0 { - tracing::debug!( - target:"lesavka_server::video", - cam_id = self.id, - frame, - bytes = pkt.data.len(), - pts = pkt.pts, - "📸 srv webcam frame" - ); - } else if frame % 10 == 0 { - tracing::trace!( - target:"lesavka_server::video", - cam_id = self.id, - bytes = pkt.data.len(), - "📸📥 srv pkt" - ); - } - - if dev_mode_enabled() - && (cfg!(debug_assertions) || tracing::enabled!(tracing::Level::TRACE)) - && contains_idr(&pkt.data) - { - let path = format!("/tmp/eye3-cli-{frame:05}.h264"); - if let Err(error) = std::fs::write(&path, &pkt.data) { - warn!("📸💾 dump failed: {error}"); - } else { - tracing::debug!("📸💾 wrote {}", path); - } - } - - self.sink.push(pkt); - } -} +// Camera sink pipelines for UVC webcam output and HDMI capture adapters. +include!("video_sinks/webcam_sink.rs"); +include!("video_sinks/hdmi_sink.rs"); +include!("video_sinks/camera_relay.rs"); diff --git a/server/src/video_sinks/camera_relay.rs b/server/src/video_sinks/camera_relay.rs new file mode 100644 index 0000000..c7c3ea6 --- /dev/null +++ b/server/src/video_sinks/camera_relay.rs @@ -0,0 +1,127 @@ +enum CameraSink { + Uvc(WebcamSink), + Hdmi(HdmiSink), + #[cfg(coverage)] + Noop, +} + +impl CameraSink { + fn push(&self, pkt: VideoPacket) { + match self { + CameraSink::Uvc(sink) => sink.push(pkt), + CameraSink::Hdmi(sink) => sink.push(pkt), + #[cfg(coverage)] + CameraSink::Noop => { + let _ = pkt; + } + } + } +} + +/// Forward camera packets from gRPC into either a UVC or HDMI sink. +/// +/// Inputs: packets received from the client camera stream. +/// Outputs: none; packets are forwarded to the configured sink. +/// Why: camera sessions share the same logging and dev-mode dump behavior even +/// though their physical sinks differ. +pub struct CameraRelay { + sink: CameraSink, + id: u32, + frames: AtomicU64, +} + +impl CameraRelay { + /// Build a relay that targets the USB UVC gadget. + /// + /// Inputs: the logical camera id, UVC device node, and camera config. + /// Outputs: a relay that writes frames into the gadget pipeline. + /// Why: keeping constructors explicit avoids accidental sink mismatches. + pub fn new_uvc(id: u32, uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { + Ok(Self { + sink: CameraSink::Uvc(WebcamSink::new(uvc_dev, cfg)?), + id, + frames: AtomicU64::new(0), + }) + } + + /// Build a relay that targets the HDMI output pipeline. + /// + /// Inputs: the logical camera id plus the camera config. + /// Outputs: a relay that writes frames into the display pipeline. + /// Why: the camera runtime reuses this constructor when the negotiated + /// output mode selects HDMI instead of UVC. + pub fn new_hdmi(id: u32, cfg: &CameraConfig) -> anyhow::Result { + Ok(Self { + sink: CameraSink::Hdmi(HdmiSink::new(cfg)?), + id, + frames: AtomicU64::new(0), + }) + } + + #[cfg(coverage)] + pub fn new_noop(id: u32) -> Self { + Self { + sink: CameraSink::Noop, + id, + frames: AtomicU64::new(0), + } + } + + /// Push one `VideoPacket` coming from the client. + /// + /// Inputs: the next packet from the camera stream. + /// Outputs: none; the packet is logged and forwarded to the sink. + /// Why: centralizing frame logging and dev-mode dump behavior keeps the + /// transport session logic separate from media sink mechanics. + #[cfg(coverage)] + pub fn feed(&self, pkt: VideoPacket) { + let frame = self + .frames + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + if dev_mode_enabled() && contains_idr(&pkt.data) { + let path = format!("/tmp/eye3-cli-{frame:05}.h264"); + let _ = std::fs::write(&path, &pkt.data); + } + + self.sink.push(pkt); + } + + #[cfg(not(coverage))] + pub fn feed(&self, pkt: VideoPacket) { + let frame = self + .frames + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if frame < 10 || frame.is_multiple_of(60) { + tracing::debug!( + target:"lesavka_server::video", + cam_id = self.id, + frame, + bytes = pkt.data.len(), + pts = pkt.pts, + "📸 srv webcam frame" + ); + } else if frame.is_multiple_of(10) { + tracing::trace!( + target:"lesavka_server::video", + cam_id = self.id, + bytes = pkt.data.len(), + "📸📥 srv pkt" + ); + } + + if dev_mode_enabled() + && (cfg!(debug_assertions) || tracing::enabled!(tracing::Level::TRACE)) + && contains_idr(&pkt.data) + { + let path = format!("/tmp/eye3-cli-{frame:05}.h264"); + if let Err(error) = std::fs::write(&path, &pkt.data) { + warn!("📸💾 dump failed: {error}"); + } else { + tracing::debug!("📸💾 wrote {}", path); + } + } + + self.sink.push(pkt); + } +} diff --git a/server/src/video_sinks/hdmi_sink.rs b/server/src/video_sinks/hdmi_sink.rs new file mode 100644 index 0000000..9ee3245 --- /dev/null +++ b/server/src/video_sinks/hdmi_sink.rs @@ -0,0 +1,354 @@ + +/// Push H.264 or MJPEG frames into the HDMI display pipeline. +/// +/// Inputs: the negotiated camera configuration. +/// Outputs: a live `HdmiSink` ready to display frames. +/// Why: HDMI output uses a different sink selection and conversion chain than +/// the USB gadget, so it warrants a dedicated implementation. +pub struct HdmiSink { + appsrc: gst_app::AppSrc, + pipe: gst::Pipeline, + next_pts_us: AtomicU64, + frame_step_us: u64, +} + +impl HdmiSink { + /// Build a new HDMI sink pipeline. + /// + /// Inputs: the selected camera configuration, including optional connector + /// metadata for `kmssink`. + /// Outputs: a sink ready to receive `VideoPacket`s. + /// Why: display output must honor connector pinning and decoder selection + /// while keeping the relay code agnostic of GStreamer details. + #[cfg(coverage)] + pub fn new(cfg: &CameraConfig) -> anyhow::Result { + gst::init()?; + + let pipeline = gst::Pipeline::new(); + let src = gst::ElementFactory::make("appsrc") + .build()? + .downcast::() + .expect("appsrc"); + src.set_is_live(true); + src.set_format(gst::Format::Time); + src.set_property("do-timestamp", &false); + + let sink = build_hdmi_sink(cfg)?; + pipeline.add_many(&[src.upcast_ref(), &sink])?; + gst::Element::link_many(&[src.upcast_ref(), &sink])?; + pipeline.set_state(gst::State::Playing)?; + + let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); + Ok(Self { + appsrc: src, + pipe: pipeline, + next_pts_us: AtomicU64::new(0), + frame_step_us, + }) + } + + #[cfg(not(coverage))] + pub fn new(cfg: &CameraConfig) -> anyhow::Result { + gst::init()?; + + let pipeline = gst::Pipeline::new(); + let source_width = cfg.width as i32; + let source_height = cfg.height as i32; + let (display_width, display_height) = cfg.hdmi_display_size(); + let width = display_width as i32; + let height = display_height as i32; + let fps = cfg.fps.max(1) as i32; + + let src = gst::ElementFactory::make("appsrc") + .build()? + .downcast::() + .expect("appsrc"); + src.set_is_live(true); + src.set_format(gst::Format::Time); + src.set_property("do-timestamp", false); + + let raw_caps = gst::Caps::builder("video/x-raw") + .field("width", width) + .field("height", height) + .field("framerate", gst::Fraction::new(fps, 1)) + .build(); + let capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &raw_caps) + .build()?; + + let queue = gst::ElementFactory::make("queue") + .property("max-size-buffers", 4u32) + .build()?; + let convert = gst::ElementFactory::make("videoconvert").build()?; + let rate = gst::ElementFactory::make("videorate").build()?; + let scale = gst::ElementFactory::make("videoscale").build()?; + let sink = build_hdmi_sink(cfg)?; + + if (display_width, display_height) != (cfg.width, cfg.height) { + tracing::info!( + target: "lesavka_server::video", + source_width = cfg.width, + source_height = cfg.height, + display_width, + display_height, + "📺 HDMI sink scaling camera uplink to adapter mode" + ); + } + + match cfg.codec { + CameraCodec::H264 => { + let caps_h264 = gst::Caps::builder("video/x-h264") + .field("stream-format", "byte-stream") + .field("alignment", "au") + .build(); + src.set_caps(Some(&caps_h264)); + let h264parse = gst::ElementFactory::make("h264parse").build()?; + let decoder_name = pick_h264_decoder(); + let decoder = gst::ElementFactory::make(decoder_name) + .build() + .with_context(|| format!("building decoder element {decoder_name}"))?; + + pipeline.add_many([ + src.upcast_ref(), + &queue, + &h264parse, + &decoder, + &rate, + &convert, + &scale, + &capsfilter, + &sink, + ])?; + gst::Element::link_many([ + src.upcast_ref(), + &queue, + &h264parse, + &decoder, + &rate, + &convert, + &scale, + &capsfilter, + &sink, + ])?; + } + CameraCodec::Mjpeg => { + let caps_mjpeg = gst::Caps::builder("image/jpeg") + .field("parsed", true) + .field("width", source_width) + .field("height", source_height) + .field("framerate", gst::Fraction::new(fps, 1)) + .build(); + src.set_caps(Some(&caps_mjpeg)); + let jpegdec = gst::ElementFactory::make("jpegdec").build()?; + + pipeline.add_many([ + src.upcast_ref(), + &queue, + &jpegdec, + &rate, + &convert, + &scale, + &capsfilter, + &sink, + ])?; + gst::Element::link_many([ + src.upcast_ref(), + &queue, + &jpegdec, + &rate, + &convert, + &scale, + &capsfilter, + &sink, + ])?; + } + } + + pipeline.set_state(gst::State::Playing)?; + let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); + Ok(Self { + appsrc: src, + pipe: pipeline, + next_pts_us: AtomicU64::new(0), + frame_step_us, + }) + } + + /// Push one client frame into the HDMI pipeline. + /// + /// Inputs: the next `VideoPacket` from the gRPC camera stream. + /// Outputs: none; the frame is forwarded to the appsrc when possible. + /// Why: display playback uses the same local monotonic PTS policy as UVC to + /// avoid visible glitches when remote timestamps jitter. + #[cfg(coverage)] + pub fn push(&self, pkt: VideoPacket) { + let buf = gst::Buffer::from_slice(pkt.data); + let _ = self.appsrc.push_buffer(buf); + } + + #[cfg(not(coverage))] + pub fn push(&self, pkt: VideoPacket) { + let mut buf = gst::Buffer::from_slice(pkt.data); + if let Some(meta) = buf.get_mut() { + let pts_us = next_local_pts(&self.next_pts_us, self.frame_step_us); + let ts = gst::ClockTime::from_useconds(pts_us); + meta.set_pts(Some(ts)); + meta.set_dts(Some(ts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us))); + } + if let Err(err) = self.appsrc.push_buffer(buf) { + tracing::warn!(target:"lesavka_server::video", %err, "📺⚠️ HDMI appsrc push failed"); + } + } +} + +impl Drop for HdmiSink { + fn drop(&mut self) { + let _ = self.pipe.set_state(gst::State::Null); + } +} + +#[cfg(coverage)] +fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result { + if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { + let sink = gst::ElementFactory::make(&name) + .build() + .context("building HDMI sink")?; + disable_sink_clock_sync(&sink); + return Ok(sink); + } + + let sink = gst::ElementFactory::make("fakesink") + .build() + .context("building fallback HDMI sink")?; + disable_sink_clock_sync(&sink); + Ok(sink) +} + +#[cfg(not(coverage))] +fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result { + if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { + let normalized = name.trim().to_ascii_lowercase(); + if normalized == "fbdev" || normalized == "fbdevsink" { + return build_fbdev_hdmi_sink(); + } + + let sink = gst::ElementFactory::make(&name) + .build() + .context("building HDMI sink")?; + disable_sink_clock_sync(&sink); + return Ok(sink); + } + + if gst::ElementFactory::find("kmssink").is_some() { + unblank_framebuffer(&hdmi_fbdev_device()); + let sink = gst::ElementFactory::make("kmssink").build()?; + if sink.has_property("driver-name", None) { + let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string()); + sink.set_property("driver-name", &driver); + } + if let Some(connector) = cfg.hdmi.as_ref().and_then(|hdmi| hdmi.id) { + if sink.has_property("connector-id", None) { + sink.set_property("connector-id", connector as i32); + } else { + tracing::warn!( + target: "lesavka_server::video", + %connector, + "kmssink does not expose connector-id property; using default connector" + ); + } + } + if sink.has_property("force-modesetting", None) { + sink.set_property("force-modesetting", true); + } + if sink.has_property("restore-crtc", None) { + let restore = read_bool_env("LESAVKA_HDMI_RESTORE_CRTC").unwrap_or(false); + sink.set_property("restore-crtc", restore); + } + if sink.has_property("skip-vsync", None) { + let skip = read_bool_env("LESAVKA_HDMI_SKIP_VSYNC").unwrap_or(false); + sink.set_property("skip-vsync", skip); + } + disable_sink_clock_sync(&sink); + return Ok(sink); + } + + let sink = gst::ElementFactory::make("autovideosink") + .build() + .context("building HDMI sink")?; + disable_sink_clock_sync(&sink); + Ok(sink) +} + +#[cfg(not(coverage))] +fn build_fbdev_hdmi_sink() -> anyhow::Result { + let device = hdmi_fbdev_device(); + unblank_framebuffer(&device); + + let sink = gst::ElementFactory::make("fbdevsink") + .property("device", &device) + .build() + .context("building framebuffer HDMI sink")?; + disable_sink_clock_sync(&sink); + + tracing::info!( + target: "lesavka_server::video", + %device, + "📺 HDMI sink using framebuffer scanout" + ); + + Ok(sink) +} + +#[cfg(not(coverage))] +fn hdmi_fbdev_device() -> String { + std::env::var("LESAVKA_HDMI_FBDEV") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "/dev/fb0".to_string()) +} + +#[cfg(not(coverage))] +fn unblank_framebuffer(device: &str) { + let Some(name) = Path::new(device) + .file_name() + .and_then(|value| value.to_str()) + else { + return; + }; + if !name.starts_with("fb") { + return; + } + + let blank_path = format!("/sys/class/graphics/{name}/blank"); + match fs::write(&blank_path, b"0\n") { + Ok(()) => tracing::debug!( + target: "lesavka_server::video", + %device, + %blank_path, + "📺 HDMI framebuffer unblanked" + ), + Err(error) => tracing::debug!( + target: "lesavka_server::video", + %device, + %blank_path, + %error, + "📺 HDMI framebuffer unblank skipped" + ), + } +} + +fn disable_sink_clock_sync(sink: &gst::Element) { + if sink.has_property("sync", None) { + sink.set_property("sync", false); + } +} + +fn read_bool_env(name: &str) -> Option { + let value = std::env::var(name).ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs new file mode 100644 index 0000000..8077b35 --- /dev/null +++ b/server/src/video_sinks/webcam_sink.rs @@ -0,0 +1,199 @@ +use anyhow::Context; +use gstreamer as gst; +use gstreamer::prelude::*; +use gstreamer_app as gst_app; +use lesavka_common::lesavka::VideoPacket; +use std::fs; +use std::path::Path; +use std::sync::atomic::AtomicU64; +use tracing::warn; + +use crate::camera::{CameraCodec, CameraConfig}; +use crate::video_support::{contains_idr, dev_mode_enabled, next_local_pts, pick_h264_decoder}; + +/// Push H.264 or MJPEG frames into the USB UVC gadget. +/// +/// Inputs: a UVC device node and the negotiated camera configuration. +/// Outputs: a live `WebcamSink` that accepts `VideoPacket`s. +/// Why: the UVC sink owns the GStreamer pipeline details for gadget output so +/// the relay logic can focus on session lifecycle instead of media plumbing. +pub struct WebcamSink { + appsrc: gst_app::AppSrc, + pipe: gst::Pipeline, + next_pts_us: AtomicU64, + frame_step_us: u64, +} + +impl WebcamSink { + /// Build a new webcam sink pipeline. + /// + /// Inputs: the target UVC device plus the selected camera profile. + /// Outputs: a sink ready to receive `VideoPacket`s. + /// Why: UVC output has its own caps and decoder chain that differs from the + /// HDMI sink, so it lives in a dedicated constructor. + #[cfg(coverage)] + pub fn new(_uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { + gst::init()?; + + let pipeline = gst::Pipeline::new(); + let src = gst::ElementFactory::make("appsrc") + .build()? + .downcast::() + .expect("appsrc"); + src.set_is_live(true); + src.set_format(gst::Format::Time); + src.set_property("do-timestamp", &false); + + let sink = gst::ElementFactory::make("fakesink") + .build() + .context("building fakesink")?; + pipeline.add_many(&[src.upcast_ref(), &sink])?; + gst::Element::link_many(&[src.upcast_ref(), &sink])?; + pipeline.set_state(gst::State::Playing)?; + + let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); + Ok(Self { + appsrc: src, + pipe: pipeline, + next_pts_us: AtomicU64::new(0), + frame_step_us, + }) + } + + #[cfg(not(coverage))] + pub fn new(uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result { + gst::init()?; + + let pipeline = gst::Pipeline::new(); + + let width = cfg.width as i32; + let height = cfg.height as i32; + let fps = cfg.fps.max(1) as i32; + let use_mjpeg = matches!(cfg.codec, CameraCodec::Mjpeg); + + let src = gst::ElementFactory::make("appsrc") + .build()? + .downcast::() + .expect("appsrc"); + src.set_is_live(true); + src.set_format(gst::Format::Time); + src.set_property("do-timestamp", false); + let block = std::env::var("LESAVKA_UVC_APP_BLOCK") + .ok() + .map(|value| value != "0") + .unwrap_or(false); + src.set_property("block", block); + + if use_mjpeg { + let caps_mjpeg = gst::Caps::builder("image/jpeg") + .field("parsed", true) + .field("width", width) + .field("height", height) + .field("framerate", gst::Fraction::new(fps, 1)) + .field("pixel-aspect-ratio", gst::Fraction::new(1, 1)) + .field("colorimetry", "2:4:7:1") + .build(); + src.set_caps(Some(&caps_mjpeg)); + + let queue = gst::ElementFactory::make("queue").build()?; + let capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &caps_mjpeg) + .build()?; + let sink = gst::ElementFactory::make("v4l2sink") + .property("device", uvc_dev) + .property("sync", false) + .build()?; + + pipeline.add_many([src.upcast_ref(), &queue, &capsfilter, &sink])?; + gst::Element::link_many([src.upcast_ref(), &queue, &capsfilter, &sink])?; + } else { + let caps_h264 = gst::Caps::builder("video/x-h264") + .field("stream-format", "byte-stream") + .field("alignment", "au") + .build(); + let raw_caps = gst::Caps::builder("video/x-raw") + .field("format", "YUY2") + .field("width", width) + .field("height", height) + .field("framerate", gst::Fraction::new(fps, 1)) + .build(); + src.set_caps(Some(&caps_h264)); + + let h264parse = gst::ElementFactory::make("h264parse").build()?; + let decoder_name = pick_h264_decoder(); + let decoder = gst::ElementFactory::make(decoder_name) + .build() + .with_context(|| format!("building decoder element {decoder_name}"))?; + let convert = gst::ElementFactory::make("videoconvert").build()?; + let scale = gst::ElementFactory::make("videoscale").build()?; + let caps = gst::ElementFactory::make("capsfilter") + .property("caps", &raw_caps) + .build()?; + let sink = gst::ElementFactory::make("v4l2sink") + .property("device", uvc_dev) + .property("sync", false) + .build()?; + + pipeline.add_many([ + src.upcast_ref(), + &h264parse, + &decoder, + &convert, + &scale, + &caps, + &sink, + ])?; + gst::Element::link_many([ + src.upcast_ref(), + &h264parse, + &decoder, + &convert, + &scale, + &caps, + &sink, + ])?; + } + pipeline.set_state(gst::State::Playing)?; + + let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); + Ok(Self { + appsrc: src, + pipe: pipeline, + next_pts_us: AtomicU64::new(0), + frame_step_us, + }) + } + + /// Push one client frame into the UVC pipeline. + /// + /// Inputs: the next `VideoPacket` from the gRPC camera stream. + /// Outputs: none; the frame is forwarded to the appsrc when possible. + /// Why: UVC sinks use a locally monotonic timeline so presentation remains + /// stable even when WAN packet timestamps arrive out of order. + #[cfg(coverage)] + pub fn push(&self, pkt: VideoPacket) { + let buf = gst::Buffer::from_slice(pkt.data); + let _ = self.appsrc.push_buffer(buf); + } + + #[cfg(not(coverage))] + pub fn push(&self, pkt: VideoPacket) { + let mut buf = gst::Buffer::from_slice(pkt.data); + if let Some(meta) = buf.get_mut() { + let pts_us = next_local_pts(&self.next_pts_us, self.frame_step_us); + let ts = gst::ClockTime::from_useconds(pts_us); + meta.set_pts(Some(ts)); + meta.set_dts(Some(ts)); + meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us))); + } + if let Err(err) = self.appsrc.push_buffer(buf) { + tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed"); + } + } +} + +impl Drop for WebcamSink { + fn drop(&mut self) { + let _ = self.pipe.set_state(gst::State::Null); + } +} diff --git a/testing/tests/client_app_include_contract.rs b/testing/tests/client_app_include_contract.rs index 1071f9c..611acff 100644 --- a/testing/tests/client_app_include_contract.rs +++ b/testing/tests/client_app_include_contract.rs @@ -1,8 +1,8 @@ //! Include-based coverage for client app startup reactor behavior. //! -//! Scope: compile `client/src/app.rs` as a module with deterministic local -//! stubs for capture/render dependencies, then exercise `new` + `run`. -//! Targets: `client/src/app.rs`. +//! Scope: compile the client app reactor with deterministic local stubs for +//! capture/render dependencies, then exercise `new` + `run`. +//! Targets: `client/src/app.rs`, `client/src/app/downlink_media.rs`. //! Why: app orchestration branches should stay stable in CI without physical //! devices. @@ -228,7 +228,7 @@ mod tests { use temp_env::with_var; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; - const APP_SRC: &str = include_str!("../../client/src/app.rs"); + const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs"); #[test] #[serial] @@ -377,9 +377,9 @@ mod tests { #[test] fn audio_loop_backoff_contract_protects_server_from_reconnect_storms() { - assert!(APP_SRC.contains("let mut delay = Duration::from_secs(1);")); - assert!(APP_SRC.contains("tokio::time::sleep(delay).await;")); - assert!(APP_SRC.contains("delay = app_support::next_delay(delay);")); - assert!(APP_SRC.contains("consecutive_source_failures = 0;")); + assert!(DOWNLINK_MEDIA_SRC.contains("let mut delay = Duration::from_secs(1);")); + assert!(DOWNLINK_MEDIA_SRC.contains("tokio::time::sleep(delay).await;")); + assert!(DOWNLINK_MEDIA_SRC.contains("delay = app_support::next_delay(delay);")); + assert!(DOWNLINK_MEDIA_SRC.contains("consecutive_source_failures = 0;")); } } diff --git a/testing/tests/client_camera_include_contract.rs b/testing/tests/client_camera_include_contract.rs index 716cf4e..fc085e9 100644 --- a/testing/tests/client_camera_include_contract.rs +++ b/testing/tests/client_camera_include_contract.rs @@ -107,6 +107,14 @@ mod camera_include_contract { } } + #[test] + #[cfg(coverage)] + fn camera_bus_logger_coverage_stub_is_non_blocking() { + init_gst(); + let pipeline = gst::Pipeline::new(); + spawn_camera_bus_logger(&pipeline, "test-camera".to_string()); + } + #[test] fn find_device_and_capture_detection_handle_missing_nodes() { assert!(CameraCapture::find_device("never-matches-this-fragment").is_none()); diff --git a/testing/tests/client_inputs_extra_contract.rs b/testing/tests/client_inputs_extra_contract.rs index 722a232..5a8c6c1 100644 --- a/testing/tests/client_inputs_extra_contract.rs +++ b/testing/tests/client_inputs_extra_contract.rs @@ -67,6 +67,82 @@ mod inputs_contract_extra { build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER]) } + fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { + let mut keys = AttributeSet::::new(); + keys.insert(evdev::KeyCode::BTN_LEFT); + let mut rel = AttributeSet::::new(); + rel.insert(evdev::RelativeAxisCode::REL_X); + rel.insert(evdev::RelativeAxisCode::REL_Y); + + let mut vdev = VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .with_relative_axes(&rel) + .ok()? + .build() + .ok()?; + + let dev = open_virtual_device(&mut vdev)?; + Some((vdev, dev)) + } + + fn new_aggregator_with_capture(capture_remote_boot: bool) -> InputAggregator { + let (kbd_tx, _) = tokio::sync::broadcast::channel(16); + let (mou_tx, _) = tokio::sync::broadcast::channel(16); + InputAggregator::new_with_capture_mode(false, kbd_tx, mou_tx, None, capture_remote_boot) + } + + #[test] + #[cfg(coverage)] + #[serial] + fn init_honors_device_selection_mismatches() { + let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-skip-kbd") else { + return; + }; + let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-skip-mouse") + else { + return; + }; + + temp_env::with_vars( + [ + ("LESAVKA_KEYBOARD_DEVICE", Some("/dev/input/does-not-match")), + ("LESAVKA_MOUSE_DEVICE", Some("/dev/input/does-not-match")), + ], + || { + let mut agg = new_aggregator_with_capture(true); + agg.init().expect("init should tolerate skipped devices"); + }, + ); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn init_stages_devices_ungrabbed_when_session_starts_local() { + let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-local-kbd") + else { + return; + }; + let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-local-mouse") + else { + return; + }; + + temp_env::with_vars( + [ + ("LESAVKA_KEYBOARD_DEVICE", None::<&str>), + ("LESAVKA_MOUSE_DEVICE", None::<&str>), + ], + || { + let mut agg = new_aggregator_with_capture(false); + agg.init().expect("init should stage local devices"); + }, + ); + } + #[test] #[serial] fn quick_toggle_detects_tap_when_press_and_release_land_in_same_poll_cycle() { @@ -101,6 +177,55 @@ mod inputs_contract_extra { ); } + #[tokio::test(flavor = "current_thread")] + #[cfg(coverage)] + async fn run_keeps_pending_release_armed_until_tracked_key_is_released() { + let Some((mut vdev, dev)) = + build_keyboard_pair_with_keys("lesavka-run-held-key", &[evdev::KeyCode::KEY_A]) + else { + return; + }; + + let (kbd_tx, _) = tokio::sync::broadcast::channel(16); + let (mou_tx, _) = tokio::sync::broadcast::channel(16); + let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None); + vdev.emit(&[evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_A.0, + 1, + )]) + .expect("emit held key"); + thread::sleep(std::time::Duration::from_millis(25)); + keyboard.process_events(); + + let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); + agg.pending_release = true; + agg.pending_keys.insert(evdev::KeyCode::KEY_A); + agg.keyboards.push(keyboard); + + let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await; + assert!(result.is_err(), "held key should keep the run loop active"); + assert!(agg.pending_release); + } + + #[tokio::test(flavor = "current_thread")] + #[cfg(coverage)] + async fn run_finishes_pending_release_after_tracked_key_disappears() { + let (kbd_tx, _) = tokio::sync::broadcast::channel(16); + let (mou_tx, _) = tokio::sync::broadcast::channel(16); + let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); + agg.pending_release = true; + agg.pending_keys.insert(evdev::KeyCode::KEY_A); + + let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await; + assert!( + result.is_err(), + "run loop should continue after releasing locally" + ); + assert!(agg.released); + assert!(!agg.pending_release); + } + #[test] #[serial] fn process_keyboard_updates_merges_modifier_and_key_across_keyboards() { diff --git a/testing/tests/client_inputs_routing_contract.rs b/testing/tests/client_inputs_routing_contract.rs index 7a20fad..bb50c8f 100644 --- a/testing/tests/client_inputs_routing_contract.rs +++ b/testing/tests/client_inputs_routing_contract.rs @@ -239,6 +239,63 @@ mod inputs_contract { }); } + #[test] + #[serial] + fn release_timeout_env_uses_default_and_safety_floor_for_bad_values() { + with_var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", None::<&str>, || { + assert_eq!( + pending_release_timeout_from_env(), + Duration::from_millis(750) + ); + }); + with_var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", Some("bad"), || { + assert_eq!( + pending_release_timeout_from_env(), + Duration::from_millis(750) + ); + }); + with_var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", Some("25"), || { + assert_eq!( + pending_release_timeout_from_env(), + Duration::from_millis(100) + ); + }); + } + + #[test] + #[serial] + fn input_device_override_env_ignores_blank_and_all_values() { + with_var("LESAVKA_KEYBOARD_DEVICE", Some("all"), || { + assert_eq!( + input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), + None + ); + }); + with_var("LESAVKA_KEYBOARD_DEVICE", Some(" "), || { + assert_eq!( + input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), + None + ); + }); + with_var("LESAVKA_KEYBOARD_DEVICE", Some("/dev/input/event7"), || { + assert_eq!( + input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), + Some("/dev/input/event7".to_string()) + ); + }); + + let path = std::path::Path::new("/dev/input/event7"); + assert!(matches_selected_input_device(path, None)); + assert!(matches_selected_input_device( + path, + Some("/dev/input/event7") + )); + assert!(!matches_selected_input_device( + path, + Some("/dev/input/event8") + )); + } + #[test] #[serial] fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() { @@ -270,6 +327,19 @@ mod inputs_contract { with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || { assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60)); }); + with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("bad"), || { + with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("123"), || { + assert_eq!( + remote_failsafe_timeout_from_env(), + Duration::from_millis(123) + ); + }); + }); + with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("bad"), || { + with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("bad"), || { + assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO); + }); + }); } #[test] @@ -406,56 +476,4 @@ mod inputs_contract { "the relay should still be in pending-release until the local handoff completes" ); } - - #[test] - fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() { - let mut agg = new_aggregator(); - agg.quick_toggle_debounce = Duration::from_millis(0); - - agg.observe_quick_toggle(true); - assert!( - agg.pending_release, - "first quick-toggle should switch from remote to local pending-release mode" - ); - assert!(!agg.released); - - agg.observe_quick_toggle(true); - assert!( - agg.pending_release, - "holding the quick-toggle key should not retrigger mode switching" - ); - - agg.released = true; - agg.pending_release = false; - agg.observe_quick_toggle(false); - agg.observe_quick_toggle(true); - assert!( - !agg.released, - "second rising edge should return to remote mode" - ); - assert!( - !agg.pending_release, - "remote-mode transition should clear pending release state" - ); - } - - #[test] - fn observe_quick_toggle_honors_debounce_window() { - let mut agg = new_aggregator(); - agg.quick_toggle_debounce = Duration::from_secs(60); - - agg.released = true; - agg.pending_release = false; - agg.observe_quick_toggle(true); - assert!(!agg.released, "first edge should switch to remote"); - - agg.released = true; - agg.pending_release = false; - agg.observe_quick_toggle(false); - agg.observe_quick_toggle(true); - assert!( - agg.released, - "second edge inside debounce window should be ignored" - ); - } } diff --git a/testing/tests/client_inputs_toggle_contract.rs b/testing/tests/client_inputs_toggle_contract.rs new file mode 100644 index 0000000..4126e9c --- /dev/null +++ b/testing/tests/client_inputs_toggle_contract.rs @@ -0,0 +1,81 @@ +//! Quick-toggle routing coverage for client input aggregation. +//! +//! Scope: include the input aggregator source and exercise swap-key edge +//! detection without creating real input devices. +//! Targets: `client/src/input/inputs.rs`. +//! Why: the swap key must not flap routing when held or bounced. + +mod layout { + pub use lesavka_client::layout::*; +} + +mod keyboard { + pub use lesavka_client::input::keyboard::*; +} + +mod mouse { + pub use lesavka_client::input::mouse::*; +} + +#[allow(warnings)] +mod inputs_toggle_contract { + include!(env!("LESAVKA_CLIENT_INPUTS_SRC")); + + fn new_aggregator() -> InputAggregator { + let (kbd_tx, _) = tokio::sync::broadcast::channel(32); + let (mou_tx, _) = tokio::sync::broadcast::channel(32); + InputAggregator::new(false, kbd_tx, mou_tx, None) + } + + #[test] + fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() { + let mut agg = new_aggregator(); + agg.quick_toggle_debounce = Duration::from_millis(0); + + agg.observe_quick_toggle(true); + assert!( + agg.pending_release, + "first quick-toggle should switch from remote to local pending-release mode" + ); + assert!(!agg.released); + + agg.observe_quick_toggle(true); + assert!( + agg.pending_release, + "holding the quick-toggle key should not retrigger mode switching" + ); + + agg.released = true; + agg.pending_release = false; + agg.observe_quick_toggle(false); + agg.observe_quick_toggle(true); + assert!( + !agg.released, + "second rising edge should return to remote mode" + ); + assert!( + !agg.pending_release, + "remote-mode transition should clear pending release state" + ); + } + + #[test] + fn observe_quick_toggle_honors_debounce_window() { + let mut agg = new_aggregator(); + agg.quick_toggle_debounce = Duration::from_secs(60); + + agg.released = true; + agg.pending_release = false; + agg.observe_quick_toggle(true); + assert!(!agg.released, "first edge should switch to remote"); + + agg.released = true; + agg.pending_release = false; + agg.observe_quick_toggle(false); + agg.observe_quick_toggle(true); + assert!( + agg.released, + "second edge inside debounce window should be ignored" + ); + } +} diff --git a/testing/tests/client_keyboard_process_contract.rs b/testing/tests/client_keyboard_process_contract.rs new file mode 100644 index 0000000..388a9e4 --- /dev/null +++ b/testing/tests/client_keyboard_process_contract.rs @@ -0,0 +1,136 @@ +//! Keyboard event-processing coverage for swallowed and live-report paths. +//! +//! Scope: include keyboard aggregation and drive synthetic evdev updates through +//! `process_events`/`drain_key_updates`. +//! Targets: `client/src/input/keyboard.rs`. +//! Why: paste chords must be swallowed cleanly while normal keys keep flowing. + +mod keymap { + pub use lesavka_client::input::keymap::*; +} + +#[allow(warnings)] +mod keyboard_process_contract { + include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC")); + + use evdev::AttributeSet; + use evdev::uinput::VirtualDevice; + use serial_test::serial; + use std::thread; + use temp_env::{with_var, with_vars}; + + fn open_virtual_device(vdev: &mut VirtualDevice) -> Option { + for _ in 0..40 { + if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { + if let Some(Ok(path)) = nodes.next() { + if let Ok(dev) = evdev::Device::open(path) { + let _ = dev.set_nonblocking(true); + return Some(dev); + } + } + } + thread::sleep(std::time::Duration::from_millis(10)); + } + None + } + + fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { + let mut keys = AttributeSet::::new(); + for key in [ + evdev::KeyCode::KEY_A, + evdev::KeyCode::KEY_V, + evdev::KeyCode::KEY_LEFTCTRL, + evdev::KeyCode::KEY_LEFTALT, + ] { + keys.insert(key); + } + + let mut vdev = VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .build() + .ok()?; + let dev = open_virtual_device(&mut vdev)?; + Some((vdev, dev)) + } + + #[test] + #[cfg(coverage)] + #[serial] + fn process_events_skips_swallowed_paste_chord_updates() { + let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-process-paste") else { + return; + }; + let (tx, mut rx) = tokio::sync::broadcast::channel(64); + let mut agg = KeyboardAggregator::new(dev, true, tx, None); + agg.paste_enabled = true; + agg.paste_rpc_enabled = false; + + with_vars( + [ + ("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v")), + ("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0")), + ("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'")), + ], + || { + vdev.emit(&[ + evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_LEFTCTRL.0, + 1, + ), + evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_LEFTALT.0, + 1, + ), + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_V.0, 1), + ]) + .expect("emit paste chord"); + thread::sleep(std::time::Duration::from_millis(25)); + agg.process_events(); + }, + ); + + let reports: Vec> = + std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); + assert!( + reports.iter().any(|report| report == &vec![0; 8]), + "swallowed paste chord should publish empty guard reports" + ); + assert!( + reports + .iter() + .all(|report| report.get(2).copied() != Some(evdev::KeyCode::KEY_V.0 as u8)), + "literal V should not leak after the paste chord is swallowed" + ); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn drain_key_updates_covers_dev_mode_logging_and_env_disable() { + let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-process-live") else { + return; + }; + let (tx, _rx) = tokio::sync::broadcast::channel(16); + + with_var("LESAVKA_CLIPBOARD_PASTE", Some("0"), || { + let mut agg = KeyboardAggregator::new(dev, true, tx, None); + assert!(!agg.paste_enabled); + vdev.emit(&[evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_A.0, + 1, + )]) + .expect("emit live key"); + thread::sleep(std::time::Duration::from_millis(25)); + + let updates = agg.drain_key_updates(); + assert_eq!(updates.len(), 1); + assert!(!updates[0].swallowed); + }); + } +} diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index b8285ce..ed02768 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -2,15 +2,21 @@ //! //! Scope: statically guard the GTK layout constants and sizing glue used by //! the launcher shell. -//! Targets: `client/src/launcher/ui_components.rs`. +//! Targets: split `client/src/launcher/ui_components/*` layout modules. //! Why: the launcher is an operational control surface; accidental spacing //! regressions can hide diagnostics or make eye/device previews unusable. -const UI_SRC: &str = include_str!("../../client/src/launcher/ui_components.rs"); +const UI_LAYOUT_SRC: &str = concat!( + include_str!("../../client/src/launcher/ui_components/types.rs"), + include_str!("../../client/src/launcher/ui_components/build_shell.rs"), + include_str!("../../client/src/launcher/ui_components/display_pane.rs"), + include_str!("../../client/src/launcher/ui_components/build_device_controls.rs"), + include_str!("../../client/src/launcher/ui_components/build_operations_rail.rs"), +); fn const_i32(name: &str) -> i32 { let needle = format!("const {name}: i32 = "); - let line = UI_SRC + let line = UI_LAYOUT_SRC .lines() .find(|line| line.trim_start().starts_with(&needle)) .unwrap_or_else(|| panic!("missing {name} constant")); @@ -22,7 +28,7 @@ fn const_i32(name: &str) -> i32 { } fn source_index(needle: &str) -> usize { - UI_SRC + UI_LAYOUT_SRC .find(needle) .unwrap_or_else(|| panic!("missing source marker: {needle}")) } @@ -40,12 +46,12 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() { assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568); assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320); assert!( - UI_SRC.contains("caption_label.set_halign(gtk::Align::End)") - || UI_SRC.contains("capture_label.set_halign(gtk::Align::End)") + UI_LAYOUT_SRC.contains("caption_label.set_halign(gtk::Align::End)") + || UI_LAYOUT_SRC.contains("capture_label.set_halign(gtk::Align::End)") ); - assert!(UI_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);")); - assert!(!UI_SRC.contains("root.append(&stream_status);")); - assert!(UI_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");")); + assert!(UI_LAYOUT_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);")); + assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);")); + assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");")); assert!( source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);") < source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);") @@ -58,45 +64,47 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() { #[test] fn device_staging_and_testing_bottoms_stay_locked_together() { - assert!(UI_SRC.contains("staging_row.set_homogeneous(true);")); - assert!(UI_SRC.contains("staging_row.set_vexpand(false);")); - assert!(UI_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains( + assert!(UI_LAYOUT_SRC.contains("staging_row.set_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("staging_row.set_vexpand(false);")); + assert!(UI_LAYOUT_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains( "let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);" )); - assert!(UI_SRC.contains("device_body_height_group.add_widget(&devices_body);")); - assert!(UI_SRC.contains("device_body_height_group.add_widget(&testing_row);")); + assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&devices_body);")); + assert!(UI_LAYOUT_SRC.contains("device_body_height_group.add_widget(&testing_row);")); } #[test] fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() { assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 280); assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 158); - assert!(UI_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains("playback_group.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains("preview_body.set_vexpand(false);")); - assert!(UI_SRC.contains("playback_body.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);")); - assert!(UI_SRC.contains("playback_body.append(&audio_check_meter);")); - assert!(UI_SRC.contains("playback_body.append(µphone_replay_button);")); + assert!(UI_LAYOUT_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("playback_group.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("preview_body.set_vexpand(false);")); + assert!(UI_LAYOUT_SRC.contains("playback_body.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("audio_check_meter.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("playback_body.append(&audio_check_meter);")); + assert!(UI_LAYOUT_SRC.contains("playback_body.append(µphone_replay_button);")); } #[test] fn operations_column_fills_height_and_splits_extra_space_between_logs() { assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124); - assert!(UI_SRC.contains("operations.set_vexpand(true);")); - assert!(UI_SRC.contains("operations.set_valign(gtk::Align::Fill);")); - assert!(UI_SRC.contains("diagnostics_panel.set_vexpand(true);")); - assert!(UI_SRC.contains("console_panel.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);")); + assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("console_panel.set_vexpand(true);")); assert_eq!( - UI_SRC + UI_LAYOUT_SRC .matches(".min_content_height(SIDE_LOG_MIN_HEIGHT)") .count(), 2 ); assert_eq!( - UI_SRC.matches(".max_content_height(SIDE_LOG").count(), + UI_LAYOUT_SRC + .matches(".max_content_height(SIDE_LOG") + .count(), 0, "the docked logs must be allowed to split extra right-rail height" ); @@ -105,12 +113,13 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() { #[test] fn session_console_buttons_share_the_remaining_toolbar_width() { assert!( - UI_SRC.contains("let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + UI_LAYOUT_SRC + .contains("let console_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") ); - assert!(UI_SRC.contains("console_buttons.set_hexpand(true);")); - assert!(UI_SRC.contains("console_buttons.set_homogeneous(true);")); - assert!(UI_SRC.contains("console_copy_button.set_hexpand(true);")); - assert!(UI_SRC.contains("console_popout_button.set_hexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("console_buttons.set_hexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("console_buttons.set_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("console_copy_button.set_hexpand(true);")); + assert!(UI_LAYOUT_SRC.contains("console_popout_button.set_hexpand(true);")); assert!( source_index("console_toolbar.append(&console_level_combo);") < source_index("console_toolbar.append(&console_buttons);") @@ -119,12 +128,14 @@ fn session_console_buttons_share_the_remaining_toolbar_width() { #[test] fn relay_controls_keep_connect_inline_with_server_entry() { - assert!(UI_SRC.contains("build_panel(\"Relay Controls\")")); - assert!(UI_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); - assert!(UI_SRC.contains("relay_row.append(&server_entry);")); - assert!(UI_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");")); - assert!(UI_SRC.contains("stabilize_button(&start_button, 108);")); - assert!(UI_SRC.contains("relay_row.append(&start_button);")); + assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")")); + assert!( + UI_LAYOUT_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + ); + assert!(UI_LAYOUT_SRC.contains("relay_row.append(&server_entry);")); + assert!(UI_LAYOUT_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");")); + assert!(UI_LAYOUT_SRC.contains("stabilize_button(&start_button, 108);")); + assert!(UI_LAYOUT_SRC.contains("relay_row.append(&start_button);")); assert!( source_index("relay_row.append(&server_entry);") < source_index("relay_row.append(&start_button);") @@ -133,59 +144,63 @@ fn relay_controls_keep_connect_inline_with_server_entry() { #[test] fn media_controls_own_stream_toggles_and_inline_gain_controls() { - assert!(!UI_SRC.contains("Remote Audio")); - assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);")); - assert!(!UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); - assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Camera\")")); - assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")")); - assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Speaker\")")); - assert!(UI_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();")); - assert!(UI_SRC.contains("sync_camera_quality_combo(")); - assert!(UI_SRC.contains("camera_quality_combo.set_size_request(88, -1);")); + assert!(!UI_LAYOUT_SRC.contains("Remote Audio")); assert!( - UI_SRC.contains("let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + UI_LAYOUT_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);") ); - assert!(UI_SRC.contains("camera_selectors.append(&camera_combo);")); - assert!(UI_SRC.contains("camera_selectors.append(&camera_quality_combo);")); + assert!(!UI_LAYOUT_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); + assert!(UI_LAYOUT_SRC.contains("gtk::CheckButton::with_label(\"Camera\")")); + assert!(UI_LAYOUT_SRC.contains("gtk::CheckButton::with_label(\"Mic\")")); + assert!(UI_LAYOUT_SRC.contains("gtk::CheckButton::with_label(\"Speaker\")")); + assert!(UI_LAYOUT_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();")); + assert!(UI_LAYOUT_SRC.contains("sync_camera_quality_combo(")); + assert!(UI_LAYOUT_SRC.contains("camera_quality_combo.set_size_request(88, -1);")); + assert!( + UI_LAYOUT_SRC + .contains("let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + ); + assert!(UI_LAYOUT_SRC.contains("camera_selectors.append(&camera_combo);")); + assert!(UI_LAYOUT_SRC.contains("camera_selectors.append(&camera_quality_combo);")); assert!( source_index("camera_selectors.append(&camera_combo);") < source_index("camera_selectors.append(&camera_quality_combo);") ); assert!( - UI_SRC.contains("let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + UI_LAYOUT_SRC + .contains("let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") ); - assert!(UI_SRC.contains("speaker_selectors.append(&speaker_combo);")); - assert!(UI_SRC.contains("speaker_selectors.append(&audio_gain_scale);")); + assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&speaker_combo);")); + assert!(UI_LAYOUT_SRC.contains("speaker_selectors.append(&audio_gain_scale);")); assert!( - UI_SRC + UI_LAYOUT_SRC .contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") ); - assert!(UI_SRC.contains("microphone_selectors.append(µphone_combo);")); - assert!(UI_SRC.contains("microphone_selectors.append(&mic_gain_scale);")); + assert!(UI_LAYOUT_SRC.contains("microphone_selectors.append(µphone_combo);")); + assert!(UI_LAYOUT_SRC.contains("microphone_selectors.append(&mic_gain_scale);")); assert_eq!( - UI_SRC + UI_LAYOUT_SRC .matches("attach_device_control_row(\n &media_grid") .count(), 3 ); - assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")")); - assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")")); - assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")")); - assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")); - assert!(UI_SRC.contains("power_row.append(&power_heading);")); - assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);")); - assert!(UI_SRC.contains("let audio_gain_scale =")); - assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);")); - assert!(UI_SRC.contains("audio_gain_scale.set_size_request(96, -1);")); - assert!(UI_SRC.contains("let mic_gain_scale =")); - assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);")); - assert!(UI_SRC.contains("mic_gain_scale.set_size_request(96, -1);")); - assert!(!UI_SRC.contains("audio_gain_row.set_size_request(220, -1);")); - assert!(!UI_SRC.contains("mic_gain_row.set_size_request(220, -1);")); - assert!(!UI_SRC.contains("power_shell.append(&audio_gain_row);")); - assert!(!UI_SRC.contains("power_shell.append(&mic_gain_row);")); + assert!(!UI_LAYOUT_SRC.contains("camera_combo.append(Some(\"auto\")")); + assert!(!UI_LAYOUT_SRC.contains("speaker_combo.append(Some(\"auto\")")); + assert!(!UI_LAYOUT_SRC.contains("microphone_combo.append(Some(\"auto\")")); + assert!(UI_LAYOUT_SRC.contains("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")); + assert!(UI_LAYOUT_SRC.contains("power_row.append(&power_heading);")); + assert!(UI_LAYOUT_SRC.contains("power_buttons.set_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("let audio_gain_scale =")); + assert!(UI_LAYOUT_SRC.contains("audio_gain_scale.set_draw_value(false);")); + assert!(UI_LAYOUT_SRC.contains("audio_gain_scale.set_size_request(96, -1);")); + assert!(UI_LAYOUT_SRC.contains("let mic_gain_scale =")); + assert!(UI_LAYOUT_SRC.contains("mic_gain_scale.set_draw_value(false);")); + assert!(UI_LAYOUT_SRC.contains("mic_gain_scale.set_size_request(96, -1);")); + assert!(!UI_LAYOUT_SRC.contains("audio_gain_row.set_size_request(220, -1);")); + assert!(!UI_LAYOUT_SRC.contains("mic_gain_row.set_size_request(220, -1);")); + assert!(!UI_LAYOUT_SRC.contains("power_shell.append(&audio_gain_row);")); + assert!(!UI_LAYOUT_SRC.contains("power_shell.append(&mic_gain_row);")); assert_eq!( - UI_SRC + UI_LAYOUT_SRC .matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));") .count(), 2, @@ -199,5 +214,5 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { source_index("power_shell.append(&power_row);") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); - assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("routing_buttons.set_homogeneous(true);")); } diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index c5aac38..7f628a1 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -1,19 +1,52 @@ //! Contract tests for launcher-owned relay process lifetime. //! //! Scope: static guardrails around launcher runtime process management. -//! Targets: `client/src/launcher/ui_runtime.rs`. +//! Targets: split launcher UI/runtime modules under `client/src/launcher/`. //! Why: the launcher is the owner of the live relay. If it crashes, audio, //! video, and input streams must not keep running as leaked child processes. -const UI_RUNTIME_SRC: &str = include_str!("../../client/src/launcher/ui_runtime.rs"); -const UI_SRC: &str = include_str!("../../client/src/launcher/ui.rs"); -const DEVICE_TEST_SRC: &str = include_str!("../../client/src/launcher/device_test.rs"); -const CAMERA_SRC: &str = include_str!("../../client/src/input/camera.rs"); +const UI_RUNTIME_SRC: &str = concat!( + include_str!("../../client/src/launcher/ui_runtime.rs"), + include_str!("../../client/src/launcher/ui_runtime/control_paths.rs"), + include_str!("../../client/src/launcher/ui_runtime/process_logs.rs"), + include_str!("../../client/src/launcher/ui_runtime/status_refresh.rs"), + include_str!("../../client/src/launcher/ui_runtime/status_details.rs"), +); +const UI_SRC: &str = concat!( + include_str!("../../client/src/launcher/ui.rs"), + include_str!("../../client/src/launcher/ui/message_and_network_state.rs"), + include_str!("../../client/src/launcher/ui/control_requests.rs"), + include_str!("../../client/src/launcher/ui/activation_setup.rs"), + include_str!("../../client/src/launcher/ui/device_refresh_binding.rs"), + include_str!("../../client/src/launcher/ui/local_test_bindings.rs"), + include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"), + include_str!("../../client/src/launcher/ui/runtime_poll.rs"), + include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"), +); +const DEVICE_TEST_SRC: &str = concat!( + include_str!("../../client/src/launcher/device_test.rs"), + include_str!("../../client/src/launcher/device_test/controller.rs"), + include_str!("../../client/src/launcher/device_test/local_preview.rs"), + include_str!("../../client/src/launcher/device_test/pipeline_helpers.rs"), +); +const CAMERA_SRC: &str = concat!( + include_str!("../../client/src/input/camera.rs"), + include_str!("../../client/src/input/camera/capture_pipeline.rs"), + include_str!("../../client/src/input/camera/encoder_selection.rs"), + include_str!("../../client/src/input/camera/preview_tap.rs"), + include_str!("../../client/src/input/camera/source_description.rs"), +); const MICROPHONE_SRC: &str = include_str!("../../client/src/input/microphone.rs"); const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs"); const MAIN_SRC: &str = include_str!("../../client/src/main.rs"); -const UI_COMPONENTS_SRC: &str = include_str!("../../client/src/launcher/ui_components.rs"); -const PREVIEW_SRC: &str = include_str!("../../client/src/launcher/preview.rs"); +const UI_COMPONENTS_SRC: &str = concat!( + include_str!("../../client/src/launcher/ui_components.rs"), + include_str!("../../client/src/launcher/ui_components/build_shell.rs"), +); +const PREVIEW_SRC: &str = concat!( + include_str!("../../client/src/launcher/preview.rs"), + include_str!("../../client/src/launcher/preview/status_pipeline.rs"), +); #[test] fn relay_child_gets_parent_identity_from_launcher() { @@ -112,6 +145,7 @@ fn active_relay_keeps_local_upstream_camera_and_microphone_evidence_visible() { #[test] fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { assert!(UI_SRC.contains("selected_camera_quality(&camera_quality_combo")); + assert!(UI_SRC.contains("selected_camera_quality(&camera_quality_combo_read")); assert!(UI_SRC.contains("sync_camera_quality_selection")); assert!(UI_SRC.contains("let camera_quality_syncing = Rc::new(Cell::new(false));")); assert!(UI_SRC.contains("camera_quality_syncing.set(true);")); diff --git a/testing/tests/client_mouse_uinput_contract.rs b/testing/tests/client_mouse_uinput_contract.rs index bf2d5ce..9a60d95 100644 --- a/testing/tests/client_mouse_uinput_contract.rs +++ b/testing/tests/client_mouse_uinput_contract.rs @@ -21,11 +21,11 @@ use tokio::sync::broadcast; fn open_virtual_node(vdev: &mut VirtualDevice) -> Option { let mut node = None; for _ in 0..40 { - if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { - if let Some(Ok(path)) = nodes.next() { - node = Some(path); - break; - } + if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() + && let Some(Ok(path)) = nodes.next() + { + node = Some(path); + break; } thread::sleep(Duration::from_millis(10)); } diff --git a/testing/tests/client_output_display_include_contract.rs b/testing/tests/client_output_display_include_contract.rs index dfcbf41..dd8c1f9 100644 --- a/testing/tests/client_output_display_include_contract.rs +++ b/testing/tests/client_output_display_include_contract.rs @@ -80,6 +80,7 @@ mod gtk { } impl Object { + #[allow(clippy::extra_unused_type_parameters)] pub fn downcast(self) -> Result { match self { Self::Monitor(monitor) => Ok(monitor), diff --git a/testing/tests/client_output_video_include_contract.rs b/testing/tests/client_output_video_include_contract.rs index a134d5a..893feab 100644 --- a/testing/tests/client_output_video_include_contract.rs +++ b/testing/tests/client_output_video_include_contract.rs @@ -118,6 +118,12 @@ mod video_include_contract { with_var("LESAVKA_H264_DECODER", None::<&str>, || { assert!(!pick_h264_decoder().trim().is_empty()); }); + #[cfg(coverage)] + with_var("LESAVKA_TEST_DISABLE_H264_DECODERS", Some("1"), || { + with_var("LESAVKA_H264_DECODER", None::<&str>, || { + assert_eq!(pick_h264_decoder(), "decodebin"); + }); + }); assert!(buildable_decoder("fakesink")); assert!(!buildable_decoder("definitely-not-a-real-gst-element")); } diff --git a/testing/tests/server_audio_include_contract.rs b/testing/tests/server_audio_include_contract.rs index e62d675..7016c7e 100644 --- a/testing/tests/server_audio_include_contract.rs +++ b/testing/tests/server_audio_include_contract.rs @@ -1,8 +1,8 @@ //! Integration coverage for server audio capture/sink plumbing. //! -//! Scope: compile `server/src/audio.rs` as a module and exercise public audio +//! Scope: compile the split server audio module and exercise public audio //! constructors/helpers across deterministic error and smoke paths. -//! Targets: `server/src/audio.rs`. +//! Targets: `server/src/audio.rs` plus `server/src/audio/*`. //! Why: audio pipeline setup is branchy and should stay stable without requiring //! physical ALSA/UAC hardware in CI. @@ -19,7 +19,11 @@ mod tests { use lesavka_common::lesavka::AudioPacket; use serial_test::serial; - const AUDIO_SRC: &str = include_str!("../../server/src/audio.rs"); + const AUDIO_SRC: &str = concat!( + include_str!("../../server/src/audio.rs"), + include_str!("../../server/src/audio/ear_capture.rs"), + include_str!("../../server/src/audio/voice_input.rs"), + ); fn source_index(needle: &str) -> usize { AUDIO_SRC @@ -52,6 +56,20 @@ mod tests { let _ = pipeline.set_state(gst::State::Null); } + #[cfg(coverage)] + #[test] + #[serial] + fn start_pipeline_or_reset_forced_failure_resets_pipeline() { + let _ = gst::init(); + let pipeline = gst::Pipeline::new(); + temp_env::with_var("LESAVKA_TEST_FORCE_PIPELINE_START_ERROR", Some("1"), || { + let err = start_pipeline_or_reset(&pipeline, "starting forced contract pipeline") + .expect_err("forced coverage error"); + assert!(err.to_string().contains("forced test failure")); + }); + assert_eq!(pipeline.current_state(), gst::State::Null); + } + #[test] #[serial] fn ear_rejects_malformed_pipeline_device_string() { @@ -77,9 +95,8 @@ mod tests { let result = rt.block_on(async { tokio::time::timeout(std::time::Duration::from_millis(250), ear("/dev/null", 0)).await }); - match result { - Ok(Ok(stream)) => drop(stream), - Ok(Err(_)) | Err(_) => {} + if let Ok(Ok(stream)) = result { + drop(stream) } } diff --git a/testing/tests/server_main_eye_hub_contract.rs b/testing/tests/server_main_eye_hub_contract.rs index ce38c3e..9dba1b9 100644 --- a/testing/tests/server_main_eye_hub_contract.rs +++ b/testing/tests/server_main_eye_hub_contract.rs @@ -134,4 +134,106 @@ mod server_main_eye_hub { }); }); } + + #[test] + #[cfg(coverage)] + fn eye_hub_forwarder_handles_dropped_downstream() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + with_capture_power_disabled(|| { + rt.block_on(async { + let hub = EyeHub::spawn( + stream::pending::>(), + CapturePowerManager::new().acquire().await, + ); + hub.subscribers + .store(1, std::sync::atomic::Ordering::Relaxed); + let (source_tx, source_rx) = tokio::sync::broadcast::channel(1); + let (out_tx, out_rx) = tokio::sync::mpsc::channel(1); + drop(out_rx); + + source_tx + .send(VideoPacket { + id: 0, + pts: 1, + data: vec![1], + ..Default::default() + }) + .expect("send packet"); + forward_eye_hub_packets( + 9, + source_rx, + out_tx, + std::sync::Arc::clone(&hub.subscribers), + std::sync::Arc::clone(&hub), + ) + .await; + + assert_eq!( + hub.subscribers.load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed)); + }); + }); + } + + #[test] + #[cfg(coverage)] + fn eye_hub_forwarder_skips_lagged_frames_and_closes_cleanly() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + with_capture_power_disabled(|| { + rt.block_on(async { + let hub = EyeHub::spawn( + stream::pending::>(), + CapturePowerManager::new().acquire().await, + ); + hub.subscribers + .store(1, std::sync::atomic::Ordering::Relaxed); + let (source_tx, source_rx) = tokio::sync::broadcast::channel(1); + let (out_tx, mut out_rx) = tokio::sync::mpsc::channel(2); + + source_tx + .send(VideoPacket { + id: 0, + pts: 1, + data: vec![1], + ..Default::default() + }) + .expect("send first packet"); + source_tx + .send(VideoPacket { + id: 0, + pts: 2, + data: vec![2], + ..Default::default() + }) + .expect("send second packet"); + drop(source_tx); + + forward_eye_hub_packets( + 7, + source_rx, + out_tx, + std::sync::Arc::clone(&hub.subscribers), + std::sync::Arc::clone(&hub), + ) + .await; + + let packet = out_rx + .recv() + .await + .expect("forwarded packet") + .expect("packet"); + assert_eq!(packet.id, 7); + assert_eq!(packet.pts, 2); + assert_eq!(packet.data, vec![2]); + assert!(out_rx.recv().await.is_none()); + assert_eq!( + hub.subscribers.load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed)); + }); + }); + } } diff --git a/testing/tests/server_uvc_binary_extra_contract.rs b/testing/tests/server_uvc_binary_extra_contract.rs index c6f2c55..db4b9ad 100644 --- a/testing/tests/server_uvc_binary_extra_contract.rs +++ b/testing/tests/server_uvc_binary_extra_contract.rs @@ -201,6 +201,142 @@ mod uvc_binary_extra { assert_eq!(out, state.default); } + #[test] + fn control_length_updates_only_for_supported_changed_sizes() { + let mut state = UvcState::new(sample_cfg()); + let original = state.ctrl_len; + + maybe_update_ctrl_len(&mut state, 99, true); + assert_eq!(state.ctrl_len, original); + + maybe_update_ctrl_len(&mut state, original as u16, true); + assert_eq!(state.ctrl_len, original); + + let next = if original == STREAM_CTRL_SIZE_11 { + STREAM_CTRL_SIZE_15 + } else { + STREAM_CTRL_SIZE_11 + }; + maybe_update_ctrl_len(&mut state, next as u16, true); + assert_eq!(state.ctrl_len, next); + } + + #[test] + fn streaming_response_handles_info_commit_and_unknown_selectors() { + let state = UvcState::new(sample_cfg()); + + assert_eq!( + build_streaming_response(&state, UVC_VS_PROBE_CONTROL, UVC_GET_INFO), + Some(vec![0x03]) + ); + assert_eq!( + build_streaming_response(&state, UVC_VS_COMMIT_CONTROL, UVC_GET_CUR) + .map(|payload| payload.len()), + Some(state.ctrl_len) + ); + assert!(build_streaming_response(&state, 0xFE, UVC_GET_CUR).is_none()); + } + + #[test] + fn sanitize_streaming_control_accepts_set_bits_and_payload_limits() { + let state = UvcState::new(sample_cfg()); + let mut payload = [0u8; UVC_DATA_SIZE]; + payload[2] = 1; + payload[3] = 1; + payload[4..8].copy_from_slice(&333_333u32.to_le_bytes()); + payload[22..26].copy_from_slice(&(state.cfg.max_packet + 500).to_le_bytes()); + + let out = sanitize_streaming_control(&payload, &state); + assert_eq!(out[2], 1); + assert_eq!(out[3], 1); + assert_eq!(read_le32(&out, 4), 333_333); + assert_eq!(read_le32(&out, 22), state.cfg.max_packet); + } + + #[test] + fn sanitize_streaming_control_keeps_zero_fields_at_defaults() { + let state = UvcState::new(sample_cfg()); + let mut payload = [0u8; UVC_DATA_SIZE]; + payload[2] = 0; + payload[3] = 0; + payload[4..8].copy_from_slice(&0u32.to_le_bytes()); + payload[22..26].copy_from_slice(&0u32.to_le_bytes()); + + let out = sanitize_streaming_control(&payload, &state); + assert_eq!(out[2], state.default[2]); + assert_eq!(out[3], state.default[3]); + assert_eq!(read_le32(&out, 4), read_le32(&state.default, 4)); + assert_eq!(read_le32(&out, 22), read_le32(&state.default, 22)); + } + + #[test] + #[cfg(coverage)] + fn parse_device_arg_accepts_flags_and_positional_paths() { + assert_eq!( + parse_device_arg(&["--device".to_string(), "/dev/video7".to_string()]), + Some("/dev/video7".to_string()) + ); + assert_eq!( + parse_device_arg(&["-d".to_string(), "/dev/video8".to_string()]), + Some("/dev/video8".to_string()) + ); + assert_eq!( + parse_device_arg(&["--debug".to_string(), "/dev/video9".to_string()]), + Some("/dev/video9".to_string()) + ); + assert_eq!(parse_device_arg(&["--device".to_string()]), None); + } + + #[test] + fn handle_data_updates_commit_and_ignores_unknown_streaming_selector() { + let interfaces = sample_interfaces(); + let mut state = UvcState::new(sample_cfg()); + let original_commit = state.commit; + let mut payload = [0u8; UVC_DATA_SIZE]; + payload[4..8].copy_from_slice(&222_222u32.to_le_bytes()); + + let mut pending = Some(PendingRequest { + interface: interfaces.streaming, + selector: UVC_VS_COMMIT_CONTROL, + expected_len: STREAM_CTRL_SIZE_11, + }); + handle_data( + -1, + 0, + &mut state, + &mut pending, + interfaces, + UvcRequestData { + length: STREAM_CTRL_SIZE_11 as i32, + data: payload, + }, + true, + ); + assert!(pending.is_none()); + assert_ne!(state.commit, original_commit); + + let after_commit = state.commit; + let mut pending = Some(PendingRequest { + interface: interfaces.streaming, + selector: 0xFE, + expected_len: STREAM_CTRL_SIZE_11, + }); + handle_data( + -1, + 0, + &mut state, + &mut pending, + interfaces, + UvcRequestData { + length: STREAM_CTRL_SIZE_11 as i32, + data: payload, + }, + true, + ); + assert!(pending.is_none()); + assert_eq!(state.commit, after_commit); + } + #[test] fn io_helpers_return_none_for_empty_or_missing_input() { let empty = NamedTempFile::new().expect("tmp"); diff --git a/testing/tests/server_video_sinks_include_contract.rs b/testing/tests/server_video_sinks_include_contract.rs index a41fa4a..53815a5 100644 --- a/testing/tests/server_video_sinks_include_contract.rs +++ b/testing/tests/server_video_sinks_include_contract.rs @@ -169,12 +169,33 @@ mod video_sinks_include_contract { #[test] #[serial] fn bool_env_parser_accepts_operator_friendly_values() { + with_var("LESAVKA_BOOL_TEST", None::<&str>, || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), None); + }); with_var("LESAVKA_BOOL_TEST", Some("yes"), || { assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true)); }); + with_var("LESAVKA_BOOL_TEST", Some("TRUE"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true)); + }); + with_var("LESAVKA_BOOL_TEST", Some("1"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true)); + }); + with_var("LESAVKA_BOOL_TEST", Some("on"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true)); + }); with_var("LESAVKA_BOOL_TEST", Some("off"), || { assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false)); }); + with_var("LESAVKA_BOOL_TEST", Some("0"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false)); + }); + with_var("LESAVKA_BOOL_TEST", Some("false"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false)); + }); + with_var("LESAVKA_BOOL_TEST", Some("no"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false)); + }); with_var("LESAVKA_BOOL_TEST", Some("shrug"), || { assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), None); });